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

js.documents

v14.4.55

Published

Bidirectional docx/pptx <-> PDF conversion and a read+write editable OOXML document model, built on ooxml.js and Zod 4 codecs.

Readme

documents.js

GitHub npm npm version CI

Converts between any two compatible document formats through a shared content/layout pivot. docx, pptx, odt, odp, ods, odg, xlsx, csv (TSV is the same format with a tab delimiter), svg, markdown, rtf, and the three legacy binary formats doc/xls/ppt ([MS-DOC], BIFF8, [MS-PPT], each wrapped in an [MS-CFB] compound file) all read into and build from the same shared ContentDocument model (reported to callers as the tree-form DocumentTree), with PDF — reached through pdf-codec's own LayoutDocument view — as the one format every variant can reach; wpd (WordPerfect 6.x-X6) reads into the same wordprocessing variant as a read-only source, routable everywhere the others are but never buildable as a target, since wpd-codec ships no writer. A composition engine (convertDocument) routes 217 (source, target) pairs across the fifteen content formats and PDF, including twenty-eight PDF-pivot round trips (the eight layout-engine formats, plus xlsx/csv/xls composing through ods, and rtf/doc composing through docx/odt/markdown and ppt composing through pptx/odp), twenty-seven cross-format bridge functions (same-variant direct copies, cross-variant semantic transforms, and PDF-composed — five of which, xlsxToMarkdown/csvToMarkdown/xlsToMarkdown/docToMarkdown/pptToMarkdown, are one-directional, xlsxToMarkdown/csvToMarkdown/xlsToMarkdown/pptToMarkdown as cross-variant transforms since ExaDev/documents.js#1043 registered spreadsheetToWordprocessing, docToMarkdown as a same-variant bridge with no markdownToDoc counterpart), fifteen one-way wpd-sourced routes, plus special-case conversions for .odm master documents, .odb database front-ends (HSQLDB and Firebird, four storage tiers), standalone .odf formula documents, and a bounded SQL/rpt-formula engine for .odb reports. Also includes: read-and-write live-view editors for all six editable formats, docx comment/footnote/header-footer exposure via readDocxExtras, real font resolution (source-embedded faces ahead of caller-supplied, vendored substitutes, and the standard 14), a hand-written MathML typesetting engine with embedded-font PDF rendering and a matching MathML ⇄ OMML translator, LaTeX lowering into the schema's two-layer semantic math core (pinned temml parser, symbol tables from prose, a coherence lint), and a fully hand-written PDF codec. Built on ooxml.js, odf.js, pdf-codec, markdown-codec, rtf-codec, wpd-codec, doc-codec, xls-codec, ppt-codec, archive-codec, and document-schema.js.

documents.js extends ooxml.js in two directions ooxml.js deliberately does not cover: full PDF support (parsing and generating, via pdf-codec), and a read-and-write manipulation API for docx/pptx content — ooxml.js's own typed readers are one-way. The PDF codec is hand-written against ISO 32000-1, with no external PDF library as a dependency — see Fidelity and pdf-codec's own README for the honest trade-off (not as robust against adversarial PDFs as a 15+-year-hardened library; fully auditable and dependency-free instead). src/mathml/ (the MathML typesetting engine) stays in this package and is hand-written too, for the same supply-chain reason. The one deliberate exception on the math side is the LaTeX parser: src/latex/ lowers LaTeX into the schema's semantic core over a pinned exact-version temml dependency — see LaTeX lowering into the semantic core for why a LaTeX grammar is the one component not worth hand-writing and what the pin guarantees.

graph TD
    schema("document-schema.js")
    ooxml("ooxml.js")
    odf("odf.js")
    pdfcodec("pdf-codec")
    mdcodec("markdown-codec")
    rtfcodec("rtf-codec")
    wpdcodec("wpd-codec")
    doccodec("doc-codec")
    xlscodec("xls-codec")
    pptcodec("ppt-codec")
    archivecodec("archive-codec")
    bytecodec("byte-codec")
    documents("documents.js")
    mcp("document-mcp")
    cli("document-cli")

    schema --> ooxml
    schema --> odf
    schema --> pdfcodec
    schema --> mdcodec
    schema --> rtfcodec
    schema --> wpdcodec
    schema --> doccodec
    schema --> xlscodec
    schema --> pptcodec
    schema --> documents
    ooxml --> documents
    odf --> documents
    pdfcodec --> documents
    mdcodec --> documents
    rtfcodec --> documents
    wpdcodec --> documents
    doccodec --> documents
    xlscodec --> documents
    pptcodec --> documents
    archivecodec --> doccodec
    archivecodec --> xlscodec
    archivecodec --> pptcodec
    archivecodec --> documents
    bytecodec --> pdfcodec
    bytecodec --> documents
    documents --> mcp
    pdfcodec --> mcp
    documents --> cli
    odf --> cli
    pdfcodec --> cli

    click schema "https://github.com/ExaDev/documents.js/tree/main/packages/document-schema.js" "document-schema.js"
    click ooxml "https://github.com/ExaDev/documents.js/tree/main/packages/ooxml.js" "ooxml.js"
    click odf "https://github.com/ExaDev/documents.js/tree/main/packages/odf.js" "odf.js"
    click pdfcodec "https://github.com/ExaDev/documents.js/tree/main/packages/pdf-codec" "pdf-codec"
    click mdcodec "https://github.com/ExaDev/documents.js/tree/main/packages/markdown-codec" "markdown-codec"
    click rtfcodec "https://github.com/ExaDev/documents.js/tree/main/packages/rtf-codec" "rtf-codec"
    click wpdcodec "https://github.com/ExaDev/documents.js/tree/main/packages/wpd-codec" "wpd-codec"
    click doccodec "https://github.com/ExaDev/documents.js/tree/main/packages/doc-codec" "doc-codec"
    click xlscodec "https://github.com/ExaDev/documents.js/tree/main/packages/xls-codec" "xls-codec"
    click pptcodec "https://github.com/ExaDev/documents.js/tree/main/packages/ppt-codec" "ppt-codec"
    click archivecodec "https://github.com/ExaDev/documents.js/tree/main/packages/archive-codec" "archive-codec"
    click bytecodec "https://github.com/ExaDev/documents.js/tree/main/packages/byte-codec" "byte-codec"
    click documents "https://github.com/ExaDev/documents.js" "documents.js"
    click mcp "https://github.com/ExaDev/documents.js/tree/main/packages/document-mcp" "document-mcp"
    click cli "https://github.com/ExaDev/documents.js/tree/main/packages/document-cli" "document-cli"

    style documents fill:#f9a825,stroke:#333,stroke-width:3px

Why

The PDF side hand-writes every layer of the format against ISO 32000-1 rather than wrapping a third-party library. The read-and-write editor exists because ooxml.js's typed readers are a deliberate one-way projection — editors are live views directly over the XmlElement objects inside a decoded Package, so a mutation edits the tree in place and everything you don't touch round-trips byte-faithful.

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. The package boundary transform (assembleTree/decompose/flattenTree/factorStyles) lives in document-schema.js, so a caller using it needs that package installed too, matching the major documents.js itself depends on (^4) — a different major hands back a DocumentTree from one package's barrel and a transform from an incompatible copy, with no resolution error to catch the mismatch:

pnpm add documents.js document-schema.js
# or
npm install documents.js document-schema.js

Usage

The generic entry point: convertDocument

