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

documents.js

v1.97.3

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 Release CI

Converts between any two compatible document formats through a shared content/layout pivot — docx, pptx, odt, odp, ods, odg, xlsx, and markdown all read into and build from the same ContentDocument/LayoutDocument model, with PDF simply the one format every variant can reach (docx/pptx/odt/odp/ods/odg/xlsx/markdown ⇄ PDF, fourteen pairs, all round-tripping both ways), plus sixteen further cross-format bridges, eight pairs (odt⇄docx, odp⇄pptx, ods⇄xlsx, markdown⇄docx, markdown⇄odt — five same-variant pairs sharing a ContentDocument pivot directly; docx⇄pptx, odt⇄odp — two cross-variant pairs through a semantic transform; and xlsx⇄markdown — one pdf-composed pair routing through the PDF pivot internally) that bypass an explicit layout/reconstruction pass for the pairs already sharing a pivot variant directly. Also included: a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, .odb (ODF database front-end) table extraction to xlsx/CSV from an embedded HSQLDB TEXT script (Tier 1), HSQLDB's own binary CACHED-table row-store format (Tier 2), and an embedded Firebird database's own gbak logical-backup format (Tier 3), plus Form/Report structure reading (bound controls, bands/groups/functions), a bounded single-table SQL SELECT engine that runs a .odb's own saved queries over that extracted data, a Report Builder rpt formula engine that evaluates a report's group breaks and footer totals over the result, and a structural report renderer that turns the printed bands into a real ContentDocument, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, docx comment/footnote/header-footer/numbering-definition exposure via readDocxExtras, real font resolution for ordinary text (a source document's own embedded faces extracted and rendered through, ahead of caller-supplied faces, metric-compatible vendored substitutes, and finally the standard 14), a hand-written MathML presentation-layer typesetting engine with embedded-font PDF rendering (odf → PDF, plus formulas embedded inside odt/odp) and a matching MathML → OMML translator so an embedded formula reaches a docx as real, editable Word math, and a fully hand-written PDF codec, built on ooxml.js, odf.js, and markdown-codec.

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 provided by pdf-codec, a sibling package extracted from this one: a hand-written, dependency-minimal PDF codec with no external PDF library (pdf-lib, pdfjs-dist, mupdf, or any other) as a dependency — see pdf-codec's own README for how it's built and what it embeds (including the vendored STIX Two Math font this package renders formulas through). src/mathml/ (the MathML typesetting engine) stays in this package and is hand-written too, for the same "no supply-chain surface beyond what's already declared" reason, but consumes pdf-codec's embedded math font through a structurally-typed port rather than any font-parsing code of its own — see Architecture. CommonMark+GFM markdown reading/writing is provided by markdown-codec, the same "hand-write the format instead of wrapping a third-party library" bet applied to markdown: no micromark/remark/marked/markdown-it/commonmark/mdast/unified/turndown/showdown dependency anywhere in that package.

graph TD
    schema("document-schema.js")
    ooxml("ooxml.js")
    odf("odf.js")
    pdfcodec("pdf-codec")
    mdcodec("markdown-codec")
    bytecodec("byte-codec")
    documents("documents.js")
    mcp("document-mcp")
    cli("document-cli")

    schema --> ooxml
    schema --> odf
    schema --> pdfcodec
    schema --> mdcodec
    schema --> documents
    ooxml --> documents
    odf --> documents
    pdfcodec --> documents
    mdcodec --> documents
    bytecodec --> pdfcodec
    bytecodec --> documents
    documents --> mcp
    pdfcodec --> mcp
    documents --> cli
    odf --> cli
    pdfcodec --> cli

    click schema "https://github.com/ExaDev/document-schema.js" "document-schema.js"
    click ooxml "https://github.com/ExaDev/ooxml.js" "ooxml.js"
    click odf "https://github.com/ExaDev/odf.js" "odf.js"
    click pdfcodec "https://github.com/ExaDev/pdf-codec" "pdf-codec"
    click mdcodec "https://github.com/ExaDev/markdown-codec" "markdown-codec"
    click bytecodec "https://github.com/ExaDev/byte-codec" "byte-codec"
    click documents "https://github.com/ExaDev/documents.js" "documents.js"
    click mcp "https://github.com/ExaDev/document-mcp" "document-mcp"
    click cli "https://github.com/ExaDev/document-cli" "document-cli"

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

Why

Converting docx/pptx to PDF and back is usually solved by wrapping a mature third-party PDF library. This package takes the opposite approach for the PDF side of the equation: pdf-codec hand-writes 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 — against the ISO 32000-1 specification, rather than wrapping one. That is a genuinely large undertaking, and it comes with an honest trade-off spelled out in Fidelity below and in pdf-codec's own README: 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 documents.js's own supply-chain surface staying limited to ooxml.js, odf.js, document-schema.js, pdf-codec, markdown-codec, 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.

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 documents.js
# or
npm install documents.js

Usage

The twelve round-trip ergonomic conversions between the six formats with their own layout engine and PDF (docx/pptx/odt/odp/ods/odg ⇄ PDF, all round-trip both ways), plus a thirteenth pair with the identical ergonomic shape and options — xlsxToPdf/pdfToXlsx, which composes the ods⇄xlsx bridge with the ods⇄pdf layout pair internally, since xlsx has no layout engine of its own — and a fourteenth, markdownToPdf/pdfToMarkdown, which DOES lay markdown out directly (it reuses the identical wordprocessing layout engine docx/odt already share):

import { docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToXlsx, pptxToPdf, 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 -- still a real, direct, single-call conversion
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

Each accepts an optional signal (AbortSignal) and either a onSubstitution callback (docx/pptx/odt/odp/ods/odg/xlsx/markdown → PDF, called once per character not representable in a standard-14 font) or a sink (PDF → docx/pptx/odt/odp/ods/odg/xlsx/markdown, called once per recoverable parse diagnostic).

Every X → PDF conversion additionally accepts fonts (extra ProvidedFont faces to make available) and onFontSubstitution (called once per requested family+weight+style that resolved to something else). Neither is needed for the common case: the conversion already extracts the source document's own embedded fonts and renders through them, so a docx or odt saved with font embedding turned on comes out in its real typeface at its real metrics with no caller involvement at all — see Fonts below for the full resolution order.

Sixteen further cross-format bridges across eight pairs bypass an explicit layout/reconstruction pass. Five of those pairs are same-variant direct copies: odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds, and markdownToDocx/docxToMarkdown, markdownToOdt/odtToMarkdown each compose a direct readXContentbuildYPackage pivot copy, since both sides of each pair already read into and build from the identical ContentDocument variant — no layout engine, no font measurement, and no geometry-based reconstruction in between. See Fidelity for what that means in practice, and for markdown specifically, why "no layout/reconstruction lossiness" is not the same claim as "no lossiness at all".

A further pair, xlsxToMarkdown/markdownToXlsx, is the one exception to "both sides share a variant": xlsx (spreadsheet) and markdown (wordprocessing) share no ContentDocument variant, so this pair routes through PDF internally (xlsxToPdf + pdfToMarkdown; markdownToPdf + pdfToXlsx) rather than copying a pivot directly. It is consequently the single lossiest conversion in the package — two stacked lossy hops (a spreadsheet rendered to a PDF page, then that page reconstructed as wordprocessing text) — and exists as a last resort for a caller with xlsx bytes who wants text and cannot read the cells directly via readXlsxContent. The DocumentConverter port routes it like any other bridge, and xlsxMarkdownCodec is its no-options z.codec() pair.

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, not merely approximated

Each takes an optional { signal } — there is no onSubstitution/sink option here, since there is no font substitution or PDF-parse degradation to report; a wrong-kind ContentDocument throws outright rather than becoming a diagnostic. odtToDocx/markdownToDocx/docxToOdt/docxToMarkdown additionally take onMathDiagnostic, called once per formula construct that degraded or was approximated as an embedded formula crossed the bridge — into OOXML math when building a docx, back out of it when reading one (see Architecture's src/omml/ entry). Either way it reports only what the target vocabulary genuinely has no counterpart for, never the whole formula. docxToPdf takes it too, for the read direction.

The same 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 },
);

DocumentFormat includes xlsx and markdown alongside docx/pptx/odt/odp/ods/odg/odf/pdf — ten members in total — xlsx because createLocalDocumentConverter's { source, targetFormat } contract already generalises past "targetFormat always means pdf" (xlsx has no PDF conversion of its own; markdown genuinely does, see markdownToPdf/pdfToMarkdown above). odtdocx, docxodt, odppptx, pptxodp, odsxlsx, xlsxods, markdowndocx, docxmarkdown, markdownodt, odtmarkdown, docxpptx, pptxdocx, odtodp, odpodt, xlsxmarkdown, and markdownxlsx are sixteen further entries in the same conversions list (eight pairs in total — ten same-variant direct copies, four cross-variant semantic transforms, two pdf-composed), routed to the sixteen bridge functions above with an empty diagnostics array. DocumentFormat itself is inferred from a real Zod schema, DocumentFormatSchema, rather than hand-written — both it and DOCUMENT_FORMATS (every member as a plain readonly DocumentFormat[], derived from that same schema so it cannot drift out of sync) are exported, for a caller that wants to enumerate or validate against the full format set without constructing its own schema — a CLI's own usage-error text, or an MCP tool's JSON-schema enum input:

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

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

Getting back the intermediate DocumentPackage (content + layout, from document-schema.js) a conversion built internally, instead of only the target bytes — every ergonomic conversion function above accepts an onDocument callback for this, and the port surfaces the same value as package on its ConversionResult:

import { docxToPdf } from 'documents.js';

const pdfBytes = docxToPdf(docxBytes, {
  onDocument: (pkg) => {
    console.log(pkg.content.kind); // 'wordprocessing'
    console.log(pkg.layout?.pages.length); // populated for every X-to-PDF/PDF-to-X conversion
  },
});

// or via the port:
const { document, package: pkg } = await converter.convert(
  { source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
  { signal: new AbortController().signal },
);

For the ten PDF-bypassing bridges, pkg.layout is always undefined — a bridge never runs a layout engine, so there is nothing to populate it with; running one purely to fill this field would be wasted work no caller asked for.

Turning that DocumentPackage into self-describing JSON — re-exported from document-schema.js, which owns the pivot schemas and the published .schema.json files (see that package's own README) — via documentPackageWithSchema, which stamps a $schema property pointing at the matching schema file for the currently installed document-schema.js version, and reading one back via documentFromJson, which uses that same $schema property to work out which of DocumentPackage/ContentDocument/LayoutDocument a value is before validating it:

import { documentFromJson, documentPackageWithSchema } from 'documents.js';

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

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

contentDocumentWithSchema/layoutDocumentWithSchema are the ContentDocument/LayoutDocument equivalents — these operate on the identical ContentDocument/ContentDocumentSchema this package imports and re-exports from document-schema.js above (a discriminated union of wordprocessing/presentation/spreadsheet/drawing variants wrapping ContentSection/ContentSlide/ContentSheet/ContentDrawPage), so no separate import or conversion step is needed to construct one for contentDocumentWithSchema.

Building any DocumentFormat's own bytes back out of an already-assembled DocumentPackage, instead of only ever getting one out of a conversion's own onDocument callback — buildDocumentBytes is the reverse of that callback: 'pdf' writes the package's own LayoutDocument half directly (throwing if the package carries none — only a <format>-to-pdf/pdf-to-<format> conversion's own dump has one; a bridge conversion's own dump, e.g. odtToDocx, never does), 'odf' (a standalone formula document) has no builder at all and throws outright, and every other target rebuilds a fresh package from the ContentDocument half through the identical buildXPackage function the matching pdfToX/bridge conversion already uses — xlsx included, via ooxml.js's own buildXlsxPackage:

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

let captured;
docxToPdf(docxBytes, { onDocument: (pkg) => { captured = pkg; } });
const pdfBytesAgain = buildDocumentBytes(captured, 'pdf');
const docxBytesAgain = buildDocumentBytes(captured, 'docx'); // rebuilds via buildDocxPackage, same as pdfToDocx's own package-building half

Decoding/encoding a DocumentFormat's own raw package container directly, without going through ContentDocument at all — the format-aware counterpart to ooxml.js's/odf.js's own decodePackage/encodePackage, for a caller holding a format + bytes rather than already knowing which of the two underlying container codecs applies. 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'/'pdf' (neither has a raw-package concept at all — markdown is plain text, not a zip container, and PDF is its own binary format, not OPC/ODF). decodeOdbPackage is the .odb-specific sibling: 'odb' is deliberately not a DocumentFormat member (see the .odb entries below), but its bytes are an ordinary ODF package, decoded through the identical odf.js decodePackage every readOdb*/odbTo* function below already starts from — there is no encodeOdbPackage, since nothing in this package's .odb support ever writes a new .odb file:

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

const pkg = decodeDocumentPackage('docx', docxBytes); // -> ooxml.js's own Package
const docxBytesAgain = encodeDocumentPackage('docx', pkg);

const odbPkg = decodeOdbPackage(odbBytes); // -> odf.js's own Package -- feed straight into readOdbTables/readOdbInventory/etc. below

Reading a document's own title/author/subject/keywords/creator/producer/created/modified, or patching its title/author/subject/keywords (the four fields MetadataOverrides covers), across any of the ten DocumentFormats, without caring which underlying reader/writer a given format uses:

import { readDocumentMetadata, setDocumentMetadata } from 'documents.js';

const metadata = readDocumentMetadata('docx', docxBytes); // -> LayoutMetadata
console.log(metadata.title, metadata.author);

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

setDocumentMetadata patches metadata in place; it does not convert format — sourceFormat and targetFormat must match (or both be 'pdf'), and it throws naming which one to fix otherwise. A 'pdf' source/target patches the parsed LayoutDocument directly (no ContentDocument, no layout engine — genuinely lossless for everything else on the page); every other supported format (docx/pptx/odt/odp/ods/odg/markdown/xlsx) rebuilds a fresh package from that format's own ContentDocument, which costs whatever that format's own buildXPackage already costs (docx, for instance, still drops comments/footnotes/headers-footers/numbering on a rebuild — see readDocxExtras above). xlsx now rebuilds through this same path too — it is no longer rejected. 'odf' (a standalone formula document) is rejected outright in both directions, since it has no write path back out at all. An override omitted from the call (rather than passed as an empty string/array) leaves that field exactly as the source document already had it, matching every other partial-update convention in this package.

readDocumentMetadata('xlsx', ...) is the one deliberate, named exception to the "dispatch by format" rule above: rather than reading a fresh ContentDocument.metadata directly (which leaves createdIso/modifiedIso/producer unset), it renders the workbook to PDF via xlsxToPdf and reads .metadata off that PDF instead — kept because a direct readXlsxContent(...).metadata and that PDF-preview path genuinely disagree on those three fields, not merely incidentally (confirmed directly, src/metadata/read.test.ts's own xlsx case). setDocumentMetadata/buildDocumentBytes do not carry this exception: both now treat xlsx uniformly with every other rebuildable format, via ooxml.js's own readXlsxContent/buildXlsxPackage.

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' });

A docx's own comments, footnotes, headers/footers, and numbering (w:abstractNum/w:num) definitions never fit ContentDocument's section/block shape, so readDocxContent never carried them — readDocxExtras is a second, independent read of the same package that returns exactly that data as its own real type, for a caller that wants it without reaching for ooxml.js's own readDocx directly:

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

const { comments, footnotes, headers, footers, numbering } = readDocxExtras(decodePackage(docxBytes));
console.log(comments[0]?.author, comments[0]?.text, footnotes[0]?.text, headers[0], footers[0]);
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 (slide.addTextBox, slide.addImage, shape.setParagraphs for multi-paragraph styled text).

openOdt/createOdt and OdtParagraph/OdtRun/OdtTable/OdtList are the odt equivalent, built on ODF's own style-name-referencing model (run.bold = true interns or reuses a named style:style in office:automatic-styles, rather than writing an inline attribute — see Conventions below). A list item reads back as well as appends: OdtListItem.paragraphs() and .nestedLists() return live views on its own text:p children and any text:list nested inside it (the read counterparts to appendParagraph/addNestedList), and .text is those paragraphs newline-joined, matching OdtTableCell.text/OdpShape.text's own convention — a nested list's text belongs to that list's own items, not to the item containing it, since ODF nests lists structurally rather than flagging membership per paragraph. editor.body.appendFormula(formula, frame) writes a real embedded formula: a whole nested ODF formula sub-document inside the same package, referenced from a draw:frame/draw:object, which is how ODF embeds a formula at all (see Architecture's src/odf-package/ entry) — the odt counterpart to DocxParagraph.appendOfficeMath. openOdp/createOdp and OdpSlide/OdpShape are the odp equivalent of PptxSlide/PptxShape (slide.addTextBox, slide.addImage, slide.notes), and reuse OdtParagraph/OdtRun/OdtList directly for a shape's own text content — a draw:frame's draw:text-box holds the identical text:p/text:span model office:text does, interned into the same content.xml style registry:

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 -- PptxShape has the equivalent a:xfrm/@rot setter now too, see Architecture below
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 — cell addressing has no docx/pptx analogue at all, so this is the one editor family built from scratch rather than reusing OdtParagraph/OdtRun. Setting a cell far from the origin does not materialise every cell in between: the underlying table:number-columns-repeated/table:number-rows-repeated runs are split in place at exactly the target position, the same repeat-compression convention odf.js's own reader already reads. OdsSheet.printSettings is a genuine getter/setter too (src/edit/ods/print-settings.ts) — a set mints a fresh style:page-layout/style:master-page/style:style[family="table"] chain and repoints the sheet at it, rather than mutating whatever it was pointing at before, matching this package's own append-only style-editing convention throughout.

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) -- there is no A1-string overload
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 — a page-level container (draw:page), extended with the vector-primitive setters a drawing carries that a presentation typically doesn't. OdgPage.addTextBox/.addImage return real OdpShape instances (draw:frame's content model is byte-for-byte identical between odp and odg — see Architecture); addRect/addEllipse/addLine/addPath return OdgBoxVector/OdgLineVector/OdgPathVector, writing real draw:rect/draw:ellipse/draw:line/draw:path elements — addPath takes whatever ContentSubpath[] the caller passes, lines and cubics both, with no fixed or preset shape vocabulary of its own. OdgPage.vectors() is the read counterpart to those four (shapes() is the counterpart to addTextBox/addImage): it returns a live handle on every vector already on the page, in paint order, as an OdgVector union discriminated on kind ('rect'/'ellipse'/'line'/'path', the same vocabulary ContentVector uses) — so a vector's fill, stroke, frame, and rotation stay editable long after the add* call that created it, exactly like every other live view in this package. A vector's own paint order is purely document order — the same convention real LibreOffice output already uses, so an earlier add* call paints behind a later one, with no draw:z-index attribute ever written.

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

buildOdsPackage bridges a spreadsheet ContentDocument (either one from readOdsContent, or a best-effort one from reconstructSpreadsheet) to a fresh package built entirely through the same primitives — pdfToOds's own package-building half, mirroring buildOdtPackage/buildOdpPackage's role for pdfToOdt/pdfToOdp. buildOdgPackage bridges a drawing ContentDocument (either one from readOdgContent, or a best-effort one from reconstructDrawing) to a fresh package built entirely through the same primitives — pdfToOdg's own package-building half.

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 nine round trips (PDF ⇄ LayoutDocument, docx ⇄ PDF, pptx ⇄ PDF, odt ⇄ PDF, odp ⇄ PDF, ods ⇄ PDF, odg ⇄ PDF, xlsx ⇄ PDF, markdown ⇄ PDF) are each also available as a schema-validated z.codec() pair, mirroring ooxml.js's own packageCodecz.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);

The ten PDF-bypassing bridges above get the same treatment: odtDocxCodec, odpPptxCodec, odsXlsxCodec (odt bytes ⇄ docx bytes, odp bytes ⇄ pptx bytes, ods bytes ⇄ xlsx bytes), and markdownDocxCodec/markdownOdtCodec (markdown bytes ⇄ docx bytes, markdown bytes ⇄ odt bytes) — the no-options form again, odtToDocx/docxToOdt/markdownToDocx/docxToMarkdown/etc. remain the entry points for signal.

readDocxContent/readPptxContent/readOdtContent/readOdpContent/readOdsContent/readOdgContent/readMarkdownContent (docx/pptx/odt/odp/ods/odg/markdown → ContentDocument), buildMarkdownText (ContentDocument → markdown text, markdown's own write-side counterpart — MarkdownEditor.toMarkdownText (src/edit/markdown/editor.ts) calls it directly as its own save step rather than wrapping a byte-level writer, so this remains the whole write path even though markdown now has a live-view editor), convertWordprocessingToLayout/convertPresentationToLayout/convertSpreadsheetToLayout/convertDrawingToLayout (ContentDocumentLayoutDocument), and reconstructWordprocessing/reconstructPresentation/reconstructSpreadsheet/reconstructDrawing (LayoutDocumentContentDocument) are each exported individually too, for a caller that wants one stage of the pipeline without the rest. readDocxContent and readOdtContent both produce the identical wordprocessing-variant ContentDocument shape from two completely unrelated package formats (OOXML and ODF), which is what lets odtToPdf feed convertWordprocessingToLayout without a single line of that engine changing; readMarkdownContent produces that identical shape too, from markdown-codec's own readMarkdown, making markdown the third format sharing this one pivot and layout engine — not just a second data point; readPptxContent and readOdpContent do the same for the presentation variant and convertPresentationToLayout. readOdgContent/convertDrawingToLayout has no OOXML-side counterpart at all (no drawing-equivalent OOXML format this package reads); readOdsContent/convertSpreadsheetToLayout now does have one on the read side — ooxml.js's own readXlsxContent — but only for the PDF-bypassing odsToXlsx/xlsxToOds bridge below, not for the PDF pivot: xlsx has no PDF conversion of its own, so convertSpreadsheetToLayout still has no xlsx-layout counterpart to reuse or be reused by. Both convertSpreadsheetToLayout and convertDrawingToLayout are genuinely new layout algorithms, since a spreadsheet's addressed-grid-with-print-settings semantics and a drawing's vector-primitive vocabulary (rect/ellipse/line/path) have no flow/pagination or direct-placement analogue; convertDrawingToLayout does still reuse convertPresentationToLayout's own shape-conversion logic (convertShape, exported from src/layout/slides.ts) verbatim for whatever text/image/table content a drawing page also carries. reconstructDrawing is reconstructWordprocessing/reconstructPresentation's drawing-side counterpart, but does no baseline/paragraph clustering at all — a drawing has no semantic structure to recover, only a near-1:1 LayoutItemContentVector/ContentShape mapping to make, in the same paint order the items were recovered in. reconstructSpreadsheet is a genuinely different geometry-recovery problem from either: a real gridline lattice on the page (drawn by a printed sheet with gridlines enabled) is used DIRECTLY as cell boundaries when one is detected; absent one, text is clustered into a 2D grid from geometry alone. It recovers what was printed, not what was entered: every cell keeps its rendered string verbatim in displayText, and additionally gets a heuristically re-typed value (number/percentage/currency/date/boolean) wherever exactly one reading of that string is defensible — an explicitly probabilistic step, reported per cell through ReconstructOptions.onCellTypeInference, and never extended to claiming a formula (see Fidelity). reconstructWordprocessing/reconstructPresentation additionally recover a page's vector primitives and, gated strictly on a real drawn gridline lattice, a real table — see the Gotchas entries on each.

One further conversion, odmToPdf, is shaped differently from every conversion above: a .odm (ODF master document, a "book" of chapters) never carries its own chapters' content — each text:section is a bare external reference to a standalone .odt file, confirmed against real LibreOffice output (see Gotchas below) — so producing a PDF needs a caller-supplied resolveSubDocument callback to hand back each chapter's own bytes given that section's href. Every chapter's own ContentSection[] is concatenated in text:section document order into one combined document, with an explicit page break marking each chapter boundary, and fed through the same convertWordprocessingToLayout engine every wordprocessing-variant conversion above already uses unmodified:

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
  }
}

odmToPdf is not one of the fourteen round-trip conversions or the sixteen bridges above, has no z.codec() pair, and is not wired into the DocumentConverter port below — see Gotchas for why.

.odb (ODF database front-end) support: readOdbTables extracts every table an embedded database declares, and odbToXlsx/odbToCsv turn that straight into xlsx or CSV bytes. Every embedded storage shape LibreOffice's own two embedded engines can produce is supported, dispatched automatically from the package's own connection URL and, for HSQLDB, its own per-table storage shape and script format: a MEMORY/TEXT table's rows inline in database/script as ordinary TEXT-format SQL (Tier 1, src/hsqldb/script.ts), a CACHED table's rows in a separate binary page-cache file, database/data (Tier 2, src/hsqldb/cache.ts/rowformat.ts — LibreOffice's own embedded-HSQLDB default, see Architecture/Gotchas for the exact scope and version pinning), a Firebird database's own database/firebird.fbk part — LibreOffice's modern default embedded engine since 4.1, a genuine gbak logical-backup stream rather than a raw on-disk database file (Tier 3; see the Gotchas entry below for the empirical finding this rests on) — and HSQLDB's own whole-script BINARY (hsqldb.script_format=1) and COMPRESSED (=3) serialisations of database/script itself (Tier 4, src/hsqldb/binary-script.ts). A caller never needs to know which shape, engine, or script format a given .odb used:

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

const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table, a header row of column names then one row per record
const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // exactly one named table as CSV -- required whenever the .odb has more than one table

const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[], for a caller that wants the raw table/column/row data without going through xlsx or CSV -- the identical shape whether the .odb is HSQLDB- or Firebird-backed

A .odb's own Form/Report structure (as opposed to readOdbTables' table data): odf.js 2.0.0's OdbInventory.forms/.reports carry each declared component's own name and href, and its readOdbForm/readOdbReport resolve one named component into its real static structure — a form's bound controls, a report's bands/groups/functions — re-exported here unmodified. readOdbForms/readOdbReports are this package's own "read every declared one at once" convenience, the readOdbTables-shaped one-call ergonomic this data did not have before odf.js made forms/reports real:

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

const forms = readOdbForms(decodePackage(odbBytes)); // OdbForm[] -- each form's own bound controls (form:text/form:data-field/etc), plus its content read as an ordinary ODT document via odf.js's readOdt
const reports = readOdbReports(decodePackage(odbBytes)); // OdbReport[] -- each report's own bands (report-header/detail/report-footer/...), groups, and functions, with each control's own data-bound field name resolved from its rpt:formula

// A caller wanting exactly one named form/report can call odf.js's own readOdbForm/readOdbReport directly instead -- both are re-exported unmodified alongside the two convenience functions above.

This is structure, not rendering — but rendering is now a real thing this package does with it, and readOdbReportContent below is the whole chain in one call: it resolves the report's own query against the data, evaluates its bands' formulas over the result, and lays the printed bands out as a real ContentDocument. What is not offered is a pixel-faithful reproduction of Report Builder's own page output; see Fidelity for exactly where that line falls.

readOdbTables takes a decoded Package (matching readOdtContent/readOdsContent/etc.'s own convention), while odbToXlsx/odbToCsv take raw bytes and decode them internally, matching every other ergonomic conversion in this package. .odb has no odbToPdf ergonomic conversion over the whole database and no reverse (xlsx/CSV → .odb) direction, and — like odmToPdf — is not wired into the DocumentConverter port below: the write direction would need a real embedded SQL engine this package deliberately does not implement, and .odb as a whole has no single natural target format, since a database front-end's tables, its saved queries, and its reports are three unrelated output shapes rather than one. A rendered report is a narrower, real exception to that: it is an ordinary wordprocessing ContentDocument, so odbReportToDocx/odbReportToOdt/odbReportToPdf (see below) dispatch it to real bytes the same one-call way every other ergonomic conversion in this package does.

readFirebirdBackup (src/firebird/backup.ts) is also exported individually, for a caller that has already extracted a Firebird-backed .odb's own database/firebird.fbk bytes and wants to decode them directly without going through a Package at all:

import { readFirebirdBackup } from 'documents.js';

const { summary, tables } = readFirebirdBackup(firebirdBackupBytes); // summary: backupFormatVersion/transportable/compressed/pageSizeBytes; tables: the same HsqldbTable[] shape

A .odb's own saved queries arrive as SQL text (OdbQueryInfo.command, via readOdbInventory), which on its own answers nothing about the data. parseSelect/evaluateSelect (src/odb/sql/) close that gap: a bounded single-table SELECT engine that runs directly over the HsqldbTable[] readOdbTables produces, in memory, with no database engine anywhere in the path:

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

const pkg = decodePackage(odbBytes);
const [query] = readOdbInventory(pkg).queries; // e.g. { name: 'HighValueSales', command: 'SELECT "SALES"."REGION", ... ORDER BY "SALES"."AMOUNT" DESC' }
const { columns, rows } = evaluateSelect(parseSelect(query.command), readOdbTables(pkg)); // columns: string[]; rows: ContentCellValue[][]

// Or write the query yourself, against whatever readOdbTables returned:
const byRegion = evaluateSelect(parseSelect('SELECT REGION, COUNT(*), SUM(AMOUNT) FROM SALES GROUP BY REGION ORDER BY REGION ASC'), readOdbTables(pkg));

The grammar is a closed allowlist: SELECT a column list or * (or COUNT/SUM/AVG/MIN/MAX) FROM one table, with optional WHERE (comparisons, AND/OR/NOT with parentheses, IS [NOT] NULL, [NOT] LIKE, [NOT] IN, [NOT] BETWEEN), GROUP BY, and a multi-column ORDER BY. JOINs, subqueries, UNION, DISTINCT, HAVING, row limits, aliases, and every scalar function beyond those five aggregates throw HsqldbSqlUnsupportedError naming the construct — never a silently partial or wrong result set. tokenizeSql is exported too, for a caller that wants the token stream without the grammar. See Gotchas for the full boundary, and Fidelity for the semantics (three-valued NULL logic, NULL ordering, group ordering).

A Report's own bands go one step further than a query: each bound control carries an rpt:formula attribute, and a report declares nested groups whose break tests and per-group totals are written in that same little language. runRptReport (src/odb/formula/) evaluates it over the result set the query engine just produced, turning a report's static structure into the band instances a renderer would lay out — each carrying its own evaluated values:

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

const pkg = decodePackage(odbBytes);
const [report] = readOdbReports(pkg); // e.g. { name: 'SalesByRegion', command: 'HighValueSales', commandType: 'query', groups: [...], functions: [...] }
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);
// bands: one entry per printed band, in print order -- 'report-header', then per row the 'group-header's that open at it, the
// 'detail' band, and the 'group-footer's that close after it, then 'report-footer'. Each carries `values`, one evaluated
// ContentCellValue per band element (undefined for an element with no formula of its own, e.g. a fixed-content label).

The function set is a closed allowlist here too: rpt:HASCHANGED(X) (the group-break test — true when X differs from its value on the preceding row), rpt:LEFT(X;n) (note the semicolon separator, LibreOffice's own formula-language convention), and rpt:SUM/COUNT/AVG/MIN/MAX, plus the separate field:[COLUMN] bound-field form, which is a plain value passthrough rather than a computation. Every other rpt function — and Report Builder ships many — throws RptFormulaUnsupportedError naming it. parseRptFormula is exported too, for a caller that wants one formula's AST without running a report. See Gotchas for the group-scoping rule, which is the substance of this engine.

readOdbReportContent (src/odb/report/) is all of the above in one call — the report's data binding resolved, its query run, its formulas evaluated, and its printed bands rendered as a real ContentDocument:

import { decodePackage } from 'odf.js';
import { readOdbReportContent } from 'documents.js';

const document = readOdbReportContent(decodePackage(odbBytes)); // a 'wordprocessing' ContentDocument -- one section, one block per printed band
const another = readOdbReportContent(decodePackage(odbBytes), { report: 'SalesByRegion' }); // required whenever the .odb declares more than one

odbReportToDocx/odbReportToOdt/odbReportToPdf are the last step, dispatching a rendered report's own ContentDocument to real bytes the same "read/render → encode" shape every other ergonomic conversion in this package has — they take the ContentDocument readOdbReportContent already produced, not a Package, since a rendered report has no source package of its own left to round-trip through:

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

const report = readOdbReportContent(decodePackage(odbBytes), { report: 'SalesByRegion' });
const docxBytes = odbReportToDocx(report); // via buildDocxPackage -- takes the same onMathDiagnostic every other ContentDocument-to-docx entry point does, though a report control's own text is plain and never triggers it
const odtBytes = odbReportToOdt(report); // via buildOdtPackage
const pdfBytes = odbReportToPdf(report); // via convertWordprocessingToLayout + writePdf -- options are DocumentToPdfOptions verbatim, the same type docxToPdf/odtToPdf/markdownToPdf already use; throws if content is not the wordprocessing variant readOdbReportContent always produces

Resolving the report's own rpt:command/rpt:command-type binding is the one part the formula engine never saw: "table" means the command names a table and the report reads all of it (turned into a real SELECT * FROM "<table>" and run through the same engine, rather than a second resolution rule that could disagree with it), "query" means it names a saved query in the .odb's own db:queries whose db:command holds the SQL, and "command" means the command is the SQL. Rows arrive in that command's own ORDER BY order, and the report's rpt:sort-expression is deliberately not applied on top — a group's sort expression is a bare column name, so re-sorting by it would discard whatever finer ordering the command already asked for (the real fixture's saved query orders REGION, QUARTER, then AMOUNT descending, and the two group sort expressions name only the first two).

Each printed band becomes one single-row ContentTable, one cell per control, in document order — the same shape the band has in the report file itself, where every band is a table:table whose cells hold its controls. Every cell's paragraph carries the band's own name as its styleId (Report Header, Page Header, Group Header 1, Detail, Group Footer 1, Report Footer, …), so which band a block printed from survives into the document rather than having to be inferred from its position. Its three stages stay independently usable like every other .odb stage: odbReportCommandSql (a report → the SQL it issues), resolveOdbReportRows (a package + a report → those rows), and renderOdbReportContent (a report + any equivalently-shaped rows → the document — useful for rendering the same report over an unfiltered table, say). See Fidelity for what "structural, not pixel-faithful" means here in detail.

A standalone .odf (an ODF formula document) converts to PDF via odfToPdf, rendering the formula's own real MathML through a hand-written typesetting engine (src/mathml/) and the embedded STIX Two Math font, not a static image or a StarMath-text placeholder. Its onDocument callback reports a real 'formula'-kind ContentDocument, the same as every other conversion reports its own pivot:

import { odfToPdf } from 'documents.js';

const pdfBytes = odfToPdf(odfBytes); // a single formula (or small formula document), faithfully typeset -- see Fidelity

odfToPdf is not one of the fourteen round-trip conversions above either: there is no pdfToOdf (recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one — see Fidelity), no z.codec() pair, and — unlike odmToPdf — it is wired into the DocumentConverter port below, as a DocumentFormat: 'odf' source with only a 'pdf' target.

Standalone .odf files are rare in practice; a formula embedded inside an odt paragraph or an odp slide is the far more common real-world case, and odtToPdf/odpToPdf already render one automatically wherever readOdtContent/readOdpContent find a draw:frame referencing an embedded formula sub-object — no extra code needed at the call site:

import { odtToPdf } from 'documents.js';

// odtBytes contains an ordinary paragraph followed by an embedded formula object (LibreOffice: Insert > Object > Formula) --
// the formula renders as real typeset MathML in the output PDF, at the position and approximate size of its own source frame.
const pdfBytes = odtToPdf(odtBytes);

The formula's real MathML travels inside the ContentDocument: readOdtContent/readOdpContent return a bare ContentDocument (exactly like readDocxContent/readPptxContent), and an embedded formula is an ordinary ContentEmbeddedObjectBlock whose own document is a genuine 'formula'-kind ContentDocument carrying { mathml, starMath? } — document-schema.js's fifth ContentDocument variant. There is no side-channel map to thread anywhere:

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 }); // writePdf's own formula-aware option -- see Architecture

