Bidirectional docx/pptx ⇄ PDF conversion, a read-and-write live-view editor for docx/pptx content, and a fully hand-written PDF codec, built on ooxml.js.
documents.js depends on ooxml.js for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions ooxml.js deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-and-write manipulation API for docx/pptx content — ooxml.js's own typed readers (readDocx/readPptx) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are entirely hand-written: no external PDF library (pdf-lib, pdfjs-dist, mupdf, or any other) is a dependency. The one exception is fflate for raw DEFLATE/zlib compression underneath PDF's FlateDecode filter and PNG's IDAT chunks — the same dependency ooxml.js itself already relies on for ZIP handling.
Converting docx/pptx to PDF and back is usually solved by wrapping a mature third-party PDF library. This package takes the opposite approach: every layer of the PDF format — the object model, the cross-reference table, the content-stream operators, standard-font metrics, the parser's cross-reference/object-stream resolution and content-stream interpreter — is hand-written against the ISO 32000-1 specification. That is a genuinely large undertaking (the PDF codec is comparable in size to the rest of the package combined), and it comes with an honest trade-off spelled out in Fidelity below: this is not, and does not attempt to be, as robust against adversarial or badly malformed real-world PDFs as a library with 15+ years of hardening. What it buys instead is a dependency-free, fully auditable PDF implementation with no supply-chain surface beyond ooxml.js and fflate.
The read-and-write editor exists because ooxml.js's own typed readers are a deliberate one-way, lossy projection — reading is fine, but there is no way to add a paragraph, style a run, or insert an image and get a valid docx/pptx back out. documents.js's editors are live views directly over the XmlElement objects inside a decoded Package: a mutation edits that tree in place, and everything you don't touch round-trips byte-faithful, because it never stopped being the original XML.
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 documents.js
# or
npm install documents.jsThe four ergonomic conversions:
import { docxToPdf, pdfToDocx, pptxToPdf, pdfToPptx } from 'documents.js';
const pdfBytes = docxToPdf(docxBytes);
const docxBytes2 = pdfToDocx(pdfBytes);
const pdfFromSlides = pptxToPdf(pptxBytes);
const pptxBytes2 = pdfToPptx(pdfFromSlides);Each accepts an optional signal (AbortSignal) and either a onSubstitution callback (docx/pptx → PDF, called once per character not representable in a standard-14 font) or a sink (PDF → docx/pptx, called once per recoverable parse diagnostic).
The same four conversions behind a swappable port, for a caller that wants to inject a different implementation later without changing call sites:
import { createLocalDocumentConverter } from 'documents.js';
const converter = createLocalDocumentConverter();
const { document, diagnostics } = await converter.convert(
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
{ signal: new AbortController().signal },
);Reading and editing docx/pptx content directly, without going through PDF at all:
import { openDocx, createDocx } from 'documents.js';
const editor = openDocx(existingDocxBytes);
const paragraph = editor.body.appendParagraph({ alignment: 'center' });
const run = paragraph.appendRun({ text: 'Hello' });
run.bold = true;
run.color = { r: 1, g: 0, b: 0 };
const bytes = editor.toBytes();
// or start from nothing:
const fresh = createDocx();
fresh.body.appendParagraph().appendRun({ text: 'New document' });openPptx/createPptx and PptxSlide/PptxShape are the pptx equivalent (slide.addTextBox, slide.addImage, shape.setParagraphs for multi-paragraph styled text).
Reading and writing PDF bytes directly, without going through docx/pptx:
import { readPdf, writePdf } from 'documents.js';
const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/link items
const bytes = writePdf(layout);The same three round trips (PDF ⇄ LayoutDocument, docx ⇄ PDF, pptx ⇄ PDF) are each also available as a schema-validated z.codec() pair, mirroring ooxml.js's own packageCodec — z.decode/z.encode validate both the raw bytes (against the magic-byte schemas below) and the parsed value (against LayoutDocumentSchema) on every call, catching a malformed value that a bare function call wouldn't. This is the no-extra-options form: readPdf/writePdf/docxToPdf/etc. remain the entry points for cancellation (signal), diagnostics (sink), or substitution reporting (onSubstitution), none of which fit z.codec()'s fixed decode(input)/encode(output) signature.
import { z } from 'zod';
import { docxPdfCodec, pdfCodec, pptxPdfCodec } from 'documents.js';
const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes has no %PDF- header
const pdfBytes2 = z.encode(pdfCodec, layout);
const pdfFromDocx = z.decode(docxPdfCodec, docxBytes);
const docxBack = z.encode(docxPdfCodec, pdfFromDocx);readDocxContent/readPptxContent (docx/pptx → ContentDocument), convertWordprocessingToLayout/convertPresentationToLayout (ContentDocument → LayoutDocument), and reconstructWordprocessing/reconstructPresentation (LayoutDocument → ContentDocument) are each exported individually too, for a caller that wants one stage of the pipeline without the rest.
The package is layered from generic primitives outward to the two conversion directions:
src/model/— thin, documents.js-specific additions on top of the siblingdocument-content-modelpackage, which now owns the two pivot models themselves:LayoutDocument(the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/link items, PDF-native coordinates and units) andContentDocument(the semantic pivot: a discriminated union ofwordprocessingandpresentationvariants sharing paragraph/run/table/image building blocks) are both imported, not defined here —document-content-modelexists specifically soooxml.js,odf.js, anddocuments.jsshare one schema instead of each maintaining an independent, drift-prone copy. What remains local:bytes.ts(magic-byte-validatedUint8Arrayschemas for docx/pptx/PDF, plusOdt/Ods/Odp/OdgBytesSchema, which check the package's actual declared media type againstodf.js'sODF_MEDIA_TYPEStable rather than only the generic ZIP signature the OOXML schemas are limited to),units.ts(OOXML EMU/twip/point/half-point conversions), andgeometry.ts/color.ts/style.ts, each now mostly a thin re-export ofdocument-content-model'sBox/Margins/PageSize/Color/Alignment/LayoutFont— the one genuinely PDF-specific piece each still adds locally isgeometry.ts'sflipY(the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates);LayoutFont/DEFAULT_LAYOUT_FONTmoved todocument-content-modeltoo (sinceLayoutText, part of the pivot, needs the field), leaving only the standard-14 font resolution logic that consumes it (src/pdf/fonts.ts/font-read.ts) as PDF-specific and local.src/bytes/andsrc/image/— generic byte and image-container primitives with zero PDF or OOXML knowledge: a chunked byte writer, a backtracking byte reader, CRC32, and a hand-written PNG decoder/encoder (palette/gray/RGB/alpha, multi-IDATfiles, all five scanline filters) plus JPEG marker scanning for dimensions only — JPEG's compressed bytes pass through completely unchanged in both directions.src/bytes/flate.tsis the only file that importsfflate, mirroring howooxml.js's ownsrc/zip.tswraps it for ZIP handling.src/xml/andsrc/opc/— parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built overooxml.js'sPackage/XmlNode, needed becauseooxml.js's own XML nodes have no parent pointers andooxml.jsnever writes new parts into an existing package.src/edit/— the read-and-write editable model: live-view classes (DocxEditor/DocxParagraph/DocxRun/DocxTable,PptxEditor/PptxSlide/PptxShape) wrapping the actualXmlElementobjects inside a decodedPackage, plusbuildDocxPackage/buildPptxPackagebridging aContentDocumentto a fresh package built entirely through those same primitives.src/pdf/— the hand-written PDF codec, importing onlymodel/bytes/image(no OOXML knowledge at all):- Write:
objects.ts(thePdfObjectdiscriminated union),afm-widths.ts/encoding.ts/winansi.ts/fonts.ts(standard-14 metrics, WinAnsi encoding, family resolution),measure.ts/text-layout.ts(greedy line-wrapping),matrix.ts,content-write.ts(LayoutItem[]→ content-stream operators),write.ts(the full object graph, classic cross-reference table, trailer). - Read:
lexer.ts/parse.ts(byte tokenizer and tokens →PdfObject),filters.ts/predictors.ts(Flate/LZW/ASCII85/ASCIIHex/RunLength, TIFF/PNG predictors),xref.ts/document.ts(classic and cross-reference-stream resolution, object streams,/Prevchains, linear-scan recovery, the page tree with attribute inheritance),content-read.ts/interpret.ts(the content-stream tokenizer and graphics/text state machine, including form-XObject recursion),cmap.ts/font-style.ts/font-read.ts(/ToUnicodeCMaps, font-dictionary resolution),images-read.ts(Image XObjects → PNG/JPEG bytes),read.ts(readPdf, assembling all of the above into aLayoutDocument). codec.ts—pdfCodec, az.codec()pair overreadPdf/writePdf(PDF bytes ⇄LayoutDocument).
- Write:
src/ooxml/— resolves aPackageinto aContentDocument:docx/read.tsandpptx/read.tsare now thin adapters overooxml.js's ownreadDocx/readPptx, wrapping their{ metadata, sections }/{ metadata, slides }result intoContentDocument'swordprocessing/presentationshape. The docx style cascade (docDefaults→ named-stylebasedOnchains → paragraph-mark run properties → character styles → direct formatting), the pptx placeholder → layout → master → theme inheritance cascade, and DrawingML geometry/colour resolution all now live upstream inooxml.jsitself, not in this package.src/layout/— the pure conversion algorithms, importing onlymodel(no I/O):engine.ts(ContentDocumentwordprocessing →LayoutDocument: flow, line-breaking, pagination),slides.ts(ContentDocumentpresentation →LayoutDocument: direct EMU-to-point placement, no pagination needed),reconstruct.ts(LayoutDocument→ContentDocument, both variants: baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs).src/convert/—convert.ts(the four ergonomic wrappers),codec.ts(docxPdfCodec/pptxPdfCodec, az.codec()pair over each),port.ts/local.ts(the swappableDocumentConvertercontract and its synchronous local implementation).
Dependency direction is strictly downward and checkable: model/bytes import nothing local; image imports bytes only; pdf imports model+bytes+image only; ooxml/* imports xml/model only (no PDF knowledge); layout imports model only; convert composes everything else. No PdfObject/PdfDict/PdfStream type appears outside src/pdf/.
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts)
pnpm typecheck # tsc --noEmit
pnpm lint # eslint . --max-warnings 0
pnpm test # vitest run --project unit
pnpm test:watch # vitest --project unit
pnpm test:smoke # rebuilds dist/, then verifies ESM/CJS parity and a real docxToPdf/pdfToDocx round trip from the built CJS bundle
pnpm test:corpus # optional real-world PDF conformance checks against a local, gitignored test/corpus/ (see Fidelity)To run a single test file: pnpm vitest run src/path/to/file.test.ts.
- Zod-first schema/type/guard, matching
ooxml.js: every model type is inferred from its Zod schema, never hand-written.ContentBlock(recursive, mirroringooxml.js's ownXmlNodetreatment) uses a hand-written structural guard +z.custom, notz.lazy, which collapses tounknownfor recursive element-children in the pinned Zod version. z.codec()for every schema-to-schema round trip, matchingooxml.js'spackageCodec/xmlCodec:pdfCodec(PDF bytes ⇄LayoutDocument) anddocxPdfCodec/pptxPdfCodec(docx/pptx bytes ⇄ PDF bytes) each wrap an already-independently-tested function pair, adding automatic two-way schema validation. These are deliberately the no-options form —readPdf/writePdf/docxToPdf/pdfToDocx/pptxToPdf/pdfToPptxremain the primary entry points wherever a caller needs anAbortSignal, aPdfDiagnosticSink, or anonSubstitutioncallback, sincez.codec()'s fixeddecode(input)/encode(output)signature has no room for side-channel options.PdfObjecthas no Zod schema at all, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser — validating it would just be validating our own output. It narrows natively on its ownkinddiscriminant instead, the same reasoningooxml.jsapplies when it picks a hand-writtenisXmlNodeguard overz.lazy.- No type assertions anywhere. Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
- Live views, not flatten-and-regenerate.
src/edit/*'s editor classes hold a reference directly into the realPackage/XmlElementobjects; saving isencodePackage(pkg), nothing more. This is what makes "everything you didn't touch stays byte-faithful" a structural guarantee rather than a best effort. - A three-tier PDF-read failure policy, applied consistently across every
src/pdf/*read module: throw a typedPdfParseError/PdfEncryptedErrorfor a file that cannot be meaningfully processed at all; recover with aPdfDiagnostic(severity: 'warning') for something malformed but salvageable (a badstartxref, a wrong stream/Length); degrade with a diagnostic for an individual unsupported feature (an unimplemented filter, an unrecognised colour space) while the rest of the document still reads. - Conventional commits, enforced via commitlint + husky, matching
ooxml.js.
ooxml.js's typed readers (readDocx/readPptx) are now the actual basis for conversion —readDocxContent/readPptxContentare thin wrappers around them, not an independent walk ofword/document.xml/ppt/slides/slideN.xml. They are still deliberately not re-exported from this package's own public surface:readDocx/readPptxalso carrycomments/footnotes/headers/footers(docx) thatContentDocumentdoesn't model, so exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models.- The docx⇄PDF and pptx⇄PDF conversions are explicitly not round-trip-lossless — in deliberate contrast to
ooxml.js's ownpackageCodec, which is byte/part-faithful by design. See Fidelity. - PDF output uses the standard 14 fonts only — no font embedding. Helvetica/Times-Roman are genuinely metric-compatible substitutes for Arial/Times New Roman, but Word's actual current defaults (Calibri, Aptos) are not, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation, not a line-identical reproduction.
- Reading arbitrary real-world PDFs is the single largest risk surface in this package, and the parser is honest about its design target: cleanly-generated output from mainstream producers (Word, PowerPoint, Chrome, LibreOffice, Acrobat), recovering from the malformations those producers and their downstream tooling actually create, and failing loudly and specifically on anything else — not matching a mature library's robustness against adversarial input.
- Encrypted PDFs are unsupported.
/Encryptpresent in the trailer throwsPdfEncryptedError, even for the common empty-user-password case. CCITTFaxDecode/JBIG2Decode/JPXDecodePDF images are unsupported (scanned-fax and JPEG2000 formats) — the image is skipped with a diagnostic, the rest of the page still reads. JPEG images (DCTDecode) pass through completely losslessly in both directions; PNG-sourced images go through a real, narrowly-scoped hand-written codec.- PDF → docx/pptx reconstruction has no table or vector-shape recovery. A PDF has no semantic table structure to recover — a wide horizontal gap on a line becomes a tab character, not a reconstructed grid. General vector paths, curves, gradients, and shadings are not recovered either.
- Table cell
colSpan/rowSpanand pptx shape rotation are read from aContentDocumentbut not yet written back bybuildDocxPackage/buildPptxPackage— a merged cell round-trips as an ordinary unmerged one, and a rotated shape round-trips unrotated. Both are bounded, tracked gaps (the cell's own text content and the shape's own position are still correct), not silent ones. - docx headers/footers, live
PAGE/NUMPAGESfield substitution, and inline images are not read byreadDocxContent— a deliberate, tracked scope narrowing from the original design, not an oversight. - pptx speaker notes survive
pptxToPdf/pdfToPptx, but not through any real PDF feature. PDF has no native concept of hidden presenter notes, soconvertPresentationToLayoutcarriesContentSlide.notesas a hidden/Subtype /Textannotation on the page (the same construct Acrobat's own sticky-note tool uses, marked with theHiddenannotation flag so it never renders or prints), andreconstructPresentationreads it back via a/Tmarker that distinguishes this package's own notes annotation from a genuine third-party sticky note. This is a round-trip mechanism specific to this package's own writer/reader pair — a PDF produced by anything else will never carry it, and a PDF consumer other than this package's ownreadPdfwill never see it as anything but an invisible, empty sticky note. sourcePathtraces aLayoutItemback to theContentDocumentnode it came from, but only within one read+layout pass.ooxml.js'sreadDocx/readPptxstamp everyContentRun/ContentImageBlock/ContentTable/ContentShapewith a positional path (sections[0].blocks[2].runs[1],slides[1].shapes[3].blocks[0]);convertWordprocessingToLayout/convertPresentationToLayoutcopy that same string onto whicheverLayoutText/LayoutImage/LayoutLink/LayoutRectitem(s) it produces, so a positioned PDF-side item can be traced back to its semantic origin. When line-wrapping splits one run's word across a run boundary, every resulting fragment gets its own run's path (not a shared or merged one); when a single run is emergency-split across several lines or pages, every resulting fragment keeps that same one run's path unchanged. A table cell's backgroundLayoutRectis attributed to its containing table's ownsourcePath, sinceContentTableCellcarries none of its own. This is not an edit-tracking or incremental-relayout mechanism — the path is only valid against the exactContentDocument/Packageit was assigned from in that one read; editing the document, re-reading it, or reordering its blocks invalidates every previously-captured path, and nothing here recomputes or diffs paths across two versions of a document.
docx/pptx → PDF is a genuine layout render: the docx flow/pagination engine and the pptx direct-placement engine both produce real positioned text, images, tables, and (for docx) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, basedOn chains, placeholder inheritance). It is a faithful visual approximation, not a pixel- or line-identical reproduction of what Word/PowerPoint would themselves render — see the standard-14 font substitution gotcha above.
PDF → docx/pptx is necessarily a best-effort reconstruction from geometry: a PDF page is just positioned glyphs and images, with no semantic paragraph or shape structure to recover. Reading order, bold/italic/colour/font-size, and page/slide count are preserved; paragraph and text-block boundaries are inferred from baseline spacing and left-margin indentation, not recovered exactly.
Neither direction is round-trip-lossless, and the two conversions are not inverses of each other — pdfToDocx(docxToPdf(x)) will not reproduce x exactly, and is not intended to. This is a deliberate, permanent contrast with ooxml.js's own packageCodec, which genuinely is a lossless round trip. docxPdfCodec/pptxPdfCodec/pdfCodec share packageCodec's mechanism (z.codec(), schema-validated both ways) but not its guarantee — wrapping a lossy conversion in z.codec() validates the shape of what comes out, not its fidelity to what went in.
Optional real-world corpus. test/corpus/ (gitignored, never committed) holds a pnpm test:corpus vitest project for manual conformance checking against real PDFs a hand-built fixture can't fully stand in for — a Word "Save as PDF", a PowerPoint "Save as PDF", a Chrome "Print to PDF", a LibreOffice export. It is not part of pnpm test and does not gate CI; drop files in locally before a significant parser change.
.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/documents.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.
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.
- ooxml.js — the sibling package this depends on for all docx/pptx/xlsx ⇄ JSON handling and cascade-resolved typed reading.
- document-content-model — the sibling package that owns
ContentDocument/LayoutDocumentthemselves; bothooxml.jsanddocuments.jsimport from it rather than each maintaining an independent copy. - odf.js — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on
document-content-model. A dependency ofdocuments.jsas of this package'sOdt/Ods/Odp/OdgBytesSchema(src/model/bytes.ts), which validate against itsODF_MEDIA_TYPEStable, andsrc/interop.test.ts, a type-level guard thatooxml.js's andodf.js's rawXmlElement/XmlNode/Attribute/Packagecontainer types stay structurally compatible. Full odt/ods/odp/odg →ContentDocumentreading (the equivalent ofreadDocxContent/readPptxContent) is not yet integrated.
MIT