A single function, convertDocument, sits behind every named conversion and reaches every pair the composition engine can route — all 217 supported (source, target) combinations. The named functions below are thin one-line forwarders to it; they remain the ergonomic layer for a caller who wants a fixed pair and autocomplete discovery, while convertDocument is the first-class entry point for a caller working from a runtime format pair (CLI, MCP tool, matrix enumeration).

import { convertDocument } from "documents.js";

// markdown -> pptx has no named function of its own: the composition engine routes it
// as one cross-variant transform hop (read wordprocessing, wordprocessingToPresentation, build pptx).
const pptxBytes = convertDocument("markdown", "pptx", markdownBytes);

// Every option a named function accepts is accepted here too, threaded to whichever hop consumes it.
const odtBytes = convertDocument("docx", "odt", docxBytes, {
  onMathDiagnostic: (d) => console.warn(d),
});

convertDocument throws UnsupportedConversionError (a named class, so a caller can branch on it) for any pair the composition engine cannot route — there is no silent fallback. resolveCompositionPlan(source, target) is exported too, for surfacing the resolved hop plan without running it.

PDF-pivot conversions

The sixteen round-trip ergonomic conversions between the formats with their own layout engine and PDF (docx/pptx/odt/odp/ods/odg/markdown/svg ⇄ PDF, all round-tripping both ways), plus xlsxToPdf/pdfToXlsx and csvToPdf/pdfToCsv (each composing its ods bridge with the ods⇄pdf layout pair internally — neither xlsx nor csv has a layout engine of its own), and rtfToPdf/pdfToRtf, docToPdf/pdfToDoc, xlsToPdf/pdfToXls, pptToPdf/pdfToPpt (each composing a same-variant bridge — doc/rtf through docx, xls through ods, ppt through pptx — with that bridge target's own layout pair internally, for the identical reason: none of the four has a layout engine of its own):

import {
  csvToPdf,
  docToPdf,
  docxToPdf,
  markdownToPdf,
  odgToPdf,
  odpToPdf,
  odsToPdf,
  odtToPdf,
  pdfToCsv,
  pdfToDoc,
  pdfToDocx,
  pdfToMarkdown,
  pdfToOdg,
  pdfToOdp,
  pdfToOds,
  pdfToOdt,
  pdfToPpt,
  pdfToPptx,
  pdfToRtf,
  pdfToSvg,
  pdfToXls,
  pdfToXlsx,
  pptToPdf,
  pptxToPdf,
  rtfToPdf,
  svgToPdf,
  xlsToPdf,
  xlsxToPdf,
} from "documents.js";

const pdfBytes = docxToPdf(docxBytes);
const docxBytes2 = pdfToDocx(pdfBytes);

const pdfFromSlides = pptxToPdf(pptxBytes);
const pptxBytes2 = pdfToPptx(pdfFromSlides);

const pdfFromOdt = odtToPdf(odtBytes);
const odtBytes2 = pdfToOdt(pdfFromOdt);

const pdfFromOdp = odpToPdf(odpBytes);
const odpBytes2 = pdfToOdp(pdfFromOdp);

const pdfFromOdg = odgToPdf(odgBytes);
const odgBytes2 = pdfToOdg(pdfFromOdg);

const pdfFromOds = odsToPdf(odsBytes);
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed, then heuristically re-types it — see Fidelity

const pdfFromXlsx = xlsxToPdf(xlsxBytes); // composes xlsxToOds -> odsToPdf internally
const xlsxBytes2 = pdfToXlsx(pdfFromXlsx); // composes pdfToOds -> odsToXlsx internally

const pdfFromMarkdown = markdownToPdf(markdownBytes);
const markdownBytes2 = pdfToMarkdown(pdfFromMarkdown); // the lossiest conversion in the whole package — see Fidelity; it does carry page boundaries (one '<!-- page break -->' marker per page) and rank-inferred heading levels

const pdfFromCsv = csvToPdf(csvBytes); // composes csvToOds -> odsToPdf internally
const csvBytes2 = pdfToCsv(pdfFromCsv); // composes pdfToOds -> odsToCsv internally; recovers what was printed, then heuristically re-types it

const pdfFromSvg = svgToPdf(svgBytes); // reads the six shape primitives into a drawing ContentDocument, then the same drawing layout engine odgToPdf feeds renders it
const svgBytes2 = pdfToSvg(pdfFromSvg); // readPdf -> reconstructDrawing -> buildSvgText: vector geometry recovers near-1:1, while recovered text boxes sit outside the svg writer's vector-only scope (reported per shape, never silently dropped)

const pdfFromRtf = rtfToPdf(rtfBytes); // composes an rtf -> docx bridge -> docx -> pdf toPdf internally
const rtfBytes2 = pdfToRtf(pdfFromRtf); // composes pdf -> docx fromPdf -> docx -> rtf internally

const pdfFromDoc = docToPdf(docBytes); // composes a doc -> docx bridge -> docx -> pdf toPdf internally
const docBytes2 = pdfToDoc(pdfFromDoc); // composes pdf -> docx fromPdf -> docx -> doc internally

const pdfFromXls = xlsToPdf(xlsBytes); // composes an xls -> ods bridge -> ods -> pdf toPdf internally
const xlsBytes2 = pdfToXls(pdfFromXls); // composes pdf -> ods fromPdf -> ods -> xls internally

const pdfFromPpt = pptToPdf(pptBytes); // composes a ppt -> pptx bridge -> pptx -> pdf toPdf internally
const pptBytes2 = pdfToPpt(pdfFromPpt); // composes pdf -> pptx fromPdf -> pptx -> ppt internally

Each accepts an optional signal (AbortSignal) and either onSubstitution (X → PDF, called per character not representable in a standard-14 font) or sink (PDF → X, called per recoverable parse diagnostic). Every X → PDF conversion additionally accepts fonts (extra ProvidedFont faces) and onFontSubstitution (per family+weight+style that resolved to something else). Neither is needed for the common case — see Fonts.

Cancellation granularity, for CPU-metered runtimes (ExaDev/documents.js#585): every conversion here is synchronous end to end, and the signal is honoured at page boundaries — once per page in pdf-codec's readPdf/writePdf page loops and once per page in each of this package's four reconstructors (wordprocessing/presentation/drawing/spreadsheet). An abort arriving mid-conversion therefore takes effect at the next page boundary, not instantly: a single page's content-stream interpretation, pdf-codec's document-open phase, and the per-target build/encode stage after reconstruction are not interruptible, and parse cost is roughly linear in decompressed content length — budget for the worst single page, not the page count. A shared AbortSignal makes a deadline enforceable at that granularity on Cloudflare Workers; it cannot convert a synchronous conversion into a resumable or streaming one (an async page-at-a-time API is a deliberate non-goal of the current surface — see pdf-codec's README for the same statement from the codec side).

Cross-format bridges

Twenty-five bridge functions genuinely bypass the PDF pivot where a direct path exists. Eight same-variant direct-copy pairs (odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds, csvToOds/odsToCsv, csvToXlsx/xlsxToCsv, svgToOdg/odgToSvg, markdownToDocx/docxToMarkdown, markdownToOdt/odtToMarkdown) compose a direct readXContent → buildYPackage pivot copy — the csv pairs are one hop to its spreadsheet siblings, so csv never needs PDF to reach ods or xlsx, and svgToOdg/odgToSvg bridge svg to its drawing sibling odg the same way. Two cross-variant semantic-transform pairs (docxToPptx/pptxToDocx, odtToOdp/odpToOdt) go through src/convert/variant-bridges.ts. xlsxToMarkdown and csvToMarkdown join them as two one-directional cross-variant transforms since ExaDev/documents.js#1043 registered spreadsheetToWordprocessing (also in variant-bridges.ts): their own reverse direction (markdownToXlsx/markdownToCsv) has no transform to take instead, so those two still route through PDF internally, since a markdown table has no cell types, formulas, or geometry of its own to recover — the lossiest conversions left in the package. xlsToMarkdown and pptToMarkdown join the one-directional cross-variant group too: xlsToMarkdown is the identical spreadsheetToWordprocessing bridge fed by xls-codec's own reader instead of ooxml.js's, and pptToMarkdown crosses the same wordprocessing/presentation variant boundary via presentationToWordprocessing instead — neither has a markdownToXls/markdownToPpt reverse registered. docToMarkdown is the one same-variant bridge that is also one-directional: doc already shares docx/odt/markdown's own wordprocessing variant, so it resolves exactly like docxToMarkdown/odtToMarkdown above, but with no markdownToDoc counterpart — doc-codec's own writer scope makes that reverse a worse trade than simply not offering it.

import {
  odtToDocx,
  docxToOdt,
  markdownToDocx,
  docxToMarkdown,
} from "documents.js";

const docxBytes = odtToDocx(odtBytes);
const odtBytes2 = docxToOdt(docxBytes);

const docxFromMarkdown = markdownToDocx(markdownBytes);
const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct — dropped on this hop

Each takes an optional { signal } — no onSubstitution/sink, since there is no font substitution or PDF-parse degradation. odtToDocx/markdownToDocx/docxToOdt/docxToMarkdown additionally take onMathDiagnostic, called per formula construct that degraded crossing the bridge. The csv-sourced bridges (csvToOds, csvToXlsx, csvToMarkdown, csvToPdf) take { delimiter } — '\t' parses the same format as TSV, since a delimiter is a parse option, not a different document format — and onCellTypeInference, the per-decision audit channel the read shares with pdfToOds. The csv-target bridges (odsToCsv, xlsxToCsv, markdownToCsv, pdfToCsv) take { delimiter, sheet }: csv has no second sheet, so writing a multi-sheet source refuses with CsvSheetNotSpecifiedError naming every sheet until a caller selects one. The svg-sourced bridges (svgToOdg, svgToPdf) take onSvgDiagnostic, the reader's per-scope-limit channel; the svg-target bridges (odgToSvg, pdfToSvg) take { page, onSvgDiagnostic }: an svg is a single drawing, so writing a multi-page source refuses with SvgMultiPageNotSpecifiedError naming the page count until { page } selects one (an index, because drawing pages are anonymous where sheets are named).

The DocumentConverter port

The same conversions behind a swappable port, for a caller that wants to inject a different implementation 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 },
);

