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, and markdown all read into and build from the same
ContentDocument/LayoutDocumentmodel, with PDF as the one format every variant can reach. A composition engine (convertDocument) routes 111 (source, target) pairs across the ten content formats and PDF, including twenty PDF-pivot round trips (the eight layout-engine formats, plus xlsx and csv composing through ods), twenty-four cross-format bridge functions (same-variant direct copies, cross-variant semantic transforms, and PDF-composed), plus special-case conversions for.odmmaster documents,.odbdatabase front-ends (HSQLDB and Firebird, four storage tiers), standalone.odfformula documents, and a bounded SQL/rpt-formula engine for.odbreports. Also includes: read-and-write live-view editors for all six editable formats, docx comment/footnote/header-footer exposure viareadDocxExtras, 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, 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")
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
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.
Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).
pnpm installInstall as a dependency in another project:
pnpm add documents.js
# or
npm install documents.jsA single function, convertDocument, sits behind every named conversion and reaches every pair the composition engine can route — all 111 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.
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):
import { csvToPdf, docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToCsv, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToSvg, pdfToXlsx, pptxToPdf, svgToPdf, 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
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)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.
Twenty-four bridge functions across twelve pairs 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. Two PDF-composed pairs (xlsxToMarkdown/markdownToXlsx, csvToMarkdown/markdownToCsv) route through PDF internally — the lossiest conversions in the package.
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 hopEach 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 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/pdf — twelve members. The port's conversions list is derived from resolveCompositionPlan plus the odf→pdf special case — 111 pairs total. DocumentFormat is inferred from DocumentFormatSchema (a real Zod schema); DOCUMENT_FORMATS is exported as a plain array derived from the same schema:
import { DOCUMENT_FORMATS, DocumentFormatSchema } from 'documents.js';
console.log(DOCUMENT_FORMATS); // ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'odf', 'csv', 'markdown', 'pdf']
DocumentFormatSchema.parse(userSuppliedFormat); // throws a ZodError for anything outside that listEvery conversion function accepts an onDocument callback receiving the intermediate DocumentPackage — the fused unified tree of document-schema.js 3: content (whose own nodes carry frames, the rendered page positions the layout pass stamped onto them, in PDF user-space) plus pages (each rendered page's size, indexed to match every frames[].pageIndex). 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 { docxToPdf } from 'documents.js';
const pdfBytes = docxToPdf(docxBytes, {
onDocument: (pkg) => {
console.log(pkg.content.kind); // 'wordprocessing'
console.log(pkg.pages?.length); // populated for every X-to-PDF/PDF-to-X conversion
const block = pkg.content.kind === 'wordprocessing' ? pkg.content.sections[0]?.blocks[0] : undefined;
console.log(block?.kind === 'paragraph' ? block.runs[0]?.frames : 'no paragraph'); // that run's rendered placements
},
});documentPackageWithSchema/documentFromJson turn a DocumentPackage into self-describing JSON and back (re-exported from document-schema.js):
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'buildDocumentBytes rebuilds any DocumentFormat's bytes from a DocumentPackage — 'pdf' rebuilds the pdf-codec view from the package's own frames+pages (layoutDocumentFromPackage, a mechanical inverse walking the content tree 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 ContentDocument half. layoutDocumentFromPackage is exported too, for a caller wanting the rebuilt LayoutDocument without writing bytes. Two honest limits on the pdf rebuild, both structural properties of what a package records: a run's frames carry positions, not the wrap decisions that distributed its text across them, so a wrapped run re-renders once, whole, at its first recorded placement; and no font registry or positioned formula survives a bare package (a formula block's frame records where it sat while its glyphs render as nothing):
import { buildDocumentBytes, docxToPdf } from 'documents.js';
let captured;
docxToPdf(docxBytes, { onDocument: (pkg) => { captured = pkg; } });
const pdfBytesAgain = buildDocumentBytes(captured, 'pdf');
const docxBytesAgain = buildDocumentBytes(captured, 'docx');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); 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', ...) is a named exception: it renders via xlsxToPdf and reads the PDF's metadata, because a direct read and the PDF-preview path genuinely disagree on createdIso/modifiedIso/producer.
import { readDocumentMetadata, setDocumentMetadata } from 'documents.js';
const metadata = readDocumentMetadata('docx', docxBytes);
const patchedBytes = setDocumentMetadata('docx', 'docx', docxBytes, { title: 'New title', keywords: ['a', 'b'] });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';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 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. 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.
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, headers/footers, 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, headers, footers, numbering } = readDocxExtras(decodePackage(docxBytes));
console.log(Object.values(numbering)[0]?.levels['0']?.format); // numbering is keyed by numId, each level by its own level indexopenPptx/createPptx and PptxSlide/PptxShape are the pptx equivalent. 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();import { readPdf, writePdf } from 'documents.js';
const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/link items
const bytes = writePdf(layout);The eleven 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, odtDocxCodec, odpPptxCodec, odsXlsxCodec, odsCsvCodec, xlsxCsvCodec, odgSvgCodec, markdownDocxCodec, markdownOdtCodec) — the no-options form, adding automatic two-way schema validation. The two PDF-composed pairs have codec forms too (xlsxMarkdownCodec, csvMarkdownCodec):
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);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 single-table SELECT over readOdbTables' output. Closed allowlist grammar: column list or * or aggregates (COUNT/SUM/AVG/MIN/MAX), FROM one table, optional WHERE/GROUP BY/ORDER BY. Everything else 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 MathMLA 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);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-runsrc/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 exact1/nexponent, a scripted Sigma or Product with limits → asum/prodbinder 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_1is neverxtimes1), superscripts →math:powunless 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),\textprose, compound subscripts (a_{i+1}), binomials,align/casesenvironments — each becomes anunparsednode carrying the verbatim source span plus a named diagnostic fromLATEX_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
symreference 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, andreadMarkdownContentlowers them into embedded formula blocks (position, content, presentation MathML from the same parse — somarkdownToPdftypesets real math through the STIX engine,markdownToDocxwrites real OMML, andmarkdownToOdtwrites real embedded formula sub-documents). The write side reconstructs the same markdown math syntax from the verbatim presentation layer. The pass's diagnostics surface throughreadMarkdownContent'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 }]Every X → PDF conversion resolves each typeface through a real FontRegistry, in this order:
- The source document's own embedded faces — docx (
w:embed*, obfuscated per ECMA-376), pptx (p:embeddedFontLst, unobfuscated), ODF (Fonts/undersvg:font-face-uri). Extracted automatically. - Faces the caller supplied through
options.fonts. - pdf-codec's vendored Carlito and Caladea — metric-compatible with Calibri and Cambria.
- 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');The package is layered from generic primitives outward to the two conversion directions:
src/model/— thin additions on top ofdocument-schema.js, which owns the two pivot models (LayoutDocument,ContentDocument) imported, not defined here. Local:bytes.ts(magic-byte schemas),units.ts(EMU/twip/point conversions),geometry.ts/color.ts/style.ts(thin re-exports plus PDF-specificflipY),paint-order.ts(merges drawing pageshapes/vectorsbypaintOrder),formula.ts(helpers aroundContentFormula),embedded-drawing.ts(packages recovered vectors as aContentEmbeddedObjectBlock).pdf-codec(external) — the hand-written PDF codec, plus generic byte/image primitives (now inbyte-codec). See that package's own README.src/ports/— injectable ports:throwIfAborted(signal check at long-loop boundaries) andClockPort/systemClock/fixedClock(injectable "now" for deterministic output — exported but not yet consumed by any conversion path).src/xml/andsrc/opc/— parent-aware XML query/mutation and OPC package mechanics overooxml.js'sPackage/XmlNode.src/xml/odf-text.tsholdsencodeOdfText/decodeOdfText— see the ODF text gotcha below.src/odf-package/— ODF-side counterpart tosrc/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, plusbuildXPackagefunctions bridgingContentDocumentto fresh packages. Key reuse patterns:src/edit/odp/*reusessrc/edit/odt/*wholesale (identicaltext:p/text:spanmodel);src/edit/odg/*reusesOdpShapefordraw:framecontent;src/edit/drawingml/vector.tsis the shared OOXML vector writer for docx and pptx;src/edit/odg/vector.tsis 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.tsimplements ECMA-376 Part 4, 2.8.1;ooxml.ts/odf.tsresolve font references) andregistry.ts'screateDocumentFontRegistrycomposing the precedence chain as data.src/mathml/— a self-contained MathML presentation-layer typesetting engine (no import frommodel,pdf-codec, orodf.js; consumes only port contracts fromdocument-schema.jsand its own locally-mirroredMathMlNode). Coversmrow/mi/mn/mo/mtext/mspace/msub/msup/msubsup/munder/mover/munderover/mfrac/msqrt/mroot/mtable/mtr/mtd/mstyle/semantics, driven by the injectedMathFontMetricsport. Stretches vertical fences and horizontal braces via the font'sMathVariantsdata.src/omml/— the MathML ⇄ OMML structural translator, both directions.write.tscovers the identical construct setsrc/mathml/layout.tstypesets;read.tscovers strictly more (reads what Word authored, not just what this package writes). Lives outsidesrc/mathml/because its I/O type isooxml.js'sXmlElementandsrc/mathml/imports no package.src/ooxml/— thin adapters overooxml.js's ownreadDocx/readPptx, wrapping results intoContentDocument.docx/formula.tsis the one local reading pass (splicing OOXML math equations).docx/extras.ts'sreadDocxExtrasreturns comments/footnotes/headers/footers/numbering.src/odf/— ODF-side counterparts:readOdtContent/readOdpContent/readOdsContent/readOdgContentare thin adapters overodf.js.formula/read.ts/formula/detect.tshandle embedded formula detection (genuinely new work with noodf.js-side equivalent).src/latex/— the LaTeX presentation →MathExpressionlowering:temml.tsis the pinned-parser boundary (exact-version temml, its internal parse API guarded behind structural type guards),lower.tsthe mechanical rules and their degradations,symbols.tsthe glyph/command map and the prose definition scanner,rational.tsthe exact-rational helpers,lint.tsthe coherence lint. See LaTeX lowering into the semantic core.src/markdown/— third adapter family, viamarkdown-codec.readMarkdownContentpassesreadMarkdown's result through the math-lowering pass (math.ts— markdown-codec's preserved$$display blocks and\( \)inline spans become two-layer formula blocks, with the document's symbol table seeded from its own prose).buildMarkdownTextwrapswriteMarkdown, reconstructing markdown math syntax from formula blocks carrying a presentation layer.text.tsis the byte↔text boundary.MarkdownEditorholds a mutable in-memoryContentDocument.src/csv/— fourth adapter family, sharing the spreadsheet variant with xlsx/ods.records.tsis the RFC 4180 record parser/writer (one sharedquoteCsvField, also used by the.odbCSV exporter);text.tsis the byte↔text boundary, rejecting malformed UTF-8;read.tsturns records into a spreadsheetContentDocument(first record as verbatim string header, data cells through the same cell-typing heuristicpdfToOdsuses);write.tsturns one sheet of a spreadsheetContentDocumentback into records via each cell'sdisplayText. TSV is the same format with{ delimiter: '\t' }on either side.src/svg/— fifth adapter family, sharing the drawing variant with odg.text.tsis the byte↔text boundary, rejecting malformed UTF-8;read.tsmaps the six SVG shape primitives (rect/circle/ellipse/line/polyline/polygon/path) onto a one-page drawingContentDocument, with transform lists composed as 2×3 affines and CSS lengths and the viewBox map resolved into page points;write.tswrites the six primitives back out, one shape element each;path.tsis the full SVG path-data grammar (M/L/H/V/C/S/Z plus Q/T/A and the relative forms — S/Q/T convert exactly, A is the one bounded approximation at ≤90° per cubic);transform.tsparses and composes the transform attribute and classifies the result by frame representability;units.tsresolves CSS length units and the viewBox;paint.tsresolves fill/stroke presentation attributes and dash styles;diagnostics.tsis the shared scope-limit vocabulary.src/layout/— the pure conversion algorithms:engine.ts(wordprocessing → layout: flow, line-breaking, pagination),slides.ts(presentation → layout: direct placement),sheets.ts(spreadsheet → layout: grid, print settings, the first algorithm acceptingAbortSignal),drawing.ts(drawing → layout: vector primitives + shape reuse),reconstruct.ts(layout → content: baseline clustering for wordprocessing/presentation, near-1:1 mapping for drawing, gridline-lattice-or-text-clustering for spreadsheet).src/hsqldb/—.odbdecoders, four tiers:script.ts(TEXT-script DDL/DML parser),rowformat.ts/cache.ts(CACHED binary row-store),binary-script.ts(BINARY/COMPRESSED whole-script). All import onlydocument-schema.js— no odf.js knowledge.src/firebird/— Tier 3: gbak logical-backup reader.reader.ts(attribute framing + RLE decompression + XDR decoding),schema.ts/data.ts(table/row walking). No ratified spec — built against Firebird's own engine source.src/odb/— decoder-selection and pivot-mapping:read.tsroutes to the right tier,spreadsheet.ts/csv.tsmap to output formats.odb/sql/is the bounded SQL engine,odb/formula/is the rpt formula engine,odb/report/is the renderer,odb/values.tsis shared comparison/aggregation semantics.src/convert/— the composition layer:convert.ts(all named functions +convertDocument+resolveCompositionPlan),composition.ts(the pathfinder and primitive registry),codec.ts(z.codec()pairs),port.ts/local.ts(theDocumentConverterport),variant-bridges.ts(cross-variant semantic transforms),from-package.ts(buildDocumentBytes).src/codecs/—DOCUMENT_FORMAT_CODECS: every format's read/build capability as data, soreadDocumentMetadata/setDocumentMetadata/buildDocumentBytesdispatch through one registry.src/metadata/— cross-format metadata read/write viaDOCUMENT_FORMAT_CODECS.src/package-codec.ts—decodeDocumentPackage/encodeDocumentPackage/decodeOdbPackage.
Dependency direction is downward and checkable. Six external dependencies each own a distinct concern: ooxml.js (docx/pptx/xlsx), odf.js (odt/ods/odp/odg), document-schema.js (shared schemas + port contracts), pdf-codec (PDF codec + text-layout/font primitives), byte-codec (byte/image utilities), markdown-codec (markdown). No PdfObject/PdfDict/PdfStream type appears anywhere in this package.
pnpm build # turbo run _build (tsdown -> dist/ (ESM + CJS + .d.ts))
pnpm typecheck # turbo run _typecheck _typecheck:node
pnpm lint # turbo run _lint (eslint . --fix --cache --max-warnings 0)
pnpm test # turbo run _test (vitest run --project unit)
pnpm test:workers # turbo run _test:workers (vitest run --config vitest.workers.config.ts -- Cloudflare Workers runtime)
pnpm test:watch # vitest --project unit
pnpm test:smoke # turbo run _test:smoke (rebuilds dist/, verifies ESM/CJS parity, real round trips across all conversions, font resolution, from the built CJS bundle)To run a single test file: pnpm vitest run src/path/to/file.test.ts.
- Zod-first schema/type/guard, matching
ooxml.js: every model type is inferred from its Zod schema.ContentBlock(recursive) uses a hand-written structural guard +z.custom, notz.lazy. z.codec()for every schema-to-schema round trip — the no-options form; named functions remain the entry points forsignal/sink/onSubstitution.PdfObjecthas no Zod schema — it never crosses a public boundary; narrows on its ownkinddiscriminant.- No type assertions anywhere. Every loosely-typed value is narrowed through a type guard or Zod parse at the boundary.
- Live views, not flatten-and-regenerate. Editor classes hold a reference into the real
Package/XmlElementobjects; saving isencodePackage(pkg). - Three-tier PDF-read failure policy — throw for unprocessable files, recover-with-diagnostic for malformed-but-salvageable, degrade-with-diagnostic for unsupported features. See pdf-codec's README.
- Conventional commits, enforced via commitlint + husky.
- Worker-isomorphic runtime.
src/is typechecked against a web-only environment (lib: ["ES2024", "WebWorker"], no@types/node);eslintbans Node-only imports/globals;test:workersproves PDF-bypassing paths run inworkerd.
ooxml.js's typed readers are the basis for conversion —readDocxContent/readPptxContentare thin wrappers, not independent walks. They are deliberately not re-exported (exposing both would invite using the wrong one).readDocx'scomments/footnotes/headers/footers/numberingare exposed viareadDocxExtras.readPptxhas no extras reader yet. xlsx is the one exception:ooxml.js'sreadXlsxContent/buildXlsxPackagealready read/write a spreadsheetContentDocumentdirectly (unlikereadDocx/readPptx, whichreadDocxContent/readPptxContentwrap), so they're re-exported as-is rather than given a documents.js-local wrapper of their own —readXlsx, the separate lossy cell-values-only view, stays unexported for the same reasonreadDocx/readPptxdo.- ODF text content is not a plain string. ODF represents runs of spaces as
<text:s>, tabs as<text:tab/>, line breaks as<text:line-break/>— all elements, not text nodes. Every ODF text getter MUST calldecodeOdfText, nevertextContent()— which silently drops them (no error, just shorter text). - docx⇄PDF and pptx⇄PDF are explicitly not round-trip-lossless — see Fidelity. The cross-format bridge pairs are a genuinely different case.
- A
DocumentPackagefromonDocument/ConversionResult.packageis a snapshot, not a live view — mutatingcontentafter the layout pass leaves its nodes'framesstale; nothing detects or rejects that, and the schema keepscontent's populatedframesandpagesin sync with nothing. framesare stamped in place onto the caller's own content tree —convertXToLayoutmutates itsContentDocumentargument (each node's placements are appended to its ownframesarray, one frame per rendered placement: per wrapped fragment on a run, the cell box on a cell, the emitted item's box on an image/vector/shape) and returnspagesalongside the internalLayoutDocument. A run wrapped across three lines carries three frames; a repeat-row spreadsheet cell carries one per page it re-renders on. Reconstructors attach frames from the exact items each reconstructed node was clustered from, so every PDF-to-X conversion's content carries genuine positions too.- ODF text getters must call
decodeOdfText. See the dedicated gotcha above. readPdfrecovers rect/ellipse/line as their ownLayoutRect/LayoutEllipse/LayoutLinekinds via pdf-codec's shape-pattern detection — an axis-aligned closed four-corner subpath is a rect, four kappa-ratio cubics at cardinal points is an ellipse, an open single straight stroke is a line. A false positive changes kind, never geometry. Off-axis rotations, freeform curves, and multi-subpath figures narrow toLayoutPath.pdfToOdsre-types cells heuristically — this is probabilistic, not a fidelity guarantee. A rendered PDF never carries a cell's typed value, only the printed string. Re-typing fires only where the string has exactly one defensible reading: the decimal must be exactly representable as a JS number; separators must be unambiguous ("1,234"is declined — competing European reading is 1.234); leading zeros decline ("007"); dates must self-state their component roles (ISO or named month accepted;"01/02/2024"declined).TRUE/FALSEre-type as booleans;Yes/Noare declined.displayTextalways carries the rendered string verbatim.onCellTypeInferencereports every decision. A formula is never claimed.- The csv read shares
pdfToOds's cell-typing heuristic, with the same decision-only audit channel. The first record is a verbatim string header (never re-typed, even when it looks like data); data cells re-type throughinferCellValueexactly as the PDF reconstructor does — declines keep the plain string,displayTextalways carries the raw field text, andonCellTypeInferencefires per decision, staying silent for header cells and no-candidate text. The parser drops blank records, so a record of one empty field alone cannot round-trip. Writing csv takes exactly one sheet: a multi-sheet source refuses withCsvSheetNotSpecifiedErrornaming every sheet until{ sheet }selects one. TSV is not a separate format —{ delimiter: '\t' }on either side parses or writes the same grid. - The svg read's scope limits are named diagnostics, never silent drops. Text, images,
usereferences, gradients/patterns, CSS style blocks, and out-of-scope opacity are each reported throughonSvgDiagnosticwith a code fromSVG_DIAGNOSTIC_CODES(svg/text-unsupported,svg/image-unsupported,svg/use-unsupported,svg/gradient-unsupported,svg/css-style-ignored,svg/opacity-ignored, …) — the same contract as markdown's construct-mapping vocabulary. A plain vector SVG (the six shape primitives, transforms, paint) reads silently. - An absent SVG fill paints black — the SVG spec default, and the one visible svg⇄odg asymmetry. The svg reader turns a missing
fillattribute into a black fill; the svg writer leaves the drawing frame's absent fill unset rather than second-guessing it. Round-tripping odg→svg→odg therefore converts an unfilled odg shape into a black-filled one, mirroring what a browser would render from the same markup. - A rootless size falls back to the CSS default, and a stretched viewBox says so. When neither
width/heightnor aviewBoxis present, the read assumes the CSS default 300×150px viewport ({225, 112.5}pt) and reportssvg/default-size-assumed; whenwidth/heightand the viewBox disagree in aspect ratio, the read maps through the stretched viewport and reportssvg/preserve-aspect-ratio-stretchedrather than silently re-proportioning the geometry. - Writing svg takes exactly one page. An svg is a single drawing, so a multi-page source refuses with
SvgMultiPageNotSpecifiedErrornaming the page count until{ page }selects one (an index, because drawing pages are anonymous where csv's sheets are named — the same contract one variant over). - svg→csv and svg→markdown honestly produce empty output. The svg read has no text in scope, and neither csv nor markdown has a vocabulary for vectors, so the composition routes (via PDF into the spreadsheet/text readers) yield a document with nothing to emit — pinned as expected-empty in the round-trip matrix rather than dressed up as a conversion.
- A rotated rect or ellipse stays a frame, with
rotationDeg. The read composes the transform list into one 2×3 affine and classifies it: an axis-aligned map (any scale, mirrors included) folds into the frame; a similarity rotation keeps the frame and recordsrotationDegabout the frame's centre; a shear or rotation-composed non-uniform scale narrows to a path. The affine itself is exact in every case — only which container carries it changes. - The path grammar's one approximation is the elliptical arc.
Aconverts endpoint-to-centre parameterisation exactly (F.6.5, with the F.6.5.6 radii correction), then approximates each arc segment with kappa-bounded cubics at ≤90° per cubic; S/Q/T convert exactly (a quadratic elevates to an exact cubic, T reflects the previous quadratic's own control). reconstructWordprocessing/reconstructPresentationrecover vector primitives too, in a nested drawing document — a rule under a heading, an underline, a cell background are all recovered as vectors (intended — discarding real content because it might be incidental is ruled out). A table's gridlines are excluded from vector recovery when the lattice claims them.- Recovered vectors round-trip through all five vector-writing readers —
buildDocxPackage/buildPptxPackagewrite real DrawingML;buildOdtPackage/buildOdpPackagewrite realdraw:rect/draw:ellipse/draw:line/draw:path;buildSvgTextwrites real SVG shape elements. The PDF-bypassing bridges between vector-carrying formats (odt⇄docx, odp⇄pptx, odt⇄odp, svg⇄odg) carry vector geometry across too. - Each format wraps a vector shape differently. OOXML: pptx gets a plain
p:sp; docx gets aw:drawing/wp:anchorwithbehindDoc="1"/wp:wrapNonecarrying awps:wsp. ODF: odp appends todraw:page; odt anchors in atext:pwithstyle:horizontal-rel/style:vertical-rel="page"(page-absolute coordinates) andstyle:run-through="background". ContentStroke.styleis not written by vector writers.LayoutLine/LayoutPathcarry the enum, but neither ODF nor DrawingML vector writers read it — a hand-built vector withstroke.stylepaints solid. Cell borders are a separate path that does set the style.pdfToOdsrecovers what was printed, not what was entered.reconstructSpreadsheettries a real gridline lattice first (MIN_GRIDLINE_COUNT_PER_AXIS = 3), using line positions directly as cell boundaries; absent one, clusters text into a grid from geometry. Column widths/row heights are measured, never invented. No print range/scale/repeat-rows/manual-breaks are inferred.OdsSheet.printSettingsround-trips every field —pageSize/margins/gridlines/headers/pageOrder/printRange/scalePercent/fitToPages/repeatColumns/repeatRows/manualBreaks. The setter mints a fresh style chain (append-only convention).OdsSheetcolumn-width/row-height setters close the zero-size hazard. An explicit-but-unstyled column/row element reads back atwidthPt/heightPt0, which wins over the layout engine's fallback —xlsxToPdf's internal composition made this a real bug.ensureColumnDefaultWidth/ensureRowDefaultHeightstamp defaults on first individuation.OdsSheet.addImage/addEmbeddedObjectwrite floating shapes and formula sub-documents.reconstructDrawingmaps recovered geometry near-1:1 — no clustering (a drawing has no semantic structure to infer). Kind survives wherereadPdfrecovers it; a rotation not a multiple of 90° narrows topath. A wrapped multi-line text box comes back as separate single-line boxes (oneLayoutText= one shape). Apath's reconstructedframeis the tight bounding box of all recovered points including cubic controls.- Two fill bugs fixed as part of
pdfToOdg(both pre-existing, exposed by real-file verification):draw:fill="solid"is now written explicitly whenever a fill is set (LibreOffice silently renders adraw:pathwithdraw:fill-coloralone as unfilled); andwriteEllipsenow emits a PDFhclosepath operator (PDF fills close implicitly, butreadPdfonly marksclosed: truewhen it seesh). - Vector fill/stroke uses a self-contained graphic-family style writer (
src/edit/odg/style.ts), notodf.js'sStyleRegistry— which recognises'graphic'but never emitsstyle:graphic-properties. svg:dis cross-checked againstodf.js's real parser —OdgPathVector.subpathsre-derives by reparsing the writtensvg:viewBox/svg:don every read.- Paint order is document order, never
draw:z-index.shapesandvectorsarrays merge via the sharedpaintOrderfield. An earlieradd*call paints behind a later one. LayoutPathSchemahas no quadratic or elliptical-arc segment — deliberately; real LibreOffice output only emitsM/L/H/V/C/Z.- A rotated vector renders as
LayoutPath—LayoutRect/LayoutEllipsecarry no rotation field. The rotation is exact (affine maps edges to edges, cubics to cubics); only therotationDegfield is lost on PDF round trip. ContentVector.path.fillRuleis read from realsvg:fill-rulemarkup.- Cell borders render with real
style(solid/dashed/dotted/double) —LayoutLineSchemacarries the enum,pushCellBorderLinessets it, pdf-codec renders it. The'double'inter-line offset is an internal constant (not in the data model). - Font resolution uses a real registry, standard 14 as last resort. A family with no embedded/caller/vendored face (Aptos, third-party typefaces) renders through the nearest standard-14 face with a width-correction factor — expect a visual approximation, not line-identical output. MathML formula rendering is separate: it embeds STIX Two Math, not registry-resolvable.
- Justified paragraphs stretch inter-word gaps in all three layout engines (
engine.ts,slides.ts,sheets.ts).justifyLineGapsPtdivides slack evenly across detected word gaps; final lines stay left-aligned. - Encrypted PDFs and CCITT/JBIG2/JPX images are real capabilities in pdf-codec — not scope boundaries. The permanent boundary is adversarial/malformed-input robustness.
- PDF → docx/pptx/odt/odp table recovery requires a real drawn gridline lattice — never text alignment (which would invent structure). A lattice with no text inside is rejected.
- Merged table cells round-trip as merged. docx: horizontal merge collapses to one
w:tcwithw:gridSpan; vertical merge needs onew:tcper covered row withw:vMerge. ODF: one entry per grid position, covered cells gettable:covered-table-cell. - docx headers/footers/comments/footnotes/numbering are readable via
readDocxExtras—readDocxContentstill drops them (ContentDocumenthas nowhere to put them).PAGE/NUMPAGESfield substitution is never read (it's a render-time value). - A docx inline image reads as a real
ContentImageBlock—buildDocxPackagerecognisesreadDocx's two-block pattern (empty-text paragraph + image) and writes it back as one paragraph, avoiding spurious blank paragraphs on round trip. - pptx speaker notes survive via a hidden
/Subtype /Textannotation — specific to this package's writer/reader pair; other PDF producers/consumers won't see it. odmToPdfis the one non-bytes-in/bytes-out conversion — chapters are external.odtreferences requiringresolveSubDocument. All unresolved sections are collected before throwingOdmUnresolvedSectionError..odbhas noodbToPdf— a database front-end's tables/queries/reports are three unrelated output shapes. Rendered reports are the exception:odbReportToDocx/odbReportToOdt/odbReportToPdftake an already-renderedContentDocument.- The rpt formula engine's group scoping cascades enclosing breaks inward. A group at level L starts a new instance when its own expression breaks OR when any enclosing group breaks — otherwise a "Q2" subtotal would span two regions.
HASCHANGEDitself knows nothing about groups; the cascade lives in the report structure. Aggregates are computed over complete ranges (not running totals); group expressions may not transitively depend on aggregates (circular). - The rpt function set is a closed allowlist; separator is semicolon.
rpt:HASCHANGED/rpt:LEFT/rpt:SUM/COUNT/AVG/MIN/MAX/field:[COLUMN]— everything else throws.[NAME]and"NAME"are one concept. Three refusals where guessing would produce wrong values: non-boolean group expressions,rpt:LEFTover non-text, per-row formulas in report header/footer. - The rpt engine emits no page headers/footers — the renderer places them under a single-logical-page model, at report scope.
- The SQL engine is a closed allowlist — JOINs, subqueries,
UNION,DISTINCT,HAVING,LIMIT, aliases,CASE, arithmetic, etc. all throwHsqldbSqlUnsupportedErrornaming the construct. Silently dropping a clause would return plausible wrong rows. - Four SQL semantics decisions: (1) NULL is
{ kind: 'empty' }, three-valued logic; (2) values compare within classes (numeric/boolean/text), cross-class throws; (3)GROUP BYputs NULLs in one group, first-appearance order;COUNT(*)counts rows,COUNT(column)counts non-NULL; (4)ORDER BYsorts NULLs last under ASC, stable. - Unquoted SQL identifiers fold to upper case; double-quoted match exactly.
- All four
.odbdecoder tiers are implemented. Tier 4 (BINARY/COMPRESSED) is a sibling of Tier 2, not a new value decoder — it recovers DDL as TEXT-format script text and decodes rows through the same per-column encoder. An external-only connection is a permanent scope boundary. - The CACHED-table decoder is scoped to HSQLDB 1.8.x (LibreOffice's bundled version). No ratified spec; ground truth is the decompiled engine source, cross-checked against a JDBC oracle.
- A CACHED table's index count comes from its
SET TABLE ... INDEX'...'line's token count —tokens.length - 1. Traversing index 0's tree suffices (every index spans the same rows); the AVL tree is walked by child positions, never key comparisons. - DATE/TIME/TIMESTAMP from CACHED tables need a timezone — the file doesn't record one.
{ timeZone }option (IANA name), defaulting to local zone. Affects Tier 2 and 4 only. - BIGINT/DECIMAL/NUMERIC beyond double precision carry
exactValue— a decimal-string sidecar, built viaBigIntdigit manipulation, attached only whenNumber()would lose precision. .odbTier 3 (Firebird) has no ratified spec. Thedatabase/firebird.fbkpart is a gbak logical backup stream, not a raw ODS page dump (confirmed by hex-inspecting a real fixture). Built against Firebird's own engine source; format version 10 (FB2.5→FB3.0).- Three real fixtures back the Firebird reader, generated via headless LibreOffice 26.2 UNO automation, cross-verified field-by-field against LibreOffice's own SDBC.
- BLOB columns are genuinely decoded. TEXT blobs arrive as UTF-8 strings; binary blobs as base64
data:URIs. NULL blobs write no record. Noatt_endterminator after blob data. - FB4+-only types (
INT128/DECFLOAT) are an environmental hard stop — LibreOffice's bundled FB3 engine cannot declare them, so no.odbexists to verify against. - Firebird gbak mixes two byte-level encodings: little-endian for tags/attributes, big-endian XDR for row field values.
- STIX Two Math is embedded as a whole
CFFtable — pdf-codec's scope decision, not this package's. - Stretchy fences stretch vertically via
MathVariants— parentheses, brackets, braces, floor/ceiling, angle brackets, bars.msqrt/mrootradicals render through the font's √ construction plus a vinculum rule. Multi-charactermonever stretches. - Over/under-braces stretch horizontally via the identical
MathFontMetrics.stretchport, called withaxis: 'horizontal'. - Stretched fence glyphs have no ToUnicode mapping — pdf-codec wraps them in
/ActualTextspans for text extraction. - Big operators (
∑/∏/⋃) are NOT stretchy — they grow vialargeop, matching MathML3. - The operator dictionary is a bounded ~60-entry table, not the full MathML3 spec.
mover/mundercentre at the font'sMathTopAccentAttachmentpoint when available, geometric centring otherwise.- Greek
mathvariantcovers the alphabet, nabla, partial, and six symbol-variant glyphs — generated from Unicode'sUnicodeData.txt. - Cell-anchored formulas render for real —
sheets.tsresolves the anchor against positioned column/row geometry. The print range widens to cover the anchor cell when no explicit range is declared. A formula in a repeat band renders on every page. Hidden anchor rows/columns skip the formula. convertSpreadsheetToLayoutreturns{ document, formulas }— formula CID-font glyph runs can't travel throughLayoutDocument.pages[].items.formulaSizePtForFrameis one shared two-pass fit — lay out once at reference size, rescale to fit both frame width and height, floored at 8pt. docx OMML (no geometry) uses height alone.- Embedded-formula detection in odt/odp is genuinely new work —
collectFormulaFrames/collectSlideFormulaFramesmirrorodf.js's own walks. ods needs no detection pass (odf.js2.2.0 classifies formula sub-documents directly). - A formula that cannot typeset degrades to its plain-text stand-in, never to nothing.
buildDocxPackagewrites real OMML;buildOdtPackagewrites real embedded formula sub-documents. The markdown writer reconstructs real$$/\( \)math for formulas carrying a presentation layer and falls back to the plain-text stand-in (StarMath, the verbatim presentation LaTeX, else[formula]) only for formulas with no LaTeX at all.odmToPdfcarries formulas through as ordinary blocks. - OMML read/write are deliberately asymmetric — the reader covers more (
m:d,m:nary,m:acc,m:bar,m:func,m:sPre) because it must read what Word wrote.docx → odt → docxround trips keep the mathematics but may change the OMML construct. - The OMML translator covers exactly what
src/mathml/layout.tstypesets. A stretchy fence diverges: PDF stretches it, docx writes it at base size.munderoverbecomes nestedm:limUpp/m:limLow(no operand scope in MathML). sourcePathtraces aLayoutItemto itsContentDocumentorigin, but only within one read+layout pass — not an edit-tracking mechanism. Since the frames fusion it survives as traceability only: the authoritative node↔position association is each content node's ownframes, stamped at the moment of layout (or of reconstruction) rather than re-matched by string afterwards.readMarkdownContentruns markdown-codec's result through the math-lowering pass —markdown-codecalready produces a fullContentDocument, but it deliberately carries$$display blocks and\( \)inline spans through as raw LaTeX text (styled paragraphs and marker runs); the pass lowers that LaTeX into two-layer formula blocks so markdown math typesets, edits, and computes like math from any other format (see LaTeX lowering into the semantic core).- Every markdown construct-mapping gap is a documented
MarkdownDiagnosticCodesentry (md/invented-page-geometry,md/nested-emphasis-flattened,md/link-title-dropped,md/code-block-info-string-dropped,md/blockquote-nested-depth,md/list-item-block-unlisted,md/list-item-multi-block-flattened,md/image-unresolved,md/raw-html-preserved-as-text/md/raw-html-dropped,md/front-matter-key-unmapped,md/heading-level-clamped,md/adjacent-links-merged,md/code-span-as-monospace-run,md/paragraph-indent-dropped,md/list-numid-fallback,md/table-cell-formatting-dropped,md/table-cell-multi-paragraph-joined) — never a silent approximation. buildMarkdownTextthrows for non-'wordprocessing'ContentDocument.decodeMarkdownTextthrows on malformed UTF-8 rather than producing U+FFFD.- The composition engine routes every pair generically through a declarative primitive registry and minimum-cost pathfinder.
resolveCompositionPlanfinds the minimum-cost route (same-variant bridge < cross-variant transform < via-PDF multi-hop). Named functions are thin forwarders.
Read as row → column. ✓ lossless, ~ bounded, ✗ lossy, ✗✗ severe, → one-way, – no conversion. .odm/.odb sit outside this table.
| ↓ from \ to → | docx | pptx | xlsx | odt | odp | ods | odg | svg | odf | markdown | csv | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| docx | — | ~ | – | ✓ | – | – | – | ✗ | – | ✗ | ✗ | ~ |
| pptx | ~ | — | – | – | ✓ | – | – | ✗ | – | – | ✗ | ~ |
| xlsx | – | – | — | – | – | ~ | – | ✗ | – | ✗✗ | ~ | ~ |
| odt | ✓ | – | – | — | ~ | – | – | ✗ | – | ✗ | ✗ | ~ |
| odp | – | ✓ | – | ~ | — | – | – | ✗ | – | – | ✗ | ~ |
| ods | – | – | ~ | – | – | — | – | ✗ | – | – | ~ | ~ |
| odg | – | – | – | – | – | – | — | ✓ | – | – | ✗ | ~ |
| svg | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | — | – | ✗✗ | ✗✗ | ~ |
| odf | – | – | – | – | – | – | – | – | — | – | – | → |
| markdown | ~ | – | ✗✗ | ~ | – | – | – | ✗✗ | – | — | ✗✗ | ~ |
| csv | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | – | ✗✗ | — | ~ |
| ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | – | ✗✗ | ✗ | — |
111 of 132 directional pairs are routable. The ContentDocument/LayoutDocument pivots are the hub, not PDF — twenty bridges bypass PDF entirely.
X → PDF is a genuine layout render: positioned text, images, tables, lists, vector primitives, styled through the full cascade. It is a faithful visual approximation, not pixel-identical — closeness depends on font availability.
odf → PDF and embedded formulas render faithful mathematical typesetting through STIX Two Math: real box-model layout, per-glyph metrics, font-wide constants from the MATH table, stretchy fences and braces via MathVariants. pdfToOdf is not attempted — recovering a semantic operator tree from glyphs is OCR-adjacent.
PDF → docx/pptx/odt/odp is best-effort reconstruction from geometry. Reading order, font properties, page count survive; paragraph boundaries are inferred from baseline spacing. Tables recover only from a real gridline lattice. Vector primitives recover into a nested drawing document.
PDF → odg is near-1:1 mapping (no clustering needed). Kind narrows upstream: rotated rects, freeform curves, multi-subpath figures become path.
svg ⇄ PDF and PDF → svg lay out through the same drawing engine odg feeds, so svgToPdf is bounded only by the svg read's documented scope; pdfToSvg reuses pdfToOdg's near-1:1 vector recovery writing SVG shape elements instead. Recovered text boxes sit outside the svg writer's vector-only scope — reported per shape via onSvgDiagnostic, never silently dropped — and svg→csv/svg→markdown honestly produce empty output (no text in the read's scope, no vector vocabulary in the target).
PDF → ods recovers what was printed, not what was entered. The printed string always survives in displayText; re-typed value is explicitly probabilistic inference.
markdownToPdf/pdfToMarkdown is the lossiest round trip: markdownToPdf is faithful, but pdfToMarkdown stacks reconstruction lossiness PLUS markdown's coarser vocabulary (no colour, font, size, alignment). The PDF-composed markdown bridges (xlsxToMarkdown/markdownToXlsx, csvToMarkdown/markdownToCsv) stack the same two losses in both directions — hence their ✗✗ cells.
The six same-variant bridge pairs (odt⇄docx, odp⇄pptx, ods⇄xlsx, csv⇄ods, csv⇄xlsx, svg⇄odg) bypass PDF entirely — no layout engine, no reconstruction. Text, styling, tables, lists, rotated shapes survive completely. ods⇄xlsx has small format-boundary limits (time cells, formula dialects). Embedded formulas survive odtToDocx as real OOXML math. The csv pairs are bounded by what csv itself carries: toward ods/xlsx nothing the csv had is lost, while writing to csv collapses each cell to its displayText — formulas become their rendered values, formatting disappears, and a multi-sheet source must name the sheet it wants. The svg pair carries the six vector primitives losslessly in both directions; its one asymmetry is paint defaults — SVG's absent-fill-is-black versus a drawing frame's no-fill.
The two markdown bridge pairs bypass PDF too, but markdown's grammar has no construct for colour/font/size/alignment — docxToMarkdown/odtToMarkdown drop them (format-boundary loss, not approximation).
Four cross-variant bridges (docx⇄pptx, odt⇄odp) go through a semantic transform — slide boundaries are heuristic, but blocks survive intact.
.odb extraction is genuine verified data extraction across all four tiers, differing by what each storage shape carries. BLOB content recovers byte-for-byte. No reverse direction.
SQL/rpt engines are exact within their closed grammars, hard failures outside — never approximations.
Report rendering is structurally faithful, not pixel-faithful: band order/content/formulas are exact; fonts/colours/number formats/pagination are not reproduced (odf.js's report reader doesn't resolve styles).
.github/workflows/ci.yml runs commitlint, lint, typecheck, unit suite, and smoke test on every push/PR. On push to main where all pass, release.config.ts drives semantic-release: commit history decides the version bump, CHANGELOG.md and package.json are committed back, a GitHub Release is cut, and the package publishes to npmjs.org via OIDC trusted publishing (no NPM_TOKEN). Publication is detected by diffing package.json's version before/after. A second job republishes under @exadev/documents.js to GitHub Packages; a third generates an SPDX SBOM and signs build-provenance attestations.
Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint via a husky commit-msg hook — semantic-release's version bump depends on these. pre-commit runs lint-staged; pre-push runs the test suite. Single main branch, no open PR workflow established.
- ooxml.js — docx/pptx/xlsx ⇄ JSON handling and typed reading, including
readXlsxContent/buildXlsxPackage(consumed by theodsToXlsx/xlsxToOdsbridge and internal codecs, and re-exported directly from this package's own surface — see Reading and building xlsx content directly). - document-schema.js — owns
ContentDocument/LayoutDocumentand the port contracts; shared by all sibling packages. - markdown-codec — CommonMark+GFM ⇄
ContentDocumenthandling. The third format (after docx/odt) sharing the wordprocessing pivot. - pdf-codec — the hand-written PDF codec (
readPdf/writePdf/pdfCodec), the embedded STIX Two Math font, and text-measurement/font-resolution primitives. - byte-codec — generic byte/image utilities (ByteWriter, CRC-32, deflate/inflate, PNG/JPEG), extracted from pdf-codec.
- odf.js — ODF codec (odt/ods/odp/odg), also built on
document-schema.js. Style interning, rotation,svg:dparsing, and manifest handling consumed directly. - STIX Two Math — the embedded math font. Vendored within pdf-codec (OFL-1.1).
- firebirdsql/firebird — ground truth for
src/firebird/, since gbak backup format has no ratified spec. Read as source material only, not a build/runtime dependency.
This package also publishes under:
MIT