writePdf's formulas option is the one place a formula still travels beside its document rather than within it, and for a different reason: a rendered formula's CID-font glyph runs have no LayoutItem kind to be (see pdf-codec's own README), so convertWordprocessingToLayout/convertPresentationToLayout return the positioned results alongside the LayoutDocument.

layoutFormula (the typesetting engine's own entry point) and loadMathFont (the embedded STIX Two Math font, parsed and cached once per process) are each exported individually too, for a caller that wants to lay out a formula directly:

import { layoutFormula, loadMathFont } from 'documents.js';

const { metricsAt } = loadMathFont();
const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), sizePt: 12, color: { r: 0, g: 0, b: 0 } });
// box: a MathBox -- positioned glyph runs, fraction/radical rules, and radical-hook strokes, ready for pdf-codec's own math-content-write.ts
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see Fidelity

buildOfficeMath/buildOfficeMathParagraph are the write-side counterpart, translating the same MathML into real OMML (OOXML's own math markup) rather than into positioned glyphs — buildDocxPackage uses them for every embedded formula, and they are exported for a caller assembling OOXML math itself, e.g. into a docx opened through openDocx:

import { buildOfficeMathParagraph, openDocx } from 'documents.js';

const editor = openDocx(existingDocxBytes);
const { diagnostics } = editor.body.appendParagraph().appendOfficeMath(mathml); // appends a real m:oMathPara > m:oMath equation
// diagnostics: an 'unsupported-element' or 'approximated-element' entry per construct OMML has no faithful counterpart for -- see Gotchas

const { element } = buildOfficeMathParagraph(mathml); // or build the fragment directly, for a caller placing it itself

readOfficeMath/collectOfficeMathElements are the read-side inverse — an OOXML equation back to real MathML. readDocxContent runs them over every paragraph itself (see Architecture's src/omml/ entry), so an equation in a docx arrives as an ordinary formula-carrying ContentEmbeddedObjectBlock with no caller involvement; these are exported for a caller mining equations out of a docx directly:

import { collectOfficeMathElements, readOfficeMath } from 'documents.js';

for (const equation of collectOfficeMathElements(paragraphElement.children)) {
  const { mathml, diagnostics } = readOfficeMath(equation);
  // mathml: the children of a <math> root -- exactly what ContentFormula.mathml holds, and what layoutFormula above consumes
  // diagnostics: an 'unsupported-element' or 'approximated-element' entry per OMML construct MathML has no faithful counterpart for -- see Gotchas
}

Every module under src/ is also directly deep-importable by its package-relative path, without going through the barrel — useful for a caller that wants exactly one conversion function and nothing else pulled in:

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

This works via a "./*" wildcard entry in package.json's exports map, resolving any subpath to the correspondingly-named file under dist/ — the same directory structure src/ has, one output file per source module, so src/edit/odt/content.ts becomes dist/edit/odt/content.js/.cjs/.d.ts/.d.cts.

Fonts

Every X → PDF conversion (docxToPdf, pptxToPdf, odtToPdf, odpToPdf, odsToPdf, odgToPdf, plus markdownToPdf/xlsxToPdf/odmToPdf) resolves each requested typeface through a real FontRegistry, in this order:

  1. The source document's own embedded faces. A docx that was saved with font embedding on carries the exact bytes it was authored against, in word/fontTable.xml's w:embed* parts (obfuscated per ECMA-376 Part 4, 2.8.1 — the first 32 bytes XORed against a key derived from the accompanying w:fontKey GUID); a pptx carries them in p:embeddedFontLst (unobfuscated .fntdata parts); an ODF package carries them under Fonts/, declared by office:font-face-decls's svg:font-face-uri (also unobfuscated). All three are extracted automatically — the caller does nothing.
  2. Faces the caller supplied through options.fonts, for a family the source document did not embed.
  3. pdf-codec's vendored Carlito and Caladea faces, genuinely metric-compatible with Calibri and Cambria, embedded as real subsetted TrueType font programs.
  4. The standard 14, for everything else — where Helvetica/Times-Roman remain metric-compatible with Arial/Times New Roman and a width-correction factor approximates the rest.

The same registry drives both halves of a conversion: the TextMeasurer that decides where lines break and the writer that emits the glyphs. That is load-bearing rather than tidy — measuring against Helvetica's metrics and then drawing through a real Carlito face would wrap text at positions that do not match what was painted.

import { docxToPdf } from 'documents.js';

// Nothing to configure: a docx that embedded its fonts renders in its real typeface.
const pdfBytes = docxToPdf(docxBytes);

// A face for a family the document didn't embed, plus a report of anything that still fell back.
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 family a vendored substitute covers writes byte-identical output to the standard-14-only pipeline this package had before font resolution existed — proven by a real before/after byte comparison across all six conversions in src/convert/convert-fonts.test.ts, against a reference that reproduces the old pipeline exactly.

Two honest limits, both structural rather than provisional. An embedded face is normally subsetted by the application that saved it, so it can legitimately lack a character this package synthesises rather than reads (a list bullet, sheets.ts's ### column-overflow marker); pdf-codec reports that per character through onMissingGlyph and falls back for that one character, never for the run or the document. And odfToPdf accepts both font options and consults neither — a standalone formula document emits no positioned text at all, only the embedded STIX Two Math font's own glyphs, which are not registry-resolvable.

extractOoxmlEmbeddedFonts/extractOdfEmbeddedFonts, extractSourceFonts, and createDocumentFontRegistry are exported for a caller composing readXContentconvertXToLayoutwritePdf themselves rather than going through an ergonomic conversion.

extractSourceFontsForFormat is the DocumentFormat-aware counterpart to extractSourceFonts above, for a caller holding a format + bytes rather than an already-decoded Package: docx/pptx decode via ooxml.js's own decodePackage, odt/odp/ods/odg via odf.js's. xlsx, pdf, markdown, and odf (a standalone formula document, which embeds only the STIX Two Math font pdf-codec itself carries, never a caller-resolvable face) throw UnsupportedFontSourceFormatError — none of the four has a source-embedded-font concept of its own to extract:

import { extractSourceFontsForFormat } from 'documents.js';

const faces = extractSourceFontsForFormat('docx', docxBytes); // -> readonly ProvidedFont[], the same shape createDocumentFontRegistry consumes

describeFontFace is the standalone-file counterpart to extractSourceFonts/extractSourceFontsForFormat above: where those extract the faces a document already embeds, describeFontFace inspects an arbitrary standalone .ttf/.otf font file the caller holds and reports its family, bold, and italic — the same FontFace shape (owned by document-schema.js) ProvidedFont builds on. It is a re-export of pdf-codec's own readFontFace, throws FontFaceParseError (also re-exported) for bytes that are not a parseable sfnt font, and takes a source string used only in diagnostics:

import { describeFontFace } from 'documents.js';

const { family, bold, italic } = describeFontFace(fontBytes, 'BrandSans-Regular.ttf'); // -> FontFace (from document-schema.js), reused directly as a ProvidedFont's identity

Architecture

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

  • src/model/ — thin, documents.js-specific additions on top of the sibling document-schema.js package, which now owns the two pivot models themselves: LayoutDocument (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — LayoutPath is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind writePath, pdf-codec's own content-write.ts, turns into PDF m/l/c/h content-stream operators) and ContentDocument (the semantic pivot: a discriminated union of wordprocessing, presentation, spreadsheet, drawing, and formula variants — the first four sharing paragraph/run/table/image building blocks, drawing's own ContentVector vocabulary — rect/ellipse/line/path — being the vector-primitive counterpart to the shared ContentShape, and formula carrying a real MathML tree rather than any of them) are both imported, not defined here — document-schema.js exists specifically so ooxml.js, odf.js, pdf-codec, and documents.js share one schema instead of each maintaining an independent, drift-prone copy. What remains local: bytes.ts (magic-byte-validated Uint8Array schemas for docx/pptx/PDF, plus Odt/Ods/Odp/OdgBytesSchema, which check the package's actual declared media type against odf.js's ODF_MEDIA_TYPES table rather than only the generic ZIP signature the OOXML schemas are limited to), units.ts (OOXML EMU/twip/point/half-point conversions), and geometry.ts/color.ts/style.ts, each now mostly a thin re-export of document-schema.js's Box/Margins/PageSize/Color/Alignment/LayoutFont — the one genuinely PDF-specific piece each still adds locally is geometry.ts's flipY (the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates); LayoutFont/DEFAULT_LAYOUT_FONT moved to document-schema.js too (since LayoutText, part of the pivot, needs the field), leaving only the standard-14 font resolution logic that consumes it (pdf-codec's own fonts.ts/font-read.ts) as PDF-specific, now external to this package entirely. ContentDocument/ContentDocumentSchema/CONTENT_FORMAT_VERSION themselves have no local file at all any more — every consumer imports them directly from document-schema.js, which owns the envelope as well as everything it wraps. paint-order.ts's mergeByPaintOrder merges a drawing page's two arrays (shapes, vectors) back into one true-paint-order walk through the shared paintOrder field both carry; it lives here rather than beside either caller because src/layout/drawing.ts and src/edit/odg/content.ts both need the identical merge and src/layout/* deliberately imports no odf.js/edit code. formula.ts holds the small helpers around document-schema.js's own ContentFormula: the 'formula'-kind ContentDocument envelope, the ContentEmbeddedObjectBlock an odt/odp reader produces for an inline formula, the narrowing back out of such a block, and the plain-text stand-in (`formulaP