DocumentFormat includes docx/pptx/xlsx/odt/odp/ods/odg/svg/odf/csv/markdown/rtf/doc/xls/ppt/wpd/pdf — seventeen members, wpd the one read-only member: it appears as a source in conversions but never as a target, since wpd-codec ships no writer. The port's conversions list is derived from resolveCompositionPlan plus the odf→pdf special case — 217 pairs total. DocumentFormat is inferred from DocumentFormatSchema (a real Zod schema); DOCUMENT_FORMATS is exported as a plain array derived from the same schema:

The port also exposes contractVersion: number, bumped only when DocumentConverter's own contract shape changes — a new field on ConversionResult a caller might need to branch on, or a new ConversionOptions field an implementation is now expected to honour — never when the conversions table simply grows with more supported source/target pairs (that's discoverable at runtime via conversions itself). It is currently 7: the bump from 6 reflects ConversionResult.package changing type to the tree-form DocumentTree described below, which a caller reading that field must now flatten rather than read directly.

import { DOCUMENT_FORMATS, DocumentFormatSchema } from "documents.js";

console.log(DOCUMENT_FORMATS); // ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'odf', 'csv', 'markdown', 'rtf', 'doc', 'xls', 'ppt', 'wpd', 'pdf']
DocumentFormatSchema.parse(userSuppliedFormat); // throws a ZodError for anything outside that list

Intermediate DocumentTree, JSON, and bytes

Every conversion function accepts an onDocument callback receiving the intermediate DocumentTree — since document-schema.js 4, the single hierarchical tree: children carry the decomposed group tree (one group per container — a section, slide, sheet, or draw page — with heading and list paragraphs anchoring nested groups inside their container's flow), and the content nodes embedded in that tree carry frames, the rendered page positions the layout pass stamped onto them, in PDF user-space. pages (each rendered page's size, indexed to match every frames[].pageIndex) and the minted styles table ride the root. The port surfaces the same value as package on ConversionResult. For PDF-bypassing bridges, pkg.pages is always undefined and no node carries frames — no layout pass ran.

import { flattenTree } from "document-schema.js";
import { docxToPdf } from "documents.js";

const pdfBytes = docxToPdf(docxBytes, {
  onDocument: (pkg) => {
    console.log(pkg.kind); // 'wordprocessing' — the document kind rides the tree's root
    console.log(pkg.pages?.length); // populated for every X-to-PDF/PDF-to-X conversion
    const content = flattenTree(pkg); // the flat ContentDocument, fully materialised
    const block =
      content.kind === "wordprocessing"
        ? content.sections[0]?.blocks[0]
        : undefined;
    console.log(
      block?.kind === "paragraph" ? block.runs[0]?.frames : "no paragraph",
    ); // that run's rendered placements
  },
});

The tree and the flat ContentDocument are one format in two encodings, related by three laws (stated on document-schema.js#20, proven over this package's real corpus by the bijection suite in src/convert/bijection.test.ts): (i) flattenTree(assembleTree(c)) reproduces c exactly, up to one declared normalisation (a present-but-empty sheet embeddedObjects array normalises to the field absent); (ii) effective-property equality holds universally — a factored and an unfactored serialisation of one document resolve to the same properties; (iii) minting is idempotent — factoring a second time produces the identical styles table.

Three flat-form signals drive the grouping, and all three are reproduced exactly on the way back: headingLevel, list.level, and — since document-schema.js 4.2.0 — the constructStart/constructEnd block pair that delimits a fidelity construct (a docx SDT, an ODF field, a tracked-change span, a bookmark, a hyperlink region, a division). decompose promotes each marker pair to a construct group carrying the ConstructDescriptor and holding the delimited region as its children, decomposed on its own; flattenTree writes the pair back around that region. A construct is a semantic wrapper rather than a container, so it neither disturbs the enclosing heading/list nesting it sits inside nor resets the style chain resolving onto it — content inside a construct still inherits the ambient heading's or section's factored properties, exactly as if the construct were not there. Markers must pair up within one container's block flow: an unmatched constructEnd, or a constructStart a container never closes, throws document-schema.js's ConstructMarkerImbalanceError (carrying its ConstructMarkerImbalance payload, so the offending block index is available without parsing the message) rather than being repaired into a plausible tree. The format codecs emit and consume these markers on both sides today — ooxml.js's docx pair (SDTs, bookmarks, tracked changes, block-scoped fields), odf.js's odt reader for divisions, index wrappers, and cross-paragraph bookmarks with its writer covering the construct kinds it models and refusing the rest by name, markdown-codec, rtf-codec, and epub-codec in both directions, and this package's own PDF reconstruction emitting division and anchor pairs — so the marker machinery has real producers and consumers, not just the boundary transform. Reaching further than the codecs is still a mixed picture, not a blanket guarantee: buildMarkdownText passes markers through to markdown-codec's own bracket-resolving writer, which renders each construct it has a markdown spelling for (a footnote definition, a blockquote division, a titled image's link wrapper) and renders the rest transparently with a diagnostic — markdown-codec's own read side emits those pairs, so this package's editor and conversion round trips depend on it; building docx bytes from marker-carrying flat content writes a bookmark anchor's pair as real w:bookmarkStart/w:bookmarkEnd around the blocks it spans, a content control's pair as a real w:sdt region (the descriptor driving w:sdtPr, the blocks between the markers landing inside its own w:sdtContent), and a tracked change's pair as a real w:ins/w:del/w:moveFrom/w:moveTo region whose author and date ride the element and whose deletion interiors spell their runs w:delText — and the odt builder does the bookmark pair through text:bookmark-start/-end, a division's pair as a real text:section region the between-blocks land inside, and an index contentControl's pair as a real index wrapper region (text:table-of-content or one of its six siblings, named by the descriptor's *-source residue) whose cached blocks land inside its own text:index-body — so every wrapper kind each format natively spells at body scope survives the build, round-tripping through its own reader as the identical construct pair. A formatChange pair stays dropped on the docx side (its Word spellings are property-layer, with no block-level element to open) and a residue-less index descriptor stays dropped on the odt side (nothing names which of the seven wrappers to write); and the layout engines (convertWordprocessingToLayout, convertShape) silently skip a marker block during pagination — harmless there, since a marker carries no content of its own to render.

assembleTree is the one constructor behind every construction site — decompose, then factorStyles, the minting pass that hoists property tuples occurring two or more times onto a group-wrapper ref plus a styles table entry (deterministic order; frames/sourcePath/styleId are per-node facts and never factor). The transform belongs to document-schema.js, which owns both encodings and publishes assembleTree, decompose, flattenTree, factorStyles, ConstructMarkerImbalanceError, and the TreeChildren type for any caller composing its own boundary — import them from there, not from this package. documents.js consumes that transform at its own boundary and re-exports none of it; the readers, builders, layout engines, and editors here keep producing and consuming the flat form, so the tree exists only where a DocumentTree is constructed or consumed:

import {
  assembleTree,
  decompose,
  factorStyles,
  flattenTree,
} from "document-schema.js";

const tree = assembleTree(content, pages); // decompose + mint: the tree a conversion reports
const flat = flattenTree(tree); // the exact flat ContentDocument back, refs materialised
const again = factorStyles(tree); // re-mint: identical table and tree (law iii)

documentTreeWithSchema/documentFromJson turn a DocumentTree into self-describing JSON and back (re-exported from document-schema.js); the version-pinned $schema URI the dumper stamps is the package's version — the hand-kept formatVersion integer is gone:

import { documentFromJson, documentTreeWithSchema } from "documents.js";

const tagged = documentTreeWithSchema(pkg);
writeFileSync("converted.doc.json", JSON.stringify(tagged, null, 2));

const { kind, value } = documentFromJson(
  JSON.parse(readFileSync("converted.doc.json", "utf8")),
);
// kind: 'DocumentTree' (here) | 'ContentDocument'

buildDocumentBytes rebuilds any DocumentFormat's bytes from a tree-form DocumentTree — it flattens once at the boundary and hands the flat form to the builders, whose signatures never changed. 'pdf' rebuilds the pdf-codec view from the package's own frames+pages (layoutDocumentFromPackage, a mechanical inverse walking the flattened content and emitting LayoutItems from each node's recorded placements; throwing if the package carries no pages), 'odf' has no builder and throws, everything else rebuilds from the flattened ContentDocument. layoutDocumentFromPackage is exported too, for a caller wanting the rebuilt LayoutDocument without writing bytes. The pdf rebuild re-derives what the recorded data genuinely determines rather than approximating: a wrapped run's frames each carry the tight measured width of the fragment the original wrap placed there, so the rebuild re-wraps the run's text against each frame's own width through the same line-breaker and standard-14 metrics its re-render draws with, reproducing the original split wherever the original also drew through those metrics; and an embedded formula is re-typeset from its own recorded MathML at its recorded frame through the identical layoutFormula pipeline the original pass ran. One honest limit remains, a structural property of what a package records: no font registry survives a bare package, so text re-renders through the standard 14 (or the caller's write-option faces) rather than the source document's own embedded faces, and a formula whose source carried no MathML at all has nothing to re-typeset:

import { buildDocumentBytes, docxToPdf } from "documents.js";

let captured;
docxToPdf(docxBytes, {
  onDocument: (pkg) => {
    captured = pkg;
  },
});
const pdfBytesAgain = buildDocumentBytes(captured, "pdf");
const docxBytesAgain = buildDocumentBytes(captured, "docx");

Package decode/encode, metadata, and deep imports

decodeDocumentPackage/encodeDocumentPackage dispatch docx/pptx/xlsx through ooxml.js's OPC codec and odt/odp/ods/odg/odf through odf.js's ODF codec, throwing UnsupportedPackageFormatError for markdown/csv/svg/pdf (none of the four is a package — the first three are plain text, pdf is bytes). decodeOdbPackage is the .odb-specific sibling (.odb is not a DocumentFormat member):

import {
  decodeDocumentPackage,
  decodeOdbPackage,
  encodeDocumentPackage,
} from "documents.js";

const pkg = decodeDocumentPackage("docx", docxBytes);
const docxBytesAgain = encodeDocumentPackage("docx", pkg);
const odbPkg = decodeOdbPackage(odbBytes);

readDocumentMetadata/setDocumentMetadata read or patch metadata across any DocumentFormat. setDocumentMetadata patches in place (source/target formats must match); most formats rebuild a fresh package from that format's own ContentDocument, which is lossy wherever that format's own build function is (a docx rebuild, for instance, would drop comments, footnotes, header/footer parts, and numbering definitions — everything readDocxExtras covers). docx/docx is the one exception: it patches docProps/core.xml directly on the decoded Package instead of rebuilding (the identical mechanism patchDocxMetadata below exposes directly), so all of that survives byte-faithful — every caller of setDocumentMetadata gets this for free, with nothing to opt into. pdf/pdf similarly patches the parsed LayoutDocument directly, with no layout engine involved. odf is rejected in both directions, and csv is rejected in both directions too (RFC 4180 text has no metadata container) — readDocumentMetadata('csv', ...) answers an empty LayoutMetadata for the same reason. svg reads its root <title> as metadata.title and is rejected as a setDocumentMetadata source/target for the mirror-image reason: <title> is svg's whole metadata surface, so any other override would be silently dropped by the rebuild. readDocumentMetadata('xlsx', ...) reads the workbook's own docProps like every other content format — createdIso/modifiedIso from docProps/core.xml when the file declares them, docProps/app.xml's Application as creator, and producer unset (a PDF-only concept no semantic reader sets). It previously rendered a xlsxToPdf preview and read that PDF's metadata instead, which for a file carrying no timestamps of its own reported facts about the render rather than the workbook: timestamps stamped at the render moment and a producer naming the preview PDF's writer.

patchDocxMetadata is the docx-specific patch setDocumentMetadata calls internally, exported in its own right for a caller that wants the lossless guarantee explicit at the call site rather than implied by passing 'docx'/'docx':

import { patchDocxMetadata } from "documents.js";

const patchedBytes = patchDocxMetadata(docxBytes, { title: "New title" });
import { readDocumentMetadata, setDocumentMetadata } from "documents.js";

const metadata = readDocumentMetadata("docx", docxBytes);
const patchedBytes = setDocumentMetadata("docx", "docx", docxBytes, {
  title: "New title",
  keywords: ["a", "b"],
});

readNativeDocumentTree reads a DocumentTree straight off a source document's own bytes, with no conversion target and no bridging hop involved at all — the source's native structure, always, regardless of what (if anything) else it also gets converted to. This is a genuinely different report from onDocument/ConversionResult.package, which reflect whichever hop actually produced a requested conversion's output: a target sharing no ContentDocument variant with the source composes through a lossy cross-variant transform or a pdf pivot to reach it (xlsx → markdown, say), and that intermediate hop's shape is not the source's own — an xlsx workbook's onDocument report for an xlsx-to-markdown conversion is a wordprocessing tree with no sheet/cell/formula/A1 data at all, since markdown has no native way to carry any of it (ExaDev/documents.js#823). readNativeDocumentTree sidesteps that entirely: every DocumentFormat member reads through that format's own reader with no reconstruction and no pages — odf's readOdfFormulaContent included — except pdf, which has no ContentDocument reader of its own; its native representation is readPdf's LayoutDocument reconstructed into a wordprocessing tree (reconstructWordprocessing, the identical reconstruction pdfToDocx runs), with pages/frames and the document-level PDF tables (destinations/outline/attachments/layers/structure/comment bodies) attached exactly as that conversion's own report already carries:

import { readNativeDocumentTree } from "documents.js";

const tree = readNativeDocumentTree("xlsx", xlsxBytes);
console.log(tree.kind); // 'spreadsheet' — the workbook's own shape, never a projection through some other target

readContentDocument is readNativeDocumentTree's flat-shape sibling: the identical source-native, no-bridging read, reported as a ContentDocument rather than a DocumentTree. It exists for a caller who wants "whatever ContentDocument this format's own reader produces" for any DocumentFormat member at runtime — a CLI flag, an MCP tool argument, a matrix enumeration — without hand-rolling a per-format switch over readXlsxContent/readOdsContent/readXlsContent/readCsvContent/readDocxContent/etc themselves, the same ergonomic gap convertDocument closes for conversions. It dispatches through the exact same per-format reader readNativeDocumentTree does (both share one internal dispatch, so the two can never drift on what a source's native content means), including pdf's choice: pdf has no ContentDocument reader of its own, so readContentDocument("pdf", ...) deliberately mirrors readNativeDocumentTree's reconstruction — readPdf's LayoutDocument run through reconstructWordprocessing, the identical pass pdfToDocx's own report carries — rather than refuse:

import { readContentDocument } from "documents.js";

const content = readContentDocument("xls", xlsBytes);
console.log(content.kind); // 'spreadsheet' — xls-codec's own reader, no ods/pdf pivot involved

Every module under src/ is deep-importable by package-relative path:

import { emuToPt } from "documents.js/model/units";
import { buildOdtPackage } from "documents.js/edit/odt/content";

One subpath is a declared entry point in its own right: documents.js/read (an explicit exports entry onto src/convert/from-pdf.ts, where the pdfTo* family lives). A consumer that only ever converts FROM pdf and imports the root barrel statically reaches every X-to-PDF renderer — and through pdf-codec's root barrel, ~2.9 MB of vendored font binaries it can never execute, which on Cloudflare Workers' free plan (3 MB gzipped for an entire Worker) is most of the budget. The read entry's module graph provably excludes them, all the way across the workspace boundary into pdf-codec's own source:

import { pdfToMarkdown } from "documents.js/read";

It carries the ten pdfTo* conversions, PdfToDocumentOptions, readDocumentMetadata (with ReadDocumentMetadataOptions), readNativeDocumentTree (with ReadNativeDocumentTreeOptions), and readContentDocument (with ReadContentDocumentOptions) — identical functions to the root barrel's (the same forwarders, run through the composition engine's read half, and the same dispatch through the read-only codec half src/codecs/read.ts), never a forked behaviour; convertDocument and every X-to-PDF direction stay on the root barrel. src/read-graph.test.ts walks the entry's static import graph, follows pdf-codec specifiers through that package's real exports map into its source, and fails the build if the write path or any font asset becomes reachable.

Reading and building xlsx content directly

Every other content format has its own standalone readXContent-shaped entry point (readDocxContent, readPptxContent, readOdtContent, readOdpContent, readOdsContent, readOdgContent) — xlsx is no longer the exception. readXlsxContent/buildXlsxPackage are this package's names for ooxml.js's own spreadsheet ContentDocument read/build pair — the same one the ods⇄xlsx bridge and every xlsx metadata-rebuild path already use internally — re-exported here directly rather than wrapped, since readXlsxContent already produces the right shape on its own. (Since ooxml.js 4.0.0 the upstream flat builder is named buildXlsxPackageFromContent — the bare buildXlsxPackage name moved to that package's tree-form DocumentTree builder — so this package re-exports the flat builder under its own long-standing buildXlsxPackage name and the ContentDocument-in/Package-out contract is unchanged.) csv's readCsvContent/buildCsvText are the same kind of directly-exported stage pair, one level further in: they operate on RFC 4180 text rather than a decoded package (see src/csv/ under Architecture). svg's readSvgContent/buildSvgText are the drawing-variant counterpart of csv's pair, operating on SVG text rather than a decoded package (see src/svg/ under Architecture).

import {
  buildXlsxPackage,
  decodeDocumentPackage,
  encodeDocumentPackage,
  readXlsxContent,
} from "documents.js";

const content = readXlsxContent(decodeDocumentPackage("xlsx", xlsxBytes)); // ContentDocument, kind: 'spreadsheet'
const rebuiltBytes = encodeDocumentPackage("xlsx", buildXlsxPackage(content));

This pair is comparatively newer than the ODF/DrawingML readers above, and inherits their maturity level: percentage, currency, and date cell kinds round-trip with their semantic kind intact, but two narrower gaps are worth knowing before relying on it for more than read-only extraction — an ODS-style time-only value has no xlsx serial to write into and degrades to a plain string cell, and a written column width survives a read back only within about a point of its original value (an algebraic-inverse rounding artifact in the character-width unit conversion, not a dropped value). See src/convert/bridges.test.ts's own ods⇄xlsx section for the exact, currently-tested numbers.

Live-view editors

Read-and-write editors for docx/pptx/odt/odp/ods/odg content, holding a direct reference into the real Package/XmlElement objects. Saving is encodePackage(pkg) — everything you didn't touch stays byte-faithful.

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();

const fresh = createDocx();
fresh.body.appendParagraph().appendRun({ text: "New document" });

A docx's comments, footnotes, header/footer parts, section header/footer references, and numbering definitions never fit ContentDocument's section/block shape — readDocxExtras is a second, independent read returning exactly that data:

import { readDocxExtras } from "documents.js";
import { decodePackage } from "ooxml.js";

const {
  comments,
  footnotes,
  headerFooterParts,
  sectionHeaderFooters,
  numbering,
} = readDocxExtras(decodePackage(docxBytes));
console.log(Object.values(numbering)[0]?.levels["0"]?.format); // numbering is keyed by numId, each level by its own level index

openPptx/createPptx and PptxSlide/PptxShape are the pptx equivalent. embeddedPresentationSerialiser is ooxml.js's embedded-presentation port wired from this package's own pptx builder. ooxml.js has no PresentationML writer and cannot depend on the one pptx writer in the ecosystem (buildPptxPackage, living here one layer above it), so its docx writer instead accepts an injected serialiser; pass this value as BuildDocxContentOptions.serialiseEmbeddedPresentation and a docx carrying an OLE-embedded presentation — which readDocxContent genuinely recovers as an embeddedObject block — round-trips through that writer, the nested deck re-serialised into a real word/embeddings/oleObject<N>.pptx payload rather than refused:

import { embeddedPresentationSerialiser } from "documents.js";
import {
  buildDocxPackageFromContent,
  decodePackage,
  encodePackage,
  readDocxContent,
} from "ooxml.js";

const content = readDocxContent(decodePackage(docxBytes)); // carries a presentation embed
const rebuilt = encodePackage(
  buildDocxPackageFromContent(content, {
    serialiseEmbeddedPresentation: embeddedPresentationSerialiser,
  }),
);

openOdt/createOdt and OdtParagraph/OdtRun/OdtTable/OdtList are the odt equivalent, built on ODF's style-name-referencing model. openOdp/createOdp and OdpSlide/OdpShape reuse OdtParagraph/OdtRun/OdtList directly (a draw:frame's draw:text-box holds the identical text:p/text:span model):

import { createOdp } from "documents.js";

const editor = createOdp();
const slide = editor.addSlide();
const title = slide.addTextBox({
  frame: { xPt: 40, yPt: 30, widthPt: 640, heightPt: 80 },
  text: "Title",
});
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter
const bullets = slide.addTextBox({
  frame: { xPt: 40, yPt: 130, widthPt: 300, heightPt: 200 },
  text: "",
});
bullets.paragraphs()[0].remove();
bullets
  .addList()
  .addItem()
  .appendParagraph({ text: "A real bulleted text:list" });
slide.notes = "Speaker notes for this slide";
const bytes = editor.toBytes();

createOds/openOds and OdsEditor/OdsSheet/OdsCell are the spreadsheet equivalent — the one editor family built from scratch (cell addressing has no docx/pptx analogue). Setting a cell far from the origin splits table:number-*-repeated runs in place rather than materialising every cell in between:

import { createOds } from "documents.js";

const editor = createOds();
const sheet = editor.addSheet("Sheet1");
sheet.printSettings = {
  pageSize: { widthPt: 595, heightPt: 842 },
  margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 },
  gridlines: true,
  headers: true,
  pageOrder: "downThenOver",
};
sheet.cell(0, 0).value = { kind: "string", value: "Total" }; // 0-based (row, column)
sheet.cell(0, 1).value = { kind: "currency", value: 42.5, currency: "USD" };
sheet.cell(500, 50).value = { kind: "boolean", value: true }; // does not materialise 500x50 empty cells
const bytes = editor.toBytes();

createOdg/openOdg and OdgEditor/OdgPage are the drawing equivalent. OdgPage.addTextBox/.addImage return OdpShape instances; addRect/addEllipse/addLine/addPath return vector classes writing real draw:rect/draw:ellipse/draw:line/draw:path elements:

import { createOdg } from "documents.js";

const editor = createOdg();
const page = editor.addPage();
page.addRect({
  frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 60 },
  fill: { r: 1, g: 0.5, b: 0 },
});
page.addEllipse({
  frame: { xPt: 140, yPt: 20, widthPt: 100, heightPt: 60 },
  stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 },
});
page.addPath({
  frame: { xPt: 20, yPt: 100, widthPt: 80, heightPt: 80 },
  subpaths: [
    {
      start: { xPt: 0, yPt: 80 },
      closed: true,
      segments: [
        { kind: "line", to: { xPt: 60, yPt: 80 } },
        {
          kind: "cubic",
          control1: { xPt: 80, yPt: 80 },
          control2: { xPt: 80, yPt: 0 },
          to: { xPt: 40, yPt: 0 },
        },
      ],
    },
  ],
  fill: { r: 1, g: 1, b: 0 },
}); // a genuine Bezier curve — writes a real svg:d/svg:viewBox pair, not a polygon approximation
page.addTextBox({
  frame: { xPt: 20, yPt: 200, widthPt: 300, heightPt: 30 },
  text: "A label on top",
});
const bytes = editor.toBytes();

PDF bytes and z.codec() pairs

import { readPdf, writePdf } from "documents.js";

const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/link items
const bytes = writePdf(layout);

The fifteen PDF round trips and sixteen PDF-bypassing bridge directions are also available as schema-validated z.codec() pairs (pdfCodec, docxPdfCodec, pptxPdfCodec, odtPdfCodec, odpPdfCodec, odsPdfCodec, odgPdfCodec, svgPdfCodec, xlsxPdfCodec, csvPdfCodec, markdownPdfCodec, rtfPdfCodec, docPdfCodec, xlsPdfCodec, pptPdfCodec, odtDocxCodec, odpPptxCodec, odsXlsxCodec, odsCsvCodec, xlsxCsvCodec, odgSvgCodec, markdownDocxCodec, markdownOdtCodec) — the no-options form, adding automatic two-way schema validation. The two asymmetric pairs above have codec forms too (xlsxMarkdownCodec, csvMarkdownCodec) — decode is now spreadsheetToWordprocessing's direct cross-variant transform, encode still composes through PDF:

import { z } from "zod";
import { docxPdfCodec, pdfCodec } 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);

Special-case conversions

odmToPdf — ODF master document → PDF. A .odm never carries its chapters' content (each text:section is an external .odt reference), so it requires a caller-supplied resolveSubDocument callback. Not wired into the DocumentConverter port (its contract is bytes-in/bytes-out):

import { readFileSync } from "node:fs";
import { odmToPdf, OdmUnresolvedSectionError } from "documents.js";

const chapterBytes = new Map([
  ["../chapter1.odt", new Uint8Array(readFileSync("chapter1.odt"))],
  ["../chapter2.odt", new Uint8Array(readFileSync("chapter2.odt"))],
]);

try {
  const pdfBytes = odmToPdf(odmBytes, {
    resolveSubDocument: (href) => chapterBytes.get(href),
  });
} catch (error) {
  if (error instanceof OdmUnresolvedSectionError) {
    console.error("missing chapters:", error.hrefs); // every unresolved href, not just the first
  }
}

.odb database front-end — readOdbTables extracts every table; odbToXlsx/odbToCsv produce xlsx or CSV. All four storage tiers are supported (HSQLDB TEXT-script Tier 1, HSQLDB CACHED binary Tier 2, Firebird gbak Tier 3, HSQLDB BINARY/COMPRESSED Tier 4), dispatched automatically:

import { decodePackage } from "odf.js";
import { odbToCsv, odbToXlsx, readOdbTables } from "documents.js";

const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table
const csvBytes = odbToCsv(odbBytes, { table: "CUSTOMERS" }); // required when the .odb has more than one table
const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[]

Form/Report structure: readOdbForms/readOdbReports read every declared component's static structure (bound controls, bands/groups/functions):

import { decodePackage } from "odf.js";
import { readOdbForms, readOdbReports } from "documents.js";

const forms = readOdbForms(decodePackage(odbBytes));
const reports = readOdbReports(decodePackage(odbBytes));

readFirebirdBackup decodes a Firebird .fbk directly:

import { readFirebirdBackup } from "documents.js";
const { summary, tables } = readFirebirdBackup(firebirdBackupBytes);

SQL SELECT engine — parseSelect/evaluateSelect run a bounded SELECT over readOdbTables' output. Closed allowlist grammar: column list or * or aggregates (COUNT/SUM/AVG/MIN/MAX), FROM one table (or one derived table — a subquery standing in for one, requiring its own alias) plus zero or more joins ([INNER], LEFT [OUTER], RIGHT [OUTER], FULL [OUTER], CROSS, or NATURAL, each a plain nested-loop join with ON, USING (col, ...), or NATURAL's own implicit equi-join over shared columns; a USING/NATURAL join's shared columns merge into one output column, COALESCE(left, right)), optional table aliases (FROM t [AS] alias, which is what makes a genuine self-join possible), optional WHERE/GROUP BY/ORDER BY — WHERE (and a JOIN's own ON) also accepts [NOT] IN (SELECT ...) and [NOT] EXISTS (SELECT ...), each recursing through the identical grammar to whatever subquery nesting depth it naturally reaches, with a correlated subquery resolving an outer column it cannot resolve itself. Column aliases and everything else outside the grammar throws HsqldbSqlUnsupportedError:

import { decodePackage, readOdbInventory } from "odf.js";
import { evaluateSelect, parseSelect, readOdbTables } from "documents.js";

const pkg = decodePackage(odbBytes);
const [query] = readOdbInventory(pkg).queries;
const { columns, rows } = evaluateSelect(
  parseSelect(query.command),
  readOdbTables(pkg),
);

rpt formula engine — runRptReport evaluates a report's group breaks and per-group totals. Closed allowlist: rpt:HASCHANGED(X), rpt:LEFT(X;n) (semicolon separator), rpt:SUM/COUNT/AVG/MIN/MAX, and field:[COLUMN]. Everything else throws RptFormulaUnsupportedError:

import { decodePackage, readOdbInventory } from "odf.js";
import {
  evaluateSelect,
  parseSelect,
  readOdbReports,
  readOdbTables,
  rptDefinitionFromReport,
  runRptReport,
} from "documents.js";

const pkg = decodePackage(odbBytes);
const [report] = readOdbReports(pkg);
const query = readOdbInventory(pkg).queries.find(
  (candidate) => candidate.name === report.command,
);
const rows = evaluateSelect(parseSelect(query.command), readOdbTables(pkg));
const { bands } = runRptReport(rptDefinitionFromReport(report), rows);

Report rendering — readOdbReportContent resolves data binding, runs the query, evaluates formulas, and renders bands as a real ContentDocument. odbReportToDocx/odbReportToOdt/odbReportToPdf dispatch it to bytes:

import { decodePackage } from "odf.js";
import {
  odbReportToDocx,
  odbReportToOdt,
  odbReportToPdf,
  readOdbReportContent,
} from "documents.js";

const report = readOdbReportContent(decodePackage(odbBytes), {
  report: "SalesByRegion",
});
const docxBytes = odbReportToDocx(report);
const pdfBytes = odbReportToPdf(report);

odfToPdf — standalone .odf formula document → PDF via the MathML typesetting engine. No reverse pdfToOdf (recovering structured MathML from rendered glyphs is OCR-adjacent). Formulas embedded inside odt/odp/ods render automatically through odtToPdf/odpToPdf/odsToPdf:

import { odtToPdf, odfToPdf } from "documents.js";

const pdfBytes = odfToPdf(odfBytes); // a single formula, faithfully typeset
const pdfFromOdtWithFormula = odtToPdf(odtBytes); // embedded formulas render as real typeset MathML

A formula's MathML travels inside the ContentDocument as a ContentEmbeddedObjectBlock whose document is a 'formula'-kind ContentDocument:

import {
  convertWordprocessingToLayout,
  formulaOfBlock,
  readOdtContent,
} from "documents.js";

const document = readOdtContent(pkg);
const block = document.sections[0].blocks.find(
  (b) => b.kind === "embeddedObject",
);
formulaOfBlock(block); // -> { mathml, starMath? }, or undefined for a non-formula embedded object

const { document: layout, formulas: positioned } =
  convertWordprocessingToLayout(document, { measurer });
const pdfBytes = writePdf(layout, { formulas: positioned });

layoutFormula/loadMathFont are exported for direct formula layout. buildOfficeMath/buildOfficeMathParagraph translate MathML into OMML for docx. readOfficeMath/collectOfficeMathElements are the read-side inverse:

import {
  buildOfficeMathParagraph,
  layoutFormula,
  loadMathFont,
  openDocx,
} from "documents.js";

const { metricsAt } = loadMathFont();
const { box, diagnostics } = layoutFormula(mathml, {
  metrics: metricsAt(12),
  sizePt: 12,
  color: { r: 0, g: 0, b: 0 },
});

const editor = openDocx(existingDocxBytes);
const { diagnostics: ommlDiagnostics } = editor.body
  .appendParagraph()
  .appendOfficeMath(mathml);

LaTeX lowering into the semantic core

A formula in the 3.2.0 schema carries two co-equal layers: presentation (a verbatim LaTeX string, rendering-authoritative) and content (a MathExpression semantic tree, computation-authoritative). Neither is stored derived from the other. This package owns the string-to-tree half — the lowering — and runs it wherever LaTeX enters the model:

  • Parsing happens at the format edge through temml (MIT, zero dependencies), pinned to the exact version recorded in package.json — "temml": "0.13.4", no caret. The pin is load-bearing: the lowering consumes temml's internal parse-node API, which carries no stability guarantee across releases, and the two-layer contract says a stored presentation string has one defined parse. Bumping the pin is a deliberate act that must re-run src/latex/lower.test.ts, whose table cases pin the parse-node shapes the lowering consumes. temml is the one math component this ecosystem deliberately does not hand-write (a LaTeX grammar is a large surface with none of the supply-chain payoff the hand-written MathML engine has); it is pure JavaScript, its parser never touches the DOM, and the workerd suite proves the whole lowering path in a Cloudflare Workers isolate.
  • Lowering is mechanical exactly where notation is unambiguous: \frac → math:divide, radicals → math:sqrt / an exact 1/n exponent, a scripted Sigma or Product with limits → a sum/prod binder owning the rest of its term, numeric literals → exact rationals (3.14 → 157/50, BigInt-exact at any length), subscripts → distinct symbol identities through the symbol table (x_1 is never x times 1), superscripts → math:pow unless the table already curates the scripted form as one symbol. Named functions (\sin, \ln, ...) consume their argument the way binders consume their summand.
  • Everything context-starved degrades to visible data: juxtaposition (mc^2, f(x), 2(x+1) — multiplication and function application are both defensible readings, and LaTeX cannot say which), overloaded operators (\pm, \approx), integrals (the grammar's binders are exactly sum and prod), \text prose, compound subscripts (a_{i+1}), binomials, align/cases environments — each becomes an unparsed node carrying the verbatim source span plus a named diagnostic from LATEX_DIAGNOSTIC_CODES. Never a parse failure, never a silent guess; a degraded juxtaposition is exactly what the round-trip-safe semantic editing the schema defines is for.
  • Symbol tables come from the document's own prose: sentence-level "where R is…" / "let x be…" definitions seed curated entries (conservatively — precision over recall, no quantity kind is ever guessed), and glyphs nobody defined are minted so every sym reference resolves. The markdown read pass builds the table automatically.
  • The markdown read path runs the whole pipeline: markdown-codec hands $$ display blocks and \( \) inline spans through as raw LaTeX text, and readMarkdownContent lowers them into embedded formula blocks (position, content, presentation MathML from the same parse — so markdownToPdf typesets real math through the STIX engine, markdownToDocx writes real OMML, and markdownToOdt writes real embedded formula sub-documents). The write side reconstructs the same markdown math syntax from the verbatim presentation layer. The pass's diagnostics surface through readMarkdownContent's third parameter.
  • The coherence lint (lintMathCoherence) re-parses and re-lowers every stored presentation string against the document's own symbol table and compares with the stored content layer — divergence means somebody edited one layer deliberately, so it reports a warning carrying provenance and re-derives nothing.
import { latexToFormula, lintMathCoherence, lowerLatex } from "documents.js";

const { expression, diagnostics, mintedSymbols } = lowerLatex(
  "\\sum_{i=1}^{n} \\frac{1}{i^2}",
);
// expression: { kind: 'sum', binder: 'i', lower: {kind:'num',numerator:'1',denominator:'1'}, ... }
// diagnostics: [] — fully mechanical; '2x' would degrade to unparsed + 'latex/juxtaposition-unparsed'

const { formula } = latexToFormula("x^2", {
  symbolEntries: table.symbols,
  source: "my:pipeline",
});
// formula: { mathml, presentation: { latex: 'x^2' }, content, provenance } — ready to embed

const warnings = lintMathCoherence(pkg); // [{ code: 'math/coherence-divergence', severity: 'warning', provenance, detail }]

Fonts

Every X → PDF conversion resolves each typeface through a real FontRegistry, in this order:

  1. The source document's own embedded faces — docx (w:embed*, obfuscated per ECMA-376), pptx (p:embeddedFontLst, unobfuscated), ODF (Fonts/ under svg:font-face-uri). Extracted automatically.
  2. Faces the caller supplied through options.fonts.
  3. pdf-codec's vendored Carlito and Caladea — metric-compatible with Calibri and Cambria.
  4. The standard 14 — last resort.

The same registry drives both the TextMeasurer (line breaking) and the writer (glyph emission) — measuring against one font's metrics and drawing through another would wrap text at wrong positions.

import { docxToPdf } from "documents.js";

const pdfBytes = docxToPdf(docxBytes); // nothing to configure for embedded fonts

const withFallbackFace = docxToPdf(docxBytes, {
  fonts: [
    {
      family: "Brand Sans",
      bold: false,
      italic: false,
      bytes: brandSansTtfBytes,
    },
  ],
  onFontSubstitution: (substitution) =>
    console.warn(
      substitution.requestedFamily,
      "->",
      substitution.resolvedFamily,
    ),
});

A document that embeds nothing and asks for no vendored-substitute family writes byte-identical output to the old standard-14-only pipeline. Two structural limits: an embedded face is normally subsetted, so it can legitimately lack a synthesised character (list bullet, ### overflow marker) — resolved per character via onMissingGlyph. And odfToPdf accepts font options but consults neither — a standalone formula emits only the embedded STIX Two Math font's glyphs. extractSourceFonts/extractSourceFontsForFormat/createDocumentFontRegistry are exported for callers composing the pipeline manually. describeFontFace inspects a standalone .ttf/.otf file.

import { describeFontFace, extractSourceFontsForFormat } from "documents.js";

const faces = extractSourceFontsForFormat("docx", docxBytes); // -> readonly ProvidedFont[]
const { family, bold, italic } = describeFontFace(
  fontBytes,
  "BrandSans-Regular.ttf",
);

Architecture

The package is layered from generic primitives outward to the two conversion directions:

  • src/model/ — thin additions on top of document-schema.js, which owns the content model (ContentDocument, and since 4.0.0 the tree-form DocumentTree vocabulary) imported, not defined here; the LayoutDocument item family is pdf-codec's own since the schema-4 demotion. Local: bytes.ts (magic-byte schemas), units.ts (EMU/twip/point conversions), geometry.ts/color.ts/style.ts (thin re-exports plus PDF-specific flipY), paint-order.ts (merges drawing page shapes/vectors by paintOrder), formula.ts (helpers around ContentFormula), embedded-drawing.ts (packages recovered vectors as a ContentEmbeddedObjectBlock).
  • pdf-codec (external) — the hand-written PDF codec, plus generic byte/image primitives (now in byte-codec). See that package's own README.
  • src/ports/ — injectable ports: throwIfAborted (signal check at long-loop boundaries) and ClockPort/systemClock/fixedClock (injectable "now" for deterministic output — exported but not yet consumed by any conversion path).
  • src/xml/ and src/opc/ — parent-aware XML query/mutation and OPC package mechanics over ooxml.js's Package/XmlNode. src/xml/odf-text.ts holds encodeOdfText/decodeOdfText — see the ODF text gotcha below.
  • src/odf-package/ — ODF-side counterpart to src/opc/: manifest sync, media insertion (addImageMedia), and embedded formula sub-documents (addFormulaObject).
  • src/edit/ — the read-and-write editable model: live-view classes for all six editable formats, plus buildXPackage functions bridging ContentDocument to fresh packages. Key reuse patterns: src/edit/odp/* reuses src/edit/odt/* wholesale (identical text:p/text:span model); src/edit/odg/* reuses OdpShape for draw:frame content; src/edit/drawingml/vector.ts is the shared OOXML vector writer for docx and pptx; src/edit/odg/vector.ts is the shared ODF vector writer for odt/odp/odg. src/edit/ods/* is built from scratch (cell addressing) but reuses odt's style interning.
  • src/fonts/ — source-embedded font extraction (obfuscation.ts implements ECMA-376 Part 4, 2.8.1; ooxml.ts/odf.ts resolve font references) and registry.ts's createDocumentFontRegistry composing the precedence chain as data.
  • src/mathml/ — a self-contained MathML presentation-layer typesetting engine (no import from model, pdf-codec, or odf.js; consumes only port contracts from document-schema.js and its own locally-mirrored MathMlNode). Covers mrow/mi/mn/mo/mtext/mspace/msub/msup/msubsup/munder/mover/munderover/mfrac/msqrt/mroot/mtable/mtr/mtd/mstyle/semantics, driven by the injected MathFontMetrics port. Stretches vertical fences and horizontal braces via the font's MathVariants data.
  • src/omml/ — the MathML ⇄ OMML structural translator, both directions. write.ts covers the identical construct set src/mathml/layout.ts typesets; read.ts covers strictly more (reads what Word authored, not just what this package writes). Lives outside src/mathml/ because its I/O type is ooxml.js's XmlElement and src/mathml/ imports no package.
  • src/ooxml/ — thin adapters over ooxml.js's own flat readDocxContent/readPptxContent readers, wrapping results into ContentDocument. docx/formula.ts is the one local reading pass (splicing OOXML math equations). docx/extras.ts's readDocxExtras returns comments/footnotes/header-footer parts, section header/footer references, and numbering.
  • src/odf/ — ODF-side counterparts: readOdtContent/readOdpContent/readOdsContent/readOdgContent are thin adapters over odf.js. formula/read.ts/formula/detect.ts handle embedded formula detection (genuinely new work with no odf.js-side equivalent).
  • src/ppt/ — the one legacy-binary-format adapter with a genuine wrap of its own: ppt-codec's readPptContent/writePptContent operate on the flat { metadata, slides } shape (mirroring ooxml.js's/odf.js's own upstream flat readers), not a full 'presentation'-kind ContentDocument directly, so read.ts/write.ts do the envelope wrap/unwrap src/ooxml/pptx/read.ts/src/odf/odp/read.ts also do for their own formats — minus the formula/vector-recove