Skip to content

Repository files navigation

documents.js

GitHub npm Release CI

Bidirectional docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, six further cross-format bridges (odt⇄docx, odp⇄pptx, ods⇄xlsx) that bypass PDF entirely, .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), a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, a hand-written MathML presentation-layer typesetting engine with embedded-font PDF rendering (odf → PDF, plus formulas embedded inside odt/odp), and a fully hand-written PDF codec, built on ooxml.js and odf.js.

documents.js depends on ooxml.js for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions ooxml.js deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-and-write manipulation API for docx/pptx content — ooxml.js's own typed readers (readDocx/readPptx) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are entirely hand-written: no external PDF library (pdf-lib, pdfjs-dist, mupdf, or any other) is a dependency. The one exception is fflate for raw DEFLATE/zlib compression underneath PDF's FlateDecode filter and PNG's IDAT chunks — the same dependency ooxml.js itself already relies on for ZIP handling. src/mathml/ (the MathML typesetting engine) and the OpenType/CFF font parsing this package's own PDF writer uses to embed a real math font (src/pdf/sfnt.ts/math-*.ts) are both hand-written too, for the same "no supply-chain surface beyond what's already declared" reason — the one bundled binary asset is the vendored STIX Two Math font itself (OFL-1.1, see Fidelity and assets/fonts/NOTICE.md), not a library.

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: every layer of the PDF format — the object model, the cross-reference table, the content-stream operators, standard-font metrics, the parser's cross-reference/object-stream resolution and content-stream interpreter — is hand-written against the ISO 32000-1 specification. That is a genuinely large undertaking (the PDF codec is comparable in size to the rest of the package combined), and it comes with an honest trade-off spelled out in Fidelity below: this is not, and does not attempt to be, as robust against adversarial or badly malformed real-world PDFs as a library with 15+ years of hardening. What it buys instead is a dependency-free, fully auditable PDF implementation with no supply-chain surface beyond ooxml.js and fflate.

The read-and-write editor exists because ooxml.js's own typed readers are a deliberate one-way, lossy projection — reading is fine, but there is no way to add a paragraph, style a run, or insert an image and get a valid docx/pptx back out. documents.js's editors are live views directly over the XmlElement objects inside a decoded Package: a mutation edits that tree in place, and everything you don't touch round-trips byte-faithful, because it never stopped being the original XML.

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 (docx/pptx/odt/odp/ods/odg ⇄ PDF, all now round-trip both ways):

import { docxToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToDocx, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pptxToPdf, pdfToPptx } 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, not what was entered -- see Fidelity

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

Six further conversions bypass PDF entirely: odtToDocx/docxToOdt, odpToPptx/pptxToOdp, and odsToXlsx/xlsxToOds 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.

import { odtToDocx, docxToOdt } from 'documents.js';

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

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.

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 alongside docx/pptx/odt/odp/ods/odg/pdf — not because xlsx has a PDF conversion of its own, but because createLocalDocumentConverter's { source, targetFormat } contract already generalises past "targetFormat always means pdf": odtdocx, docxodt, odppptx, pptxodp, odsxlsx, and xlsxods are six further entries in the same conversions list, routed to the six bridge functions above with an empty diagnostics array.

Getting back the intermediate DocumentPackage (content + layout, from document-content-model) 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 six 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.

Reading and editing docx/pptx content directly, without going through PDF at all:

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

const editor = openDocx(existingDocxBytes);
const paragraph = editor.body.appendParagraph({ alignment: 'center' });
const run = paragraph.appendRun({ text: 'Hello' });
run.bold = true;
run.color = { r: 1, g: 0, b: 0 };
const bytes = editor.toBytes();

// or start from nothing:
const fresh = createDocx();
fresh.body.appendParagraph().appendRun({ text: 'New document' });

openPptx/createPptx and PptxSlide/PptxShape are the pptx equivalent (slide.addTextBox, slide.addImage, shape.setParagraphs for multi-paragraph styled text).

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). 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, unlike PptxShape's own documented gap 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. 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 seven round trips (PDF ⇄ LayoutDocument, docx ⇄ PDF, pptx ⇄ PDF, odt ⇄ PDF, odp ⇄ PDF, ods ⇄ PDF, odg ⇄ 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 six PDF-bypassing bridges above get the same treatment: odtDocxCodec, odpPptxCodec, and odsXlsxCodec (odt bytes ⇄ docx bytes, odp bytes ⇄ pptx bytes, ods bytes ⇄ xlsx bytes) — the no-options form again, odtToDocx/docxToOdt/etc. remain the entry points for signal.

readDocxContent/readPptxContent/readOdtContent/readOdpContent/readOdsContent/readOdgContent (docx/pptx/odt/odp/ods/odg → ContentDocument), 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; 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 comes back a bare string, never re-parsed into a number/date/boolean or claimed as a formula (see Fidelity).

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 twelve round-trip conversions or the six 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. Three embedded storage shapes are supported, dispatched automatically from the package's own connection URL and, for HSQLDB, its own per-table storage shape: 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), and 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). A caller never needs to know which shape or engine a given .odb used. HSQLDB's own whole-script BINARY/COMPRESSED serialisation is the one embedded shape still out of scope (see Gotchas/Fidelity):

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

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 PDF conversion, no reverse (xlsx/CSV → .odb) direction, and — like odmToPdf — is not wired into the DocumentConverter port below, since Reports require live SQL execution to render (categorically out of scope) and the write direction would need a real embedded SQL engine this package deliberately does not implement.

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

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

This works by threading the formula's own raw MathML alongside the ContentDocument readOdtContent/readOdpContent already produce (see OdtContentResult/OdpContentResult's own formulas field) rather than through ContentDocument itself, since document-content-model's own ContentEmbeddedObject.document field has no MathML-shaped variant to carry it in:

import { convertWordprocessingToLayout, readOdtContent } from 'documents.js';

const { document, formulas } = readOdtContent(pkg); // formulas: ReadonlyMap<sourcePath, EmbeddedFormula>
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer, formulas });
const pdfBytes = writePdf(layout, { formulas: positioned }); // writePdf's own formula-aware option -- see Architecture

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 src/pdf's own math-content-write.ts
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see Fidelity

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-content-model 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, src/pdf/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, and drawing variants 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) are both imported, not defined here — document-content-model exists specifically so ooxml.js, odf.js, 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-content-model'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-content-model too (since LayoutText, part of the pivot, needs the field), leaving only the standard-14 font resolution logic that consumes it (src/pdf/fonts.ts/font-read.ts) as PDF-specific and local. content.ts holds the one genuinely local piece of the ContentDocument envelope (CONTENT_FORMAT_VERSION), and formula.ts defines EmbeddedFormula/PositionedFormula — the side-channel formula shapes threaded alongside a ContentDocument/LayoutDocument rather than inside them (see the Usage section above) — the one file in src/model/ with a local dependency of its own, a type-only import of MathBox/MathMlNode from mathml (see the dependency-direction note below).
  • src/bytes/ and src/image/ — generic byte and image-container primitives with zero PDF or OOXML knowledge: a chunked byte writer, a backtracking byte reader, CRC32, and a hand-written PNG decoder/encoder (palette/gray/RGB/alpha, multi-IDAT files, all five scanline filters) plus JPEG marker scanning for dimensions only — JPEG's compressed bytes pass through completely unchanged in both directions. src/bytes/flate.ts is the only file that imports fflate, mirroring how ooxml.js's own src/zip.ts wraps it for ZIP handling.
  • src/ports/ — the two injectable ports this package's own "identity, clock, and observability are first-class ports" convention calls for: abort.ts's throwIfAborted (a signal-check helper called at page/slide/row loop boundaries throughout src/pdf/write.ts/read.ts and src/layout/sheets.ts/reconstruct.ts — the codebase has no await point for cancellation to hook into implicitly, since the local pipeline is synchronous end to end, so every long-running loop checks explicitly instead) and clock.ts's ClockPort/systemClock/fixedClock (an injectable "now", for deterministic PDF output in tests). ClockPort is exported and tested in isolation but not yet consumed by any conversion path — writePdf's own /CreationDate//ModDate come directly from LayoutDocument.metadata.createdIso/modifiedIso when present, with nothing in this package's own write path calling new Date() to fill in a missing one, so there is currently no real call site for ClockPort to inject into. A real, tracked gap in wiring, not a documentation gap: a future default-timestamp write path should consume it rather than reaching for new Date() directly.
  • src/xml/ and src/opc/ — parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built over ooxml.js's Package/XmlNode, needed because ooxml.js's own XML nodes have no parent pointers and ooxml.js never writes new parts into an existing package. src/xml/odf-text.ts is the one ODF-specific module in this directory: encodeOdfText/decodeOdfText convert between a plain string and ODF's own whitespace-run element sequence (text:s for a run of two or more literal spaces, text:tab, text:line-break — all three occupy real character positions in an ODF paragraph but are ELEMENTS, not text-node characters, unlike docx's flat w:t run text) — see the Gotchas entry below on why every ODF text getter in this codebase must call decodeOdfText, never ooxml.js's own plain-text-node textContent().
  • src/odf-package/ — the ODF-side counterpart to src/opc/: manifest.ts is a pure re-export of odf.js's own manifest read/build/write/sync/validate functions (odf.js already owns META-INF/manifest.xml end to end — reading, deriving, writing, syncing, and validating it — unlike ooxml.js's read-only OPC relationship handling), and media.ts's addImageMedia inserts a binary image part under Pictures/ (the real-world LibreOffice/OASIS convention, confirmed against odf.js's own round-trip/manifest fixtures) and re-syncs the manifest via that same syncManifest re-export — one step simpler than OOXML's own addImageMedia (src/opc/media.ts) since ODF references a media part directly by its package path (xlink:href) rather than through a relationship-ID indirection. OdpSlide.addImage/OdpShape (src/edit/odp/image.ts) is addImageMedia's real caller — and, through src/edit/odg/*'s wholesale reuse of OdpShape (see the src/edit/ entry below), OdgPage.addImage too; src/odb/read.ts also reuses manifest.ts's readManifest directly, to check database/script's own manifest-declared media type before treating it as an HSQLDB script part.
  • src/edit/ — the read-and-write editable model: live-view classes (DocxEditor/DocxParagraph/DocxRun/DocxTable, PptxEditor/PptxSlide/PptxShape, OdtEditor/OdtParagraph/OdtRun/OdtTable/OdtList, OdpEditor/OdpSlide/OdpShape, OdsEditor/OdsSheet/OdsCell, OdgEditor/OdgPage/OdgBoxVector/OdgLineVector/OdgPathVector) wrapping the actual XmlElement objects inside a decoded Package, plus buildDocxPackage/buildPptxPackage/buildOdtPackage/buildOdpPackage/buildOdsPackage/buildOdgPackage bridging a ContentDocument to a fresh package built entirely through those same primitives — pdfToOdt/pdfToOdp/pdfToOds/pdfToOdg each call the matching one. src/edit/odp/* reuses src/edit/odt/*'s own paragraph/run/list/style-interning classes WHOLESALE rather than reimplementing them for presentations: a draw:frame's draw:text-box holds the identical text:p/text:span content model office:text does, interned into the identical content.xml office:automatic-styles registry (src/edit/odt/props.ts's applyStyleChange) — OdpShape.appendParagraph/.paragraphs()/.addList() return real OdtParagraph/OdtList instances, not odp-specific lookalikes. The genuinely new odp-specific work is draw:page/draw:frame mechanics (a slide is a draw:page, a shape's geometry is explicit svg:x/svg:y/svg:width/svg:height rather than pptx's placeholder-inheritance-heavy model) and rotation: OdpShape.rotationDeg is a genuine draw:transform setter built on odf.js's own applyOdfTransform/resolveOdfShapeGeometry (typed/shared/transform.ts) — the write-side inverse of the exact function odf.js's own reader uses — unlike PptxShape, which has no rotation setter yet (see Gotchas below). src/edit/ods/* has no docx/pptx/odt/odp analogue to reuse for its core concern (cell addressing) but still reuses src/edit/odt/*'s style interning and src/edit/odt/content.ts's populateParagraph for cell text content — src/edit/ods/address.ts is the write-side counterpart to odf.js's own read-side table:number-*-repeated-aware cursor: setting a distant cell's value splits the covering repeated run in place at that one position rather than materialising every cell in between, exactly mirroring the read-side hazard odf.js's own typed/shared/a1.ts already solved. src/edit/ods/print-settings.ts is the newest addition: OdsSheet.printSettings's own getter/setter, mining styles.xml's office:automatic-styles/office:master-styles directly (a part no other src/edit/ods/* module needed to touch before) rather than content.xml alone, reusing odf.js's own exported findStyleElement/resolvePageLayoutProperties/parsePageSize/parseMargins for the read half and src/edit/odt/automatic-styles.ts's nextStyleName (already generic over which office:automatic-styles element it scans) for the write half's own fresh-name minting. src/edit/odg/* reuses OdpShape/buildTextBoxFrame/insertImageFrameMedia WHOLESALE for draw:frame text/image content (a drawing page's draw:frame content model and geometry resolution — rotation included — are byte-for-byte identical to a presentation's, both resolved through odf.js's own shared readDrawFrame), so there is no separate OdgShape class at all; the genuinely new work is the vector-primitive classes (no rotation, a per-kind attribute vocabulary: svg:x/y/width/height for rect/ellipse/path, svg:x1/y1/x2/y2 for a line) and their own fill/stroke, which needed a small, self-contained graphic-family style writer (src/edit/odg/style.ts) since odf.js's own StyleRegistry recognises 'graphic' as a style family but its StylePropertiesSchema only ever models text/paragraph formatting — it has no fill/stroke fields and never emits a style:graphic-properties element. A path vector's own svg:d is generated by src/edit/odg/svg-path.ts, the write-side inverse of odf.js's own typed/shared/path.ts parser — always absolute, always space-separated commands, anchoring svg:viewBox at "0 0 {widthPt} {heightPt}" so the written numbers are the exact source ContentPathPoint values with no rescaling arithmetic either way (see Gotchas below for the cross-check against that exact parser).
  • src/mathml/ — a MathML presentation-layer typesetting engine, comparable in scope to the standard-14 text-layout half of src/pdf/ — genuinely self-contained: no import from model, pdf, or odf.js at all (not even document-content-model), matching src/layout/'s own "pure conversion algorithm" isolation one tier further down. nodes.ts defines MathMlNode/MathMlElement as a local, structurally-compatible mirror of odf.js's own XmlNode (the same "mirror the shape, don't import the package" trick src/interop.test.ts already proves holds between ooxml.js and odf.js), so odf.js's readOdfFormula's real return value type-checks against it with zero cast. variant.ts maps mathvariant to the Unicode Mathematical Alphanumeric Symbols block (Latin/Greek/digits, including the block's own well-known Letterlike-Symbols hole-fillers — italic small h, eleven Script/Fraktur/Double-struck capitals — generated directly from Unicode's own UnicodeData.txt, not transcribed by hand). operators.ts is a deliberately bounded operator dictionary (lspace/rspace/stretchy/largeop/movablelimits per operator), not the MathML3 spec's own multi-thousand-entry table. layout.ts is the recursive box-model engine itself (mrow/mi/mn/mo/mtext/mspace/msub/msup/msubsup/munder/mover/munderover/mfrac/msqrt/mroot/mtable/mtr/mtd/mstyle/semantics, plus a text-content fallback with a diagnostic for anything else), driven entirely by the injected MathFontMetrics port (metrics.ts) rather than any font-parsing code of its own — src/pdf/math-font.ts is the real implementation. compose.ts/radical.ts/length.ts are its own small geometry helpers (baseline-offset box placement, a hand-drawn hooked radical sign built from line segments rather than a bare glyph substitute, MathML length-unit parsing). Output is a flat MathBox (positioned glyph runs, rules, and strokes, box-local top-left/y-down coordinates) — src/pdf/math-content-write.ts is the one consumer that turns it into PDF content-stream bytes.
  • src/pdf/ — the hand-written PDF codec, importing model/bytes/image/mathml (no OOXML knowledge at all):
    • Write: objects.ts (the PdfObject discriminated union), afm-widths.ts/encoding.ts/winansi.ts/fonts.ts (standard-14 metrics, WinAnsi encoding, family resolution), measure.ts/text-layout.ts (greedy line-wrapping), matrix.ts, content-write.ts (LayoutItem[] → content-stream operators), write.ts (the full object graph, classic cross-reference table, trailer, and — when WritePdfOptions.formulas is non-empty — one embedded math composite font group, allocated once and shared across pages).
    • Embedded math font: sfnt.ts (a generic sfnt table-directory reader), math-cmap.ts/math-hmtx.ts/math-table.ts (Unicode → glyph ID, per-glyph advance widths, and the OpenType MATH table's constants/glyph-info subtables — every offset cross-checked against the real vendored font while these were built, not transcribed from the spec alone), math-font.ts (parses and caches the vendored STIX Two Math font once per process, exposing a size-specific MathFontMetrics implementation), math-font-write.ts (builds the /Type0//CIDFontType0//FontDescriptor//FontFile3/ToUnicode object group), math-content-write.ts (a PositionedFormula[] → PDF content-stream bytes, Identity-H 2-byte CIDs for text-showing, re/m/l operators for rules and the radical hook). See Fidelity for the CFF-full-embed (not glyph-subsetted) simplification this makes.
    • Read: lexer.ts/parse.ts (byte tokenizer and tokens → PdfObject), filters.ts/predictors.ts (Flate/LZW/ASCII85/ASCIIHex/RunLength, TIFF/PNG predictors), xref.ts/document.ts (classic and cross-reference-stream resolution, object streams, /Prev chains, linear-scan recovery, the page tree with attribute inheritance), content-read.ts/interpret.ts (the content-stream tokenizer and graphics/text state machine, including form-XObject recursion), cmap.ts/font-style.ts/font-read.ts (/ToUnicode CMaps, font-dictionary resolution), images-read.ts (Image XObjects → PNG/JPEG bytes), read.ts (readPdf, assembling all of the above into a LayoutDocument).
    • codec.tspdfCodec, a z.codec() pair over readPdf/writePdf (PDF bytes ⇄ LayoutDocument).
  • src/ooxml/ — resolves a Package into a ContentDocument: docx/read.ts and pptx/read.ts are now thin adapters over ooxml.js's own readDocx/readPptx, wrapping their { metadata, sections }/{ metadata, slides } result into ContentDocument's wordprocessing/presentation shape. The docx style cascade (docDefaults → named-style basedOn chains → paragraph-mark run properties → character styles → direct formatting), the pptx placeholder → layout → master → theme inheritance cascade, and DrawingML geometry/colour resolution all now live upstream in ooxml.js itself, not in this package.
  • src/odf/ — the ODF-side counterpart to src/ooxml/, resolving an odf.js Package into a ContentDocument: odt/read.ts's readOdtContent is a thin adapter over odf.js's own readOdt, wrapping its { metadata, sections } result into the identical wordprocessing shape readDocxContent produces — the concrete proof that odt and docx genuinely share one pivot and one layout engine. odp/read.ts's readOdpContent is the same adapter over odf.js's own readOdp, wrapping { metadata, slides } into the identical presentation shape readPptxContent produces. ods/read.ts's readOdsContent wraps odf.js's readOds's { metadata, sheets } into the spreadsheet ContentDocument variant, and odg/read.ts's readOdgContent wraps odf.js's readOdg's { metadata, pages } into the drawing variant — odg still has no OOXML-side sibling adapter at all (no drawing-equivalent OOXML format this package reads); ods now does, ooxml.js's own readXlsxContent/buildXlsxPackage, consumed directly by src/convert/convert.ts's odsToXlsx/xlsxToOds bridge (see below) but deliberately not re-exported from this package's own public surface, mirroring the readDocx/readPptx non-re-export choice above. buildOdtPackage/buildOdpPackage/buildOdsPackage/buildOdgPackage (src/edit/{odt,odp,ods,odg}/content.ts) each bridge a ContentDocument back to a fresh package built on that format's own live-view editor, closing the PDF → odt/odp/ods/odg direction (pdfToOdt/pdfToOdp/pdfToOds/pdfToOdg each call the matching one) — see the pdfToOds gotcha below for buildOdsPackage's own printSettings-writing addition. formula/read.ts's readOdfFormulaContent/readOdfEmbeddedFormula are the same thin-adapter pattern over odf.js's own readOdfFormula, for a standalone .odf and an embedded sub-object respectively (the latter reading the sub-object's own content.xml/meta.xml directly out of the outer package's flat Package.parts record, no separate unzip step needed); formula/detect.ts's detectEmbeddedFormulaFrames is genuinely new work with no odf.js-side equivalent at all — odf.js's own readDrawFrameContent doesn't recognise a draw:object-bearing draw:frame yet, so odt/read.ts and odp/read.ts each run this as a second pass over the same package's raw content.xml to find and inject a formula's own placeholder block (see the Gotchas entry below for the exact scope and positioning caveats this second pass carries).
  • src/layout/ — the pure conversion algorithms, importing model, (for formula placement) mathml, and — for line-wrapping/pagination itself — several genuinely PDF-sourced primitives: the injected TextMeasurer port and wrapRunsToWidth (src/pdf/measure.ts/text-layout.ts, since deciding where a line breaks needs to know how wide text renders in a PDF standard-14 font, regardless of which format the content came from), loadMathFont (src/pdf/math-font.ts, for formula placement), src/pdf/matrix.ts's rotatePointAboutCenter (slides.ts's own shape-rotation placement), and src/pdf/afm-widths.ts/fonts.ts's STANDARD_METRICS/resolveStandardFont (reconstruct.ts's own font-matching when reconstructing from a LayoutDocument) — the one dependency arrow in this package that runs opposite the general "generic primitives outward" ordering below, since text layout is inherently coupled to the one font model (pdf's own standard-14 resolution) every conversion direction ultimately renders through: engine.ts (ContentDocument wordprocessing → LayoutDocument: flow, line-breaking, pagination — fed identically by docx- and odt-sourced content; also returns WordprocessingLayoutResult.formulas, every embedded formula block it laid out via src/mathml's layoutFormula, positioned in PDF page space — see the Gotchas entry below on why a formula can't become an ordinary LayoutItem), slides.ts (ContentDocument presentation → LayoutDocument: direct EMU-to-point placement, no pagination needed — fed identically by pptx- and odp-sourced content; also exports convertShape, the single-ContentShape-to-LayoutItem[] conversion drawing.ts below reuses verbatim, now optionally formula-aware via its own trailing formulaContext parameter so drawing.ts's existing call site keeps compiling unchanged), sheets.ts (ContentDocument spreadsheet → LayoutDocument: resolve the print range, build cumulative column/row offsets skipping hidden ones, reserve header/repeat-row-column space, resolve an explicit or non-iterative fit-to-page scale, partition into column/row bands honouring manual breaks with the same "an oversized item gets its own band and overflows rather than looping" guarantee engine.ts's ensureRoom documents, emit pages in downThenOver/overThenDown order, then per page paint backgrounds/gridlines/headers/cell text with default alignment by value kind and ###/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts an AbortSignal, since a 50k-cell sheet needs cancellation where a docx/pptx page count never did), drawing.ts (ContentDocument drawing → LayoutDocument: one ContentDrawPage per PDF page, direct placement like slides.ts, with one new emission path — a ContentVector rect/ellipse/line maps onto the pre-existing LayoutRect/LayoutEllipse/LayoutLine kinds, and a path vector's local, viewBox-relative subpath points are resolved through the vector's own frame offset then a single page-space flip into a LayoutPath value; vectors paint before shapes, a documented, bounded choice — see this module's own top-of-file note — since ContentDrawPageSchema keeps shapes and vectors as two independently paint-ordered arrays with no field recording their relative order when the two genuinely overlap), reconstruct.ts (LayoutDocumentContentDocument: reconstructWordprocessing/reconstructPresentation do baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs; reconstructDrawing does no clustering at all, since a drawing has no such structure to infer in the first place — every LayoutItem maps close to 1:1 back onto a ContentVector rect/ellipse/line/path or a ContentShape, in the exact z-order it was painted, bucketed into ContentDrawPageSchema's own two independently-ordered shapes/vectors arrays the same way drawing.ts produced them; reconstructSpreadsheet tries a real gridline lattice first — scanning the page's LayoutLine/stroked-single-segment-LayoutPath items for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid, using those line positions directly as cell boundaries when found — and falls back to text-position clustering otherwise, reusing this same module's clusterIntoLines for rows and a parallel recurring-x-position generalisation of clusterIntoParagraphs's own dominantLeftX for columns; every recovered cell is a bare string, column widths/row heights are genuinely measured from whichever geometry was used, and no print range/scale/repeat-rows/repeat-columns/manual-breaks are ever inferred).
  • src/hsqldb/ — the .odb decoders, in two tiers over two genuinely different on-disk storage shapes a HSQLDB table can use. script.ts (Tier 1): a small, bounded HSQLDB TEXT-script-format (hsqldb.script_format=0) DDL/DML text parser, not a database engine — parseHsqldbScript(bytes) extracts CREATE TABLE's own column names/types and INSERT INTO's own row values into HsqldbTable[], tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwing HsqldbScriptParseError for anything matching neither list. rowformat.ts/cache.ts (Tier 2): a CACHED table's own binary row-store format — LibreOffice's embedded-HSQLDB default (database.isStoredFileAccess() switches hsqldb.default_table_type to cached specifically for storage-backed access, confirmed against the decompiled engine source) — a CACHED table's DDL still lives in database/script as ordinary TEXT (Tier 1 parses it unmodified) but its row data lives in a separate binary page-cache file, database/data. rowformat.ts decodes one column's own binary field at a time (HsqldbDataCursor, a big-endian DataView cursor; readHsqldbColumnValue, one branch per SQL type code); cache.ts walks a table's own AVL row-position tree (readHsqldbCachedTableRows, following each row's persisted iLeft/iRight child positions recursively, needing no key-comparison or free-list logic at all — a deleted row is already unlinked from the tree before its space can be reused, so a traversal rooted at the tree's current root only ever reaches live rows), rooted at the position parseHsqldbIndexRoots recovers from each table's own SET TABLE ... INDEX'...' script line, using parseHsqldbProperties's reading of database/properties (cache-file scale, engine version) to resolve byte offsets; decodeHsqldbCachedTables is the orchestration src/odb/read.ts calls, splicing real rows into every table with an index-root line and leaving every other table (MEMORY/TEXT, or a genuinely empty CACHED table — HSQLDB never writes an index-root line for one) exactly as Tier 1 already produced it. Both tiers mirror src/pdf/'s own isolation discipline: script.ts imports only document-content-model's ContentCellValue type; rowformat.ts imports the same plus nothing else; cache.ts imports only those two and script.ts's own types — no odf.js Package/XmlElement knowledge anywhere in src/hsqldb/ — the caller is responsible for handing every function its raw bytes/text already extracted from a real .odb package. HsqldbTable/HsqldbColumn are also the shared pivot shape src/firebird/'s own Tier 3 decoder below produces. See Gotchas for Tier 2's own version scope and verification account.
  • src/firebird/ — the Tier 3 .odb decoder: a reader for Firebird's own gbak logical-backup format (database/firebird.fbk), the artifact a real Firebird-embedded .odb actually contains — see the README's own Gotchas entry below for the empirical finding that this is NOT a raw on-disk ODS page dump, the single largest correction this subsystem's own design went through. reader.ts holds the two distinct byte-level primitives the format mixes (FirebirdBackupReader, the generic little-endian tag+length+value attribute framing every rec_*/att_* record uses, plus its own RLE/"PackBits"-style decompression for att_data_data when the backup is compressed; XdrReader, the big-endian, 4-byte-aligned RFC 1832 XDR decoding a row's own field values use once compression is peeled off). blr-types.ts maps a field's own BLR type opcode (att_field_type) onto its physical storage representation, sourced directly from Firebird's own blr.h/align.h. date.ts restates Firebird's own MJD-epoch DATE and 1/10000-second-tick TIME encoding, taken from NoThrowTimeStamp.cpp. schema.ts walks rec_relation/rec_field (column definitions gbak has ALREADY resolved from the live engine's system tables at backup time — see the Gotchas entry). data.ts walks rec_relation_data/rec_data (a relation's own rows, addressed by name), decoding each row's XDR-and-possibly-RLE-compressed field-value sequence into ContentCellValue[]. backup.ts's readFirebirdBackup is the top-level entry point, producing the identical HsqldbTable[] shape parseHsqldbScript does.
  • src/odb/ — the decoder-selection and pivot-mapping layer sitting between odf.js's .odb support and src/hsqldb//src/firebird/: read.ts's readOdbTables(pkg) calls odf.js's own readOdbInventory to classify the package's connection (throwing OdbNoEmbeddedDataSourceError for an external-only datasource) and its embedded engine, then routes a genuine HSQLDB TEXT script to parseHsqldbScript, then — whenever a database/data part is present — hands Tier 1's own result to src/hsqldb/cache.ts's decodeHsqldbCachedTables to splice in every CACHED table's real rows (a .odb with no CACHED table at all, the common case, never even looks for database/data, leaving Tier 1's own result untouched), or routes a Firebird database/firebird.fbk part to readFirebirdBackup — throwing OdbUnsupportedFormatError (naming HSQLDB's own whole-script binary/compressed serialisation explicitly, the one embedded shape still unimplemented) for anything none of these cover. spreadsheet.ts's odbTablesToSpreadsheetDocument maps HsqldbTable[] onto the same ContentSheet-based ContentDocument spreadsheet variant readOdsContent/buildOdsPackage already produce and consume, feeding odbToXlsx's call into buildXlsxPackage directly. csv.ts's buildOdbTableCsv writes exactly one named table as CSV bytes, with no ContentSheet/xlsx machinery involved at all, throwing OdbTableNotSpecifiedError/OdbTableNotFoundError (naming every available table) when the caller's own table option doesn't resolve to exactly one table.
  • src/convert/convert.ts (the twelve PDF-pivot round-trip ergonomic wrappers, a dedicated "Six cross-format bridges" section: odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds, each a direct readXContentbuildYPackage composition bypassing PDF entirely — see FidelityodmToPdf, the one further conversion shaped around a caller-supplied resolveSubDocument callback rather than being purely bytes-in/bytes-out, since a .odm master document's own chapters are external references odf.js's readOdm never inlines — see Gotchas — odbToXlsx/odbToCsv, thin compositions over readOdbTables and src/odb/'s own pivot/CSV mapping, and odfToPdf, a standalone .odf formula document → PDF via readOdfFormulaContentsrc/mathml's layoutFormulawritePdf's own formula-aware option, with no reverse pdfToOdf at all), codec.ts (docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec plus odtDocxCodec/odpPptxCodec/odsXlsxCodec, a z.codec() pair over each — odmToPdf/odbToXlsx/odbToCsv/odfToPdf have no codec of their own, for the same fixed-signature/one-directional reasons each has no port entry, or a one-way port entry, below), port.ts/local.ts (the swappable DocumentConverter contract and its synchronous local implementation, covering docx/pptx/odt/odp/ods/odg/odfpdf, pdfdocx/pptx/odt/odp/ods/odg, and the six bridge pairs — DocumentFormat includes xlsx for exactly this reason, even though xlsx has no PDF conversion of its own; odm and odb are deliberately not DocumentFormat members, since neither odmToPdf nor odbToXlsx/odbToCsv is wired into this port at all; odf IS a member, but with only the one odf → pdf entry — no pdf → odf). Every conversion function that builds a ContentDocument/LayoutDocument internally (the twelve PDF-pivot conversions and the six bridges; odfToPdf accepts but never invokes it) also accepts an onDocument callback, and ConversionResult carries the same value through the port as an optional package field — the full DocumentPackage (content + layout, from document-content-model) that conversion built, not just its target bytes.

Dependency direction is downward and checkable, with one deliberate exception (layout, noted below): bytes/mathml/ports import nothing local (mathml is fully self-contained — no dependency on model, document-content-model, or any ODF/PDF package, since it consumes only its own locally-mirrored MathMlNode input and its own injected MathFontMetrics port); model imports nothing local at the value level, but formula.ts carries one type-only import of MathBox/MathMlNode from mathml (erased entirely at runtime, and not a cycle — mathml itself imports nothing from model); image imports bytes only; pdf imports model+bytes+image+mathml+ports; ooxml/* imports model only (now a thin adapter over ooxml.js's own readDocx/readPptx — see the src/ooxml/ entry above — with no xml/* dependency of its own left; no PDF knowledge either); odf/* imports model only (no PDF knowledge, no xml/*odf.js already owns its own XML query helpers); hsqldb imports document-content-model only (no odf.js knowledge); firebird imports document-content-model (its own row/schema decoding, ContentCellValue only) and hsqldb (HsqldbTable/HsqldbColumn, a type-only import for its own output shape — the deliberate pivot-sharing point between Tier 1 and Tier 3) but no odf.js knowledge at all; layout imports model+mathml+ports, plus, genuinely upward, several text-measurement/font-metric/matrix primitives from pdf itself (measure.ts/text-layout.ts/math-font.ts/matrix.ts/afm-widths.ts/fonts.ts — see the src/layout/ entry above for exactly which); odf-package imports odf.js only (no local dependency, mirroring opc's relationship to ooxml.js); odb imports hsqldb+firebird+model+odf-package+odf.js only; convert composes everything else. No PdfObject/PdfDict/PdfStream type appears outside src/pdf/.

Build, test, and lint

pnpm build         # tsdown -> dist/ (ESM + CJS + .d.ts)
pnpm typecheck     # tsc --noEmit
pnpm lint          # eslint . --max-warnings 0
pnpm test          # vitest run --project unit
pnpm test:watch    # vitest --project unit
pnpm test:smoke    # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise, a real createOdg/odgToPdf/pdfToOdg round trip (a curved path, a filled rect, and text, built entirely through the odg live-view editor, converted to PDF and reconstructed back to odg via reconstructDrawing), and a real odfToPdf conversion (a fraction, rendered via the embedded STIX Two Math font -- checked by confirming the built PDF contains a real /Type0/Identity-H/CIDFontType0C font resource, proving the base64-embedded font asset itself survived the tsdown build), from the built CJS bundle
pnpm test:corpus   # optional real-world PDF conformance checks against a local, gitignored test/corpus/ (see Fidelity)

To run a single test file: pnpm vitest run src/path/to/file.test.ts.

Conventions

  • Zod-first schema/type/guard, matching ooxml.js: every model type is inferred from its Zod schema, never hand-written. ContentBlock (recursive, mirroring ooxml.js's own XmlNode treatment) uses a hand-written structural guard + z.custom, not z.lazy, which collapses to unknown for recursive element-children in the pinned Zod version.
  • z.codec() for every schema-to-schema round trip, matching ooxml.js's packageCodec/xmlCodec: pdfCodec (PDF bytes ⇄ LayoutDocument), docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec (docx/pptx/odt/odp/ods/odg bytes ⇄ PDF bytes), and odtDocxCodec/odpPptxCodec/odsXlsxCodec (odt/odp/ods bytes ⇄ docx/pptx/xlsx bytes, the PDF-bypassing bridges) each wrap an already-independently-tested function pair, adding automatic two-way schema validation. These are deliberately the no-options form — readPdf/writePdf/docxToPdf/pdfToDocx/pptxToPdf/pdfToPptx/odtToPdf/pdfToOdt/odpToPdf/pdfToOdp/odsToPdf/pdfToOds/odgToPdf/pdfToOdg/odtToDocx/docxToOdt/odpToPptx/pptxToOdp/odsToXlsx/xlsxToOds remain the primary entry points wherever a caller needs an AbortSignal, a PdfDiagnosticSink, or an onSubstitution callback, since z.codec()'s fixed decode(input)/encode(output) signature has no room for side-channel options.
  • PdfObject has no Zod schema at all, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser — validating it would just be validating our own output. It narrows natively on its own kind discriminant instead, the same reasoning ooxml.js applies when it picks a hand-written isXmlNode guard over z.lazy.
  • No type assertions anywhere. Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
  • Live views, not flatten-and-regenerate. src/edit/*'s editor classes hold a reference directly into the real Package/XmlElement objects; saving is encodePackage(pkg), nothing more. This is what makes "everything you didn't touch stays byte-faithful" a structural guarantee rather than a best effort.
  • A three-tier PDF-read failure policy, applied consistently across every src/pdf/* read module: throw a typed PdfParseError/PdfEncryptedError for a file that cannot be meaningfully processed at all; recover with a PdfDiagnostic (severity: 'warning') for something malformed but salvageable (a bad startxref, a wrong stream /Length); degrade with a diagnostic for an individual unsupported feature (an unimplemented filter, an unrecognised colour space) while the rest of the document still reads.
  • Conventional commits, enforced via commitlint + husky, matching ooxml.js.

Gotchas and quirks

  • ooxml.js's typed readers (readDocx/readPptx) are now the actual basis for conversionreadDocxContent/readPptxContent are thin wrappers around them, not an independent walk of word/document.xml/ppt/slides/slideN.xml. They are still deliberately not re-exported from this package's own public surface: readDocx/readPptx also carry comments/footnotes/headers/footers (docx) that ContentDocument doesn't model, so exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models.
  • ODF paragraph/heading text content is not a plain string the way a docx run's w:t is, and reading it wrong fails silently. Real whitespace collapses HTML-style when an ODF consumer renders XML text-node content, so the format represents a run of two or more literal spaces as <text:s text:c="N"/> (an ELEMENT, not a text node), a tab as <text:tab/>, and a hard line break as <text:line-break/> — all three occupy real character positions in a paragraph's flat content model but carry no text-node value at all. Every ODF text getter in this codebase's editor layer (src/edit/odt/*, src/edit/odp/*, src/edit/ods/*) MUST call decodeOdfText (src/xml/odf-text.ts) — never ooxml.js's own textContent(), a plain text-node concatenation with no idea text:s/text:tab/text:line-break exist. textContent() would silently DROP every one of them: the file still parses as valid XML, so this produces no error and no warning, just silently shorter text. decodeOdfText delegates the real work entirely to odf.js's own decodeOdfText (wrapped in a synthetic container element, since odf.js's version operates on a whole XmlElement's children rather than a bare node array); the encode direction, encodeOdfText (plain string → the same element sequence, coalescing adjacent literal characters into as few text nodes as practical), is local to this package, since odf.js is a read-and-manifest package with no write-side text builder of its own.
  • The docx⇄PDF and pptx⇄PDF conversions are explicitly not round-trip-lossless — in deliberate contrast to ooxml.js's own packageCodec, which is byte/part-faithful by design. See Fidelity. The six cross-format bridges below (odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds) are a genuinely different case — see the Fidelity section's own paragraph on them.
  • A DocumentPackage returned via onDocument/ConversionResult.package is a snapshot from that one conversion pass, not a live view — its layout correlates with its content only as of the exact read+layout that produced it (document-content-model's own DocumentPackageSchema doc comment), so if a caller mutates the returned content afterwards, the layout sitting alongside it silently goes stale; nothing in this package (or document-content-model) detects or rejects that.
  • Building the six cross-format bridges surfaced two real, previously-undiscovered gaps in existing populateParagraph write paths, both now fixed. buildDocxPackage's populateParagraph (src/edit/docx/content.ts) never wrote a paragraph's own list membership back (ContentParagraph.list, docx's flat numId/level model) — only read, never written, since no existing caller had ever round-tripped a list-bearing paragraph through it. buildOdtPackage's populateParagraph (src/edit/odt/content.ts) never wrote a paragraph's own styleId back at all (readOdtContent/readOdfParagraph in odf.js reads it unconditionally from text:style-name, but nothing on the write side ever set that attribute). Both are now fixed: DocxParagraph.list is set unconditionally alongside styleId/alignment, matching that function's own existing pattern; OdtParagraph.styleId is set conditionally alongside alignment, matching odt's own local convention. buildOdtPackage additionally gained appendBlocks/appendListRun (src/edit/odt/content.ts) — ODF has no flat per-paragraph list property to set the way docx does, so a run of consecutive ContentParagraphs sharing list.numId is grouped and written as a real, potentially multi-level text:list/text:list-item tree via OdtList/OdtListItem, the structural inverse of odf.js's own list-reading (a fresh text:list per numId change, one level of nesting per list.level step, descending only one level at a time since ODF can only open a nested list from inside an existing item). Both gaps were invisible before this task specifically because nothing had previously round-tripped a list-bearing paragraph or a styled paragraph through docx ⇄ odt at all — the PDF-pivot conversions never exercised buildDocxPackage/buildOdtPackage on content read back from the OTHER format.
  • A table shape inside an odp slide does not survive odpToPptx. buildPptxPackage's appendShape (src/edit/pptx/content.ts) silently drops any non-paragraph block found inside a shape's own text-box loop — a scope choice whose own comment ("PDF-reconstructed shapes never mix kinds") assumed its only caller was the PDF-reconstruction path, where that is true. odpToPptx is a second, non-PDF-reconstructed caller for which it is not: a real odp draw:frame containing a table:table directly (not inside a text box) reads as a ContentShape with a 'table' block, and that block is silently dropped, leaving an empty pptx text box where the table was. Everything else on the same slide — a rotated shape, grouped shapes, an image, speaker notes — survives correctly (see src/convert/bridges.test.ts's own dedicated fidelity-gap test, which proves both halves against the existing minimalOdpBytes() fixture). A real, tracked, bounded gap, not a silent one: closing it means teaching buildPptxPackage/buildOdpPackage to write a real table into a slide shape, a materially larger feature than this bridge's own scope.
  • The ods ⇄ xlsx bridge inherits several real, format-boundary fidelity limits from ooxml.js's brand-new readXlsxContent/buildXlsxPackage, on top of its own pivot-copy design. xlsx has no percentage/currency cell type of its own (both are a plain numeric cell plus a number-format style neither this reader nor this writer interprets) — an ods percentage/currency cell survives the odsToXlsx hop with its numeric value intact but downgrades to a plain number kind, permanently (currency's own currency code is dropped outright). xlsx also has only one rare t="d" cell type covering BOTH date and time — an ods time cell survives as a date-kind cell carrying its original value string verbatim, but mislabelled; an ods date cell is unaffected (it was already the kind xlsx's own t="d" maps onto). A formula (table:formula/<f>) is carried completely verbatim in both directions — never parsed, translated, or evaluated by either this package's own reader or writer — but a REAL spreadsheet application does evaluate a workbook's own <f>/table:formula on open: confirmed against genuine LibreOffice 26.2, an ods formula authored in OpenFormula syntax (of:=[.B2]*2) becomes a formula ERROR (Err:510) when the bridged xlsx is opened in real Calc, even though the formula's own cached value is still present and correctly readable via readXlsxContent — going the other way is less fragile in practice only because a genuine xlsx formula (bare Excel A1 syntax, e.g. B2*2) happens to still parse under LibreOffice's own more lenient, backward-compatible ODF formula grammar, not because of anything this bridge does differently in either direction. Column widths survive the odsToXlsx hop within roughly a pixel of rounding tolerance (see src/convert/bridges.test.ts's own COLUMN_WIDTH_TOLERANCE_PT) but are then dropped entirely on the return xlsxToOds hop — not a character-width-unit rounding loss, but buildOdsPackage not writing ContentSheetColumn.widthPt at all, a pre-existing, already-documented gap in that file's own module comment, unrelated to and unfixed by this bridge. A boolean cell written by buildXlsxPackage renders as a raw 1/0 rather than TRUE/FALSE when opened in real Excel/Calc, since that writer's own genuinely-minimal xl/styles.xml (one default cell format, no boolean-specific number format) has nothing else to apply — the underlying { kind: 'boolean', value: true } is still read back correctly by readXlsxContent regardless; this is a real-application display gap, not a data-fidelity one. readXlsxContent's own cell.value.kind never produces 'error' from an odf.js-sourced document at all, for a structural reason rather than a bug: ODF's office:value-type enumeration has no error member, so OdsCell.value's own write-side choice for a kind: 'error' cell is to write it as a genuine, non-empty office:string-value carrying the error's own text — an xlsxToOdsodsToXlsx round trip of a genuine xlsx t="e" error cell therefore turns it into a plain string cell carrying the identical text; the message survives, the error semantic does not.
  • odpToPdf/pdfToOdp needed zero new layout code. readOdpContent (src/odf/odp/read.ts) produces the identical presentation ContentDocument shape readPptxContent does, so it feeds convertPresentationToLayout unmodified — including the existing hidden-annotation speaker-notes mechanism below, which carries odp's presentation:notes through to the PDF with no new notes-handling code at all; pdfToOdp reuses reconstructPresentation unmodified too, the same architectural bet pdfToOdt already proved for reconstructWordprocessing. The genuinely new work for the reverse direction was the live-view editor itself (src/edit/odp/*) — see Architecture above.
  • OdpShape.rotationDeg writes a real draw:transform, built on odf.js's own transform machinery. It is the write-side inverse of odf.js's resolveOdfShapeGeometry (typed/shared/transform.ts), built on that module's own exported applyOdfTransform rather than a hand-rolled rotation matrix, so it inherits that module's own empirically-verified rotate/translate composition order and sign convention by construction. Unlike PptxShape (see the colSpan/rowSpan gotcha below, which pptx still has and odp does not), buildOdpPackage writes a rotated shape's rotation back correctly — verified both by this package's own tests and by opening a fresh, editor-built .odp in actual LibreOffice.
  • src/pdf/interpret.ts tracks general vector paths, not just axis-aligned re rectangles. m/l/c/v/y/h (and re itself, per its own ISO 32000-1 definition as a 4-point rectangle subpath) accumulate real subpaths — CTM-transformed line/cubic segments, open or closed — and any paint operator (f/F/f*/S/s/B/B*/b/b*) emits a LayoutPath item when the path isn't reducible to the simple single-re-on-an-axis-aligned-CTM case (which still takes the original, unchanged LayoutRect fast path). Verified both by dedicated tests and by a genuine writePathwritePdfreadPdf round trip recovering the original LayoutPath value exactly. This is the shared infrastructure both pdfToOds and reconstructDrawing need; both now use it. A direct, practical consequence for pdfToOds: readPdf never reconstructs a 'line' kind item at all (see the reconstructDrawing gotcha below), so a gridline written by sheets.ts's own renderGridlines always comes back from a real PDF round trip as a generic, single-subpath, single-line-segment, stroke-only LayoutPathreconstructSpreadsheet's own gridline-lattice detection accepts both shapes (a genuine LayoutLine item and this stroked-single-segment LayoutPath shape) for exactly this reason.
  • pdfToOds recovers what was printed, not what was entered. reconstructSpreadsheet (src/layout/reconstruct.ts) tries a real gridline lattice first: it scans the page's LayoutLine/stroked-single-segment-LayoutPath items (see the interpret.ts gotcha above) for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid (MIN_GRIDLINE_COUNT_PER_AXIS = 3 per axis, i.e. at least a 2×2 grid, and a span-consistency check that rejects a scatter of unrelated short strokes — a page border or a couple of decorative rules — as not a genuine lattice), and uses those line positions DIRECTLY as cell boundaries when found. Absent a lattice, it clusters text into a grid from geometry alone instead: rows reuse clusterIntoLines verbatim (a spreadsheet cell's own text is never wrapped across lines, so a text line already IS a row), and columns generalise clusterIntoParagraphs's own single dominantLeftX to several recurring x-position anchors, first merging directly-adjacent same-line fragments (splitLineByLargeGaps, the same >2em-gap signal reconstructPresentation's own block clustering uses) so a cell whose text arrived as several run-level-split LayoutText items isn't scattered across spurious columns. Column widths and row heights are genuinely measured from whichever geometry was used (drawn gridline gaps, or measured text/anchor extents), never invented. Every recovered cell is a bare string carrying only its own extracted displayTextnever re-parsed into a number/date/boolean, and never claimed as a formula, even when the text looks numeric or date-shaped; see Fidelity for the full framing. buildOdsPackage (src/edit/ods/content.ts) is pdfToOds's own package-building half, mirroring buildOdtPackage/buildOdpPackage/buildOdgPackage's role for pdfToOdt/pdfToOdp/pdfToOdg.
  • buildOdsPackage now writes printSettings for real, via a new OdsSheet.printSettings getter/setter (src/edit/ods/print-settings.ts) — discovered as a genuine blocker while building pdfToOds's own round-trip verification, not a pre-planned feature. OdsEditor/OdsSheet previously had no width/height/print-settings API at all, so buildOdsPackage silently dropped ContentSheetPrintSettings entirely; that made a reconstructed sheet's own recovered gridlines/headers/pageSize unverifiable by any real write-then-reread round trip, which is exactly what pdfToOds's own test needed to prove. The setter mints a fresh style:page-layout (styles.xml/office:automatic-styles) + style:master-page (styles.xml/office:master-styles) + style:style[family="table"] (content.xml/office:automatic-styles) triple and repoints the sheet's own table:style-name to it on every call, rather than mutating whatever it was pointing at before — the same append-only style-editing convention src/edit/odg/style.ts already documents. Scoped to the five fields ContentSheetPrintSettingsSchema always carries (pageSize/margins/gridlines/headers/pageOrder); printRange/scale/fitToPages/repeatRows/repeatColumns/manualBreaks (all optional, and never set by reconstructSpreadsheet) are still not read or written — resolving them needs the same table-wide repeated-column/row cursor tracking odf.js's own readTable does before ever calling its own readPrintSettings, a genuinely separate, larger undertaking than this getter/setter's own scope.
  • OdsSheet/OdsEditor still have no column-width or row-height setter at all — discovered the same way as the printSettings gap above, while building pdfToOds's own smoke-test coverage. OdsSheet.cell()'s own column/row-materialisation (address.ts) creates a real, explicit table:table-column/table:table-row element for any position a caller ever addresses, but never gives it a width/height style. This is a genuinely different failure shape from a column/row with NO element at all: sheets.ts's own resolveAxis only falls back to DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT for an index with no ContentSheetColumn/ContentSheetRow entry whatsoever — an explicit-but-unstyled element reads back at widthPt/heightPt 0 (odf.js's own resolveColumnWidthPt/readRowLayout), and that explicit zero wins over the fallback. A sheet built purely through createOds()/OdsSheet.cell() therefore renders with a zero-size grid — real content needs an explicit column-width/row-height style, exactly as a real LibreOffice-authored file always has one (see src/test-support/ods.ts's own fixtures, which set one deliberately). buildOdsPackage (src/edit/ods/content.ts) documents this as a tracked, bounded gap alongside ContentSheetImage/embeddedObjects, mirroring buildOdtPackage's own identical image/colSpan-write gaps.
  • reconstructDrawing maps recovered geometry back onto ODF shapes near-1:1, with no clustering — but PDF's own content-stream operators still force several ContentVector kinds to collapse to a generic path on the way through. Every painted LayoutItem maps onto a ContentVector/ContentShape directly, in the exact z-order it was recovered — LayoutRectrect, LayoutEllipseellipse, LayoutLineline, LayoutPathpath, LayoutText/LayoutImageContentShape — a fundamentally more tractable problem than reconstructWordprocessing/reconstructPresentation's own paragraph/shape geometry clustering, since a drawing has no semantic structure to infer at all. The catch is upstream of reconstructDrawing itself, in what readPdf can even hand it: src/pdf/interpret.ts's LayoutRect fast path only fires for a fill-only rectangle under a non-rotated CTM (see the interpret.ts gotcha above), writeEllipse always emits an ellipse as four cubic Beziers with no PDF-level marker that it started life as an ellipse, and readPdf never reconstructs a 'line' kind item at all — so a stroked-and-filled rect, any ellipse, and any line each come back from a PDF as a generic LayoutPath, and reconstructDrawing correctly maps that to a ContentVector 'path', not the shape's original kind. Position, size, and fill/stroke colour still survive (within ordinary floating-point/string-formatting tolerance); only the vector's own discriminant kind narrows to whatever PDF's content-stream operators actually distinguish. A path vector's own reconstructed frame is a further, separate approximation: it is the tight bounding box of every recovered point, cubic control points included (a cubic curve is guaranteed to lie within their convex hull, so this never clips the curve) — which can legitimately be larger than whatever frame the original path's own author declared, if that frame didn't tightly bound its own control points to begin with (a real, valid ODF/SVG authoring pattern: a viewBox/frame is a declared coordinate window, not a guaranteed tight bounding box). A single original drawing text box that PDF's own greedy line-wrapper split across several lines does not reconstruct as one multi-line shape: reconstructDrawing maps each recovered LayoutText item to its own separate ContentShape (the same one-LayoutItem-to-one-shape rule every other kind follows), so a wrapped multi-line text box comes back as several small, independently-positioned text boxes, one per original line — confirmed visually against real LibreOffice (see the real-file verification note below); the full text content still survives, just redistributed. buildOdgPackage (src/edit/odg/content.ts) is pdfToOdg's own package-building half, mirroring buildOdtPackage/buildOdpPackage's role for pdfToOdt/pdfToOdp.
  • Two real, confirmed-against-actual-LibreOffice-rendering fill bugs were fixed as part of building reconstructDrawing/pdfToOdg, not by it. Both are pre-existing gaps in code that reconstructDrawing's own real-file verification exposed, not something the reconstruction algorithm itself introduced, and both apply to every .odg this package writes, not only a reconstructed one: (1) src/edit/odg/style.ts's graphicPropertyAttrs wrote draw:fill-color alone, with no accompanying draw:fill="solid" — real LibreOffice 26.2 fills a draw:rect/draw:ellipse that way fine, but silently renders a draw:path with the identical omission as unfilled, even with a fill colour declared. draw:fill="solid" is now written explicitly whenever a fill is set, for every vector kind. (2) writeEllipse (src/pdf/content-write.ts) never emitted a PDF closepath (h) operator, even though its four Bezier arcs already return exactly to their own starting point — PDF fill operators close every subpath implicitly regardless (ISO 32000-1 8.5.3.1), but readPdf's own general path tracking only marks a subpath closed: true when it actually sees an explicit h, so a PDF-round-tripped ellipse came back with closed: false, which correctly-behaving ODF/SVG consumers then refuse to fill even with draw:fill="solid" set. writeEllipse now emits h before its paint operator, drawing no additional ink (the path was already geometrically closed) but recording that closure explicitly.
  • A vector primitive's own fill/stroke needed a self-contained graphic-family style writer, not odf.js's own StyleRegistry. 'graphic' is a recognised StyleFamily member (odf.js's src/styles/registry.ts), but StylePropertiesSchema/buildStylePropertyElements (properties.ts/serialize.ts) only ever model text/paragraph formatting and never emit a style:graphic-properties element for any family — extending that shared package for one narrow, documents.js-local need (draw:fill(-color)/draw:stroke + svg:stroke-color/svg:stroke-width) would be scope creep into a foreign package for a two-attribute-group writer this package can express directly. src/edit/odg/style.ts is that writer: it still reuses odf.js's general append-only style-editing invariant (a setter always mints a fresh style:style and repoints draw:style-name, never mutates an existing entry — verified by the same assertAutomaticStylesOnlyAppended helper OdpEditor's own live-view fidelity test uses) and src/edit/odt/automatic-styles.ts's ensureAutomaticStyles/nextStyleName (the "find-or-create office:automatic-styles, mint the next unused name" logic every other hand-rolled style helper in this package already shares), rather than a third reimplementation of either.
  • A path vector's own svg:d is cross-checked against odf.js's real parser, not merely asserted to "look plausible". src/edit/odg/svg-path.ts's buildSvgPathData is the write-side inverse of odf.js's parseOdfPathData; OdgPathVector.subpaths re-derives its value by reparsing the actual written svg:viewBox/svg:d through that exact function (plus parseOdfViewBox/buildOdfSubpaths) on every read, rather than echoing back whatever ContentSubpath[] the caller originally passed to addPath — so every read is itself a live round-trip proof, and this module's own test suite additionally feeds buildSvgPathData's output straight into parseOdfPathData to confirm point-for-point recovery.
  • A newly added vector/shape's paint order is expressed purely as document order, with no draw:z-index ever written. This matches odf.js's own reader-side convention exactly (typed/draw/shapes.ts's paintOrderKey: honour an explicit draw:z-index when present, otherwise fall back to document order — and real LibreOffice output never emits one, it reorders elements instead), so OdgPage.addRect/addEllipse/addLine/addPath/addTextBox/addImage simply append to draw:page's own children in call order and nothing more is needed for a later add* call to paint in front of an earlier one.
  • LayoutPathSchema (document-content-model) has no quadratic or elliptical-arc segment kind, deliberately — not a scope gap that happens to be unfilled. writePath (src/pdf/content-write.ts) therefore has no quadratic-to-cubic elevation and no SVG-arc-to-cubic endpoint-to-centre parameterization anywhere in it: odf.js's own real-LibreOffice-output-verified svg:d parser (typed/shared/path.ts) recognises S/s/Q/q/T/t/A/a as command letters (so its own token stream stays in sync) but produces no segment for any of them — real LibreOffice output for rectangles, ellipses, freeform curves, and basic custom-shape presets never emits a quadratic or an arc in the first place, only M/L/H/V/C/Z. Building unused quadratic/arc conversion code against a segment kind that can never occur would be speculative, not root-cause work.
  • A drawing page's shapes and vectors paint in two independently-ordered arrays, with no field recording their relative order. ContentDrawPageSchema (document-content-model) keeps text/image/table content (shapes) and vector primitives (vectors) as two separate arrays, each correctly paint-ordered on its own by odf.js's own reader (honouring a real draw:z-index when present, falling back to document order otherwise) — but there is no shared ordering field between the two arrays at all, a real, tracked gap in the shared schema, not something convertDrawingToLayout can reconstruct after the fact. convertDrawingToLayout resolves it with one fixed, documented choice: every vector paints before every shape (vectors are the common "diagram" content in a real .odg; shapes are far more often text labels layered on top of them than the reverse). A page that genuinely interleaves the two mid-stack will not paint in true document z-order until the schema itself grows a shared field. reconstructDrawing resolves the identical gap in reverse the same way: it buckets each recovered LayoutItem into vectors or shapes by kind while walking the page once in overall paint order, so each array keeps its own items' relative order — which reproduces a convertDrawingToLayout-produced page's original paint order exactly (vectors-then-shapes, by construction), and is still the best either array's own shape is able to express for a LayoutDocument from any other producer.
  • A vector primitive's own rotation is never read at all. None of ContentVectorSchema's variants carry a rotation field, unlike ContentShapeSchemareadOdgContent's underlying odf.js reader deliberately discards a draw:rect/draw:ellipse/draw:custom-shape's own rotation, so it reads (and convertDrawingToLayout places) at its unrotated bounding frame. A real, tracked model limitation inherited from odf.js, not something this package's own layout code introduces.
  • ContentVector's path variant's fillRule is never populated by the reader — always undefined, which writePath treats as nonzero. odf.js's readDrawPathVector does not currently resolve an evenodd fill rule from real ODF output, so every path this pipeline reads paints with PDF's default nonzero winding rule. LayoutPathSchema/writePath fully support fillRule: 'evenodd' regardless — a caller constructing a LayoutPath (or a future ContentVector producer) directly can still set it; it just never arrives via odgToPdf today.
  • ContentSheetCellSchema (document-content-model) models no per-cell border or background, and no per-cell alignment override — unlike ContentTableCellSchema.background. sheets.ts's cell-background and cell-border z-order steps are consequently skipped entirely (no dead placeholder code), and cell text alignment always falls back to the value-kind default (numeric right, boolean/error centre, string left) since there is nothing to override it with. A tracked, documented gap, not a silent one.
  • Ordinary text in PDF output uses the standard 14 fonts only — no font embedding. Helvetica/Times-Roman are genuinely metric-compatible substitutes for Arial/Times New Roman, but Word's actual current defaults (Calibri, Aptos) are not, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation, not a line-identical reproduction. The one exception is MathML formula rendering (odfToPdf, and formulas embedded inside odt/odp): those genuinely embed the real STIX Two Math font — see the CFF-embedding gotcha below for the exact scope of that embedding (the whole CFF table, not glyph-subsetted).
  • Justified paragraphs render left-aligned, not justified. Alignment (document-content-model) has a real 'justify' member, and it survives reading a docx/odt paragraph's own alignment correctly, but src/layout/shared.ts's alignmentOffsetPt — the one function every layout engine (engine.ts/slides.ts/sheets.ts) consults to position a line — has no branch for it at all, falling through to the same 0 offset 'left' gets; there is no inter-word spacing stretch anywhere in this package's line-wrapping code either. A documented narrowing, not a silent approximation: alignmentOffsetPt's own comment states "not implemented for v1" explicitly. Every other alignment value (center/right) works correctly.
  • Reading arbitrary real-world PDFs is the single largest risk surface in this package, and the parser is honest about its design target: cleanly-generated output from mainstream producers (Word, PowerPoint, Chrome, LibreOffice, Acrobat), recovering from the malformations those producers and their downstream tooling actually create, and failing loudly and specifically on anything else — not matching a mature library's robustness against adversarial input.
  • Encrypted PDFs are unsupported. /Encrypt present in the trailer throws PdfEncryptedError, even for the common empty-user-password case.
  • CCITTFaxDecode/JBIG2Decode/JPXDecode PDF images are unsupported (scanned-fax and JPEG2000 formats) — the image is skipped with a diagnostic, the rest of the page still reads. JPEG images (DCTDecode) pass through completely losslessly in both directions; PNG-sourced images go through a real, narrowly-scoped hand-written codec.
  • PDF → docx/pptx reconstruction has no table or vector-shape recovery. A PDF has no semantic table structure to recover — a wide horizontal gap on a line becomes a tab character, not a reconstructed grid. General vector paths, curves, gradients, and shadings are not recovered either. This is a genuinely different scope boundary from pdfToOds's own grid recovery (see the pdfToOds gotcha above): there, the grid itself IS the deliverable, so a real gridline lattice or text-position clustering builds one deliberately, within its own honestly-scoped limits (a bare string per cell, never a typed value).
  • Table cell colSpan/rowSpan and pptx shape rotation are read from a ContentDocument but not yet written back by buildDocxPackage/buildOdtPackage/buildPptxPackage — a merged cell round-trips as an ordinary unmerged one, and a rotated pptx shape round-trips unrotated (buildOdpPackage does not share the rotation half of this gap — see the OdpShape.rotationDeg gotcha above). Both are bounded, tracked gaps (the cell's own text content and the shape's own position are still correct), not silent ones.
  • docx headers/footers, live PAGE/NUMPAGES field substitution, and inline images are not read by readDocxContent — a deliberate, tracked scope narrowing from the original design, not an oversight.
  • pptx speaker notes survive pptxToPdf/pdfToPptx, but not through any real PDF feature. PDF has no native concept of hidden presenter notes, so convertPresentationToLayout carries ContentSlide.notes as a hidden /Subtype /Text annotation on the page (the same construct Acrobat's own sticky-note tool uses, marked with the Hidden annotation flag so it never renders or prints), and reconstructPresentation reads it back via a /T marker that distinguishes this package's own notes annotation from a genuine third-party sticky note. This is a round-trip mechanism specific to this package's own writer/reader pair — a PDF produced by anything else will never carry it, and a PDF consumer other than this package's own readPdf will never see it as anything but an invisible, empty sticky note.
  • odmToPdf is the one conversion in this package that is not purely bytes-in/bytes-out. A .odm (ODF master document) never carries its own chapters' content — each text:section is a bare external reference (text:section-source's xlink:href + text:filter-name) to a standalone .odt file, confirmed against real, unmodified LibreOffice 26.2 output while building odf.js's own readOdm: a self-closing text:section-source with no xlink:show/xlink:type, no manifest entry for the linked part, and no chapter text anywhere in the master document's own content.xml. There is consequently no way for odmToPdf to read a chapter's content from the .odm bytes alone — it takes an options.resolveSubDocument callback, called once per section with that section's own href, to hand back the chapter's own .odt bytes. Every section left unresolved (no callback given, or the callback returns undefined for that href) is collected across the whole document before anything throws, and reported together in one OdmUnresolvedSectionError naming every unresolved href — not just whichever section the read loop happened to reach first. odmToPdf is consequently not one of the twelve round-trip conversions or six bridges above, and is deliberately not wired into the DocumentConverter port either: that port's convert(request, options) contract is a fixed single-bytes-in/bytes-out shape, and widening it with a resolver parameter for this one format would leak an odm-specific concern into every other conversion's own request shape — a caller wanting odmToPdf behind the port can wrap it in their own adapter. OdmSection.inlineContent (declared by odf.js's own readOdm for schema-completeness, covering a producer that caches a chapter's content inline rather than only linking it) is handled too, via the same readOdfParagraph/readOdfTable primitives odf.js's own readOdt calls internally — but the installed odf.js 1.10.0 never actually populates it for any real document readOdm was tested against, so this branch is exercised only by a directly-constructed OdmSection in this package's own test suite, not by any .odm fixture.
  • .odb never gets PDF conversion, and never will. A .odb's own Reports are live SQL-backed layouts — rendering one faithfully means actually executing its query against a real database engine, categorically out of scope for a hand-written codec that never runs SQL. readOdbTables/odbToXlsx/odbToCsv extract table data, not the database's own forms/reports/queries (whose names odf.js's readOdbInventory surfaces, but never their content — see odf.js's own implementation notes).
  • All three .odb decoder tiers are implemented: HSQLDB TEXT-script rows (MEMORY/TEXT tables, Tier 1), HSQLDB's own binary CACHED-table row-store rows (Tier 2), and a Firebird-backed embedded database's own gbak logical-backup format (Tier 3, see the dedicated Tier 3 entries below). readOdbTables still detects and names one further shape it does not implement, rather than silently returning wrong or empty tables: HSQLDB's own whole-script BINARY (hsqldb.script_format=1) and COMPRESSED (hsqldb.script_format=3) serialisation — each throws OdbUnsupportedFormatError with a format field naming exactly which. BINARY and COMPRESSED are a single, materially different, larger undertaking from CACHED-table row-store decoding, not a close sibling of it: both are the entire script's own DDL/DML statements re-encoded as a length-prefixed, COMMAND-tagged binary stream (confirmed by generating a real hsqldb.script_format=1 file and reading its own COMMAND/SYSTEM_SCRIPT tokens directly), and hsqldb.script_format=3 is that identical binary stream wrapped in ordinary zlib DEFLATE (RFC 1950, confirmed against a real generated file's own 0x78 0x9c header) — genuinely not a compressed TEXT script, despite an earlier, unverified assumption to the contrary; readOdbTables' own classifyScriptBytes detects the real zlib header rather than gzip's, which a real HSQLDB-produced COMPRESSED file never carries. An external-only connection (no embedded engine at all — MySQL/PostgreSQL/JDBC/ODBC) is a second, permanent scope boundary, not a missing tier: readOdbTables throws OdbNoEmbeddedDataSourceError rather than attempting anything network-facing.
  • The CACHED-table row-store decoder (Tier 2, src/hsqldb/cache.ts/rowformat.ts) is scoped to the specific HSQLDB 1.8.x-branch on-disk layout LibreOffice's embedded driver actually ships, not "any HSQLDB version ever" — the same bounding principle the PDF codec applies to "mainstream producer output" rather than every PDF ever created. There is no ISO/ratified specification for this binary format at all (unlike ODF or OOXML); ground truth is the actual HSQLDB 1.8.0.10 engine source, decompiled from the real hsqldb.jar LibreOffice 26.2 bundles (Specification-Version: 1.8.0.10 in that jar's own META-INF/MANIFEST.MF — the exact engine version LibreOffice's embedded HSQLDB JDBC driver loads), cross-checked against a real database that exact jar produced: created, populated, and checkpointed via java.sql directly against the bundled jar, then read back — as this decoder's own ground-truth oracle — by a second, independent Java program using the identical jar. Every field of every row of all four CACHED tables in the checked-in fixture (src/test-support/odb.ts's embeddedHsqldbCachedOdbBytes) matched that oracle exactly; parseHsqldbProperties throws for a hsqldb.compatible_version outside the 1.7.x/1.8.x family rather than guessing at an unverified layout. A genuine attempt was also made to cross-check the same fixture against actual LibreOffice itself via a headless UNO Basic macro driving its own SDBC API — this task's own strictest verification bar — but headless soffice macro invocation hung indefinitely in this sandbox regardless of profile isolation, macro-security configuration, or a five-minute timeout budget, independently corroborated by a concurrent, unrelated agent's own headless-LibreOffice attempt stalling identically in the same session; the JDBC oracle above is a materially stronger substitute than a fallback of convenience, though, since LibreOffice's own SDBC-to-HSQLDB path is itself a thin wrapper around calling this exact same bundled jar's own JDBC driver methods.
  • The CACHED-table decoder supports only a single-index table (the primary key, or HSQLDB's own internal row-position index for a table with none declared) — a table also carrying an explicit CREATE INDEX or a UNIQUE constraint's own auto-generated index throws rather than mis-decoding. org.hsqldb.Table.getIndexRoots()'s own SET TABLE ... INDEX'...' script line carries one root row-position token per table index, but a UNIQUE constraint's own index has no matching CREATE INDEX DDL statement at all (its name is a Table.getIndexCount()-only trait), so this decoder's indexCount cannot be reliably recovered from the script's own DDL text alone; parseHsqldbIndexRoots throws rather than guessing which root token belongs to which index. The row-store's own AVL tree is walked purely by following each row's persisted child positions, never by comparing key values, so this decoder never needed HSQLDB's own free-block list at all: a deleted row is unlinked from its table's tree before its space is ever added to that list, so a traversal rooted at the tree's current root only ever reaches genuinely live rows.
  • DATE/TIME/TIMESTAMP columns decoded from a CACHED table's binary row store are only correct when read in the same timezone the database was written in — a genuine, inherent limitation of HSQLDB 1.8's own on-disk format, not an implementation shortcut. org.hsqldb.HsqlDateTime resolves every date/time value through a java.util.Calendar carrying no explicit TimeZone (i.e. the writing JVM's own default), and the row store's own encoding is a bare epoch-millisecond long with no timezone or offset recorded anywhere alongside it — confirmed empirically: the checked-in fixture's own DATE values straddle both GMT and BST, and decoding via UTC (rather than local-timezone) Date methods recovers the wrong calendar day for every summer date. src/hsqldb/rowformat.ts reconstructs every DATE/TIME/TIMESTAMP value using the reading process's own local timezone — correct whenever a .odb is read on the same machine/region that created it, the overwhelmingly common case, but not guaranteed for one moved to a host in a materially different timezone. src/hsqldb/cache.test.ts pins process.env.TZ to 'Europe/London' for exactly this reason, matching the fixture's own real generation environment.
  • A BIGINT value decoded from a CACHED table loses precision beyond Number.MAX_SAFE_INTEGER, the same class of limitation every 'number'-kind ContentCellValue in this package already has (DECIMAL/NUMERIC included). ContentCellValue has no arbitrary-precision integer kind to offer instead; readHsqldbColumnValue converts a decoded BIGINT through a bigint and only casts to a JS number at the very end via Number(), matching how src/hsqldb/script.ts's own Tier 1 numeric-literal parsing already stores every SQL numeric value as a plain JS number. A real, documented format-boundary gap, not a silent guess: the checked-in fixture's own BIGINT test values are deliberately kept within the safe range so the test suite demonstrates clean, exact round-tripping rather than exercising this already-understood, orthogonal ceiling.
  • .odb Tier 3 (Firebird) is the single subsystem in this whole package with no ratified spec foundation at all — not ISO 32000-1 (PDF), not the OASIS ODF 1.3 RelaxNG schema, nothing. Firebird's own on-disk page format (ODS) has no public specification; the only ground truth is Firebird's own open-source engine implementation (the firebirdsql/firebird repository) and real fixtures generated and cross-verified by hand. Building this reader surfaced a genuine, load-bearing correction to the design plan it was built against, discovered only by extracting and hex-inspecting a real LibreOffice-generated fixture: a Firebird-embedded .odb's own database/firebird.fbk part is a gbak logical BACKUP stream, not a raw ODS page dump. LibreOffice's embedded-Firebird SDBC driver backs up the live database (via the identical mechanism the standalone gbak command-line tool uses) into the .odb package on save, and restores it into a throwaway temp .fdb file only when a document is actually opened for live editing — confirmed directly from the backup stream's own embedded temp-file path attribute (att_backup_file), which names a .../lu*.tmp/firebird.fdb path under LibreOffice's own temp directory, never the .odb's own location. This means the page-level reader (header page, Page Inventory Page, Pointer Page → Data Page chains, RLE-style record compression, MVCC back-pointer chains) the original design plan called for has no real file to ever operate on — no .odb this reader was tested against, or could plausibly be tested against, ever contains one. src/firebird/ is consequently a gbak-backup-format reader instead, built against the exact same "no ratified spec, read the engine's own source, verify against real fixtures" discipline, just aimed at a different (and, as it turns out, more tractable) real artifact: src/burp/burp.h/backup.epp/restore.epp/canonical.cpp/mvol.cpp and src/common/xdr.cpp/src/common/classes/NoThrowTimeStamp.cpp in the Firebird engine repository, cross-checked line-for-line against real fixture bytes throughout construction (several real off-by-one attribute-index errors and one real high/low-word ordering bug in the initial pass were caught exactly this way, not by inspection alone). One genuine, welcome simplification falls out of this finding for free: because gbak's own backup process ALREADY resolves table/column definitions from the live engine's RDB$RELATIONS/RDB$RELATION_FIELDS/RDB$FIELDS system tables before writing anything, src/firebird/schema.ts never bootstraps those system tables itself — a real rec_relation/rec_field record pair, already fully resolved, is simply there in the stream for every user table.
  • The exact Firebird gbak backup format version this reader targets, and how that was determined: format version 10, per a real fixture's own att_backup_format attribute — burp.h's own version-history comment identifies format 10 as "FB2.5 → FB3.0" output. readFirebirdBackup checks this explicitly and throws FirebirdBackupFormatError naming the actual version found for anything else, rather than guessing at a different version's own attribute/record shape. Two real, LibreOffice 26.2-generated fixtures (src/test-support/firebird.ts) both report this same format version and both set att_backup_compress=true (gbak's own default, not something either fixture-generation session opted into) — a genuine surprise this reader's own construction caught only by testing against real bytes: the naive assumption that a .odb's own embedded backup would be uncompressed was wrong on the very first real file tested, and src/firebird/reader.ts's readCompressedPayload (a signed-run-length/"PackBits"-style codec, restated from backup.epp's own compress/restore.epp's own decompress) exists specifically because of that correction.
  • Two genuine real fixtures back this reader's own tests, both generated via a headless LibreOffice 26.2 UNO automation session and never hand-edited afterward (src/test-support/firebird.ts documents each in full): a richer one built in this task's own session (two tables, varied column types — INTEGER/VARCHAR/DOUBLE PRECISION/DATE/BOOLEAN/DECIMAL/NUMERIC — four and three rows respectively, including deliberate NULLs in every nullable column, an apostrophe-escaped string, and a zero value distinct from NULL), and the ExaDev/odf.js repository's own pre-existing fixture (two empty tables, no row data, a second independently-generated real data point proving the schema-only path). The richer fixture's own construction surfaced a genuine UNO API ordering requirement, not obvious from the API surface alone: getConnection() on a freshly createInstance()'d DatabaseContext entry fails with SQLException: No storage or URL was given unless .DatabaseDocument.storeAsURL() is called FIRST to give the embedded engine real backing storage to connect to — setting .URL alone is not enough.
  • Cross-verified field-by-field against real LibreOffice itself, not merely against this reader's own output. A second headless UNO macro (VerifyFirebirdFixture — see src/test-support/firebird.ts's own doc comment) reopens the saved fixture completely fresh from disk (a genuine new document load, not the same in-memory session that created it), reconnects via getConnection, and runs a real SELECT * FROM <table> ORDER BY <pk> through LibreOffice's own SDBC API — every value it returned matched this reader's own decoded output exactly, row for row, field for field, across both tables.
  • Headless LibreOffice command-line macro dispatch (soffice {file} {macro:///Library.Module.Name}) needed two real, non-obvious environment fixes to run at all in this sandbox, beyond the ones already documented for HSQLDB/odm fixture generation. (1) A prior session's forcefully-killed soffice process leaves macOS's own native "reopen windows after a crash" alert showing on every subsequent launch — invisible in headless/--invisible mode (no window to click), so soffice hangs indefinitely in -[NSAlert runModal] waiting for a response that can never arrive; defaults write org.libreoffice.script ApplePersistenceIgnoreState -bool true (plus removing ~/Library/Saved Application State/org.libreoffice.script.savedState) disables it. (2) soffice "macro:///Library.Module.Name" with no document argument silently does nothing at all — per soffice --help's own usage text, the {file} argument is not optional ({file} {macro:///Library.Module.MacroName}); a session invoking a macro with no real work to do on a document still needs a real (even trivial) file argument for the macro to actually dispatch.
  • A Firebird gbak backup stream's own wire format mixes two genuinely different byte-level encodings, confirmed only by testing against real bytes, not solely from reading the engine's source. Every rec_*/att_* tag-and-attribute structure is little-endian ("VAX order", isc_vax_integer), one length-prefix byte per value; a row's own field-value sequence (once any RLE compression is peeled off) is standard RFC 1832 XDR — big-endian, every value (even a nominally 16-bit SSHORT) widened to a 4-byte-aligned unit, opaque byte runs zero-padded to the next 4-byte boundary. A genuine 64-bit-integer word-order bug (high 32 bits transmitted first, not low-first as xdr_hyper's own in-memory temp_long array layout suggests on first reading) was caught exactly this way: a DECIMAL(10,2) column decoded to a nonsense value on the first real-fixture test run, not from a source-reading mistake that was obvious in advance.
  • STIX Two Math (the embedded formula font) is a CFF-flavoured OpenType font (an OTTO sfnt wrapping a CFF table), not TrueType/glyf — confirmed by inspecting the vendored font's own sfnt table directory while src/pdf/math-font.ts was built; the design plan this feature was built against assumed glyf and asked to confirm which and handle accordingly. Genuine Type2-charstring glyph subsetting (re-encoding charstrings, rebuilding the CFF INDEX structures with a renumbered, minimal glyph set) is a substantially larger undertaking than TrueType glyf/loca subsetting, and is out of scope for this pass: the entire CFF table is embedded verbatim, unmodified, as a single /FontFile3 /Subtype /CIDFontType0C stream — a real, correct, working embedded font, just not glyph-subsetted. Everything else genuinely IS built from a targeted parse of only what's used: cmap resolves exactly the Unicode code points a document's formulas actually reference to glyph IDs, and the emitted /W widths array and ToUnicode CMap only ever cover those same glyph IDs, not the font's full ~5,500-glyph repertoire. A CID-keyed composite font built this way needs no /CIDToGIDMap at all (that key exists only for /CIDFontType2): per ISO 32000-1 9.7.4.2, a /CIDFontType0 whose /FontFile3 is a "bare" (non-CID-keyed) CFF program is read with CID treated as directly indexing the CFF's own CharStrings INDEX by glyph order — i.e. CID == GID — exactly the numbering cmap-derived glyph IDs already use, so Identity-H text-showing needs no further remapping anywhere in the write path.
  • The OpenType MATH table's MathVariants subtable (stretchy glyph assembly — building a tall parenthesis or brace from reusable top/middle/bottom/extender pieces) is deliberately not parsed. A stretchy fence ((/)/[/]/{/}/|/) or a stretchy <mo> wrapping a tall construct (a large fraction, a tall matrix) always renders at its own base glyph's fixed size, not dynamically resized to its content's own height — a documented, honest fallback the task's own design plan explicitly allowed for when the full variants subtable proves too large a sub-scope. The MathConstants subtable (every fraction/radical/script-positioning constant this package's own layout engine actually uses) and the MathGlyphInfo subtable (italics correction, top-accent attachment) ARE both genuinely parsed in full — see src/pdf/math-table.ts.
  • A token element's (mi/mn/mo/mtext) own box height comes from the font's nominal design ascent/descent (hhea's own ascender/descender), not a tight per-glyph ink bounding box. src/mathml/ never parses glyph outlines (no glyf/CFF charstring geometry extraction anywhere in this package — see the CFF-embedding gotcha above), so every token run shares one uniform vertical extent regardless of which characters it actually contains. Accurate enough for box-model layout (spacing, baseline alignment, page placement) but not pixel-tight around an unusually tall or shallow glyph.
  • The MathML operator dictionary (src/mathml/operators.ts) is a deliberately bounded ~60-entry table, not the MathML3 specification's own multi-thousand-entry, form-dependent (prefix/infix/postfix) one. It covers arithmetic, relational, set/logic, calculus big-operators, fences, and punctuation — the operators real formulas overwhelmingly use — with one entry per character regardless of which position it appears in, falling back to a single sane infix-shaped default (thick-space spacing, no stretch/largeop/movablelimits) for anything else.
  • mover/munder/munderover centre an over/under-script geometrically over the wider of the two boxes, not at the base glyph's own font-declared accent-attachment point (MathTopAccentAttachment, which the embedded font's MathGlyphInfo subtable DOES carry and this package DOES parse — see the CFF-embedding gotcha above — just not consumed here). Visually correct for the common case of a single-character base (geometric centre ≈ optical centre for a roughly symmetric glyph); measurably different only for a multi-character or asymmetric base under a genuine accent="true" mark. A real, bounded simplification, not a data gap — the metric this would need is already being parsed for a different purpose.
  • Greek mathvariant mapping covers the plain alphabet plus nabla (∇) and partial differential (∂), not the OpenType/Unicode Greek "symbol variant" set (epsilon/theta/kappa/phi/rho/pi symbol glyphs — ϵ/ϑ/ϰ/ϕ/ϱ/ϖ styled to e.g. bold). Latin letters, digits, and the two named symbols above are fully covered, generated directly from Unicode's own UnicodeData.txt (see src/mathml/variant.ts's own generation note) rather than transcribed by hand.
  • Embedded-formula detection inside odt/odp is genuinely new work with no odf.js-side equivalent (readDrawFrameContent doesn't recognise a draw:object-bearing draw:frame at all yet — see the src/odf/ architecture entry above), and each format's own detection carries its own real, bounded scope narrowing. For odt (src/odf/odt/read.ts): only a draw:frame that is a direct child of office:text is detected — a formula anchored inline inside a paragraph's own run content, or nested inside a draw:g group, is not. Detected formulas are appended to the end of the section's own blocks array, in the order their frames appear in the document, not interleaved at their true original position among the paragraphs/tables odf.js's own reader already produced — true positional interleaving would need per-element block-count bookkeeping this adapter doesn't have (a text:list, for instance, unwraps into many ContentParagraph blocks from one raw XML element, so "one raw child = one block" doesn't hold in general). For odp (src/odf/odp/read.ts): only a top-level draw:frame on a draw:page with no draw:g sibling at all is detected (a slide containing any group is skipped entirely for formula detection, to avoid mismatching a formula onto the wrong shape) — but where it IS detected, position is exact, not appended: odf.js's own walkDrawShapes already produces exactly one ContentShape per top-level draw:frame in document order, so the Nth frame maps precisely onto shapes[N]. ods embedded-formula detection is not implemented at allodf.js's readOds has no existing floating-drawing/anchor-resolution mechanism (ContentSheetImage/ContentSheet.embeddedObjects are both already-known, pre-existing unpopulated gaps this task does not newly create — see the ContentSheetCellSchema gotcha above for the sibling gap on the write side), so there is no readDrawFrame-equivalent entry point to hook a formula-frame scan onto the way odt/odp have; src/layout/sheets.ts accordingly has no formula-handling branch at all, with a comment marking why.
  • A formula that isn't rendered as real MathML (an odm chapter's own embedded formula, or any formula crossing the odtToDocx/docxToOdt/odpToPptx/pptxToOdp bridges) survives only as its own plain-text placeholder — the formula's StarMath annotation if it had one, or the literal [formula] otherwise (see src/odf/formula/placeholder.ts). odmToPdf's own per-chapter readOdtContent call discards that chapter's own formulas map entirely (re-keying every formula's sourcePath against the final combined document's own renumbered block indices is a materially larger undertaking than this task's own scope, and .odm has no confirmed real-world test fixture to validate it against regardless — see the odmToPdf gotcha below). buildDocxPackage has no MathML-writing path of its own (OOXML's own math markup, OMML, is a different vocabulary this package does not write), so the six cross-format bridges never consult a formula's real MathML either, even when bridging between two formats that both, individually, support real formula rendering elsewhere in this package.
  • sourcePath traces a LayoutItem back to the ContentDocument node it came from, but only within one read+layout pass. ooxml.js's readDocx/readPptx stamp every ContentRun/ContentImageBlock/ContentTable/ContentShape with a positional path (sections[0].blocks[2].runs[1], slides[1].shapes[3].blocks[0]); convertWordprocessingToLayout/convertPresentationToLayout copy that same string onto whichever LayoutText/LayoutImage/LayoutLink/LayoutRect item(s) it produces, so a positioned PDF-side item can be traced back to its semantic origin. When line-wrapping splits one run's word across a run boundary, every resulting fragment gets its own run's path (not a shared or merged one); when a single run is emergency-split across several lines or pages, every resulting fragment keeps that same one run's path unchanged. A table cell's background LayoutRect is attributed to its containing table's own sourcePath, since ContentTableCell carries none of its own. This is not an edit-tracking or incremental-relayout mechanism — the path is only valid against the exact ContentDocument/Package it was assigned from in that one read; editing the document, re-reading it, or reordering its blocks invalidates every previously-captured path, and nothing here recomputes or diffs paths across two versions of a document.

Fidelity

docx/pptx/odt/odp/ods/odg → PDF is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, basedOn chains, placeholder inheritance for docx/pptx; style:default-style/style:parent-style-name chains for odt/odp). odg renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF m/l/c/h content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful visual approximation, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — see the standard-14 font substitution gotcha above.

odf → PDF (odfToPdf), and a formula embedded inside odt/odp, render faithful mathematical typesetting, not a static image or a plain-text placeholder: real box-model layout (script/limit positioning, fraction/radical geometry with correct rule thickness, table column alignment, mathvariant → Mathematical Alphanumeric Symbols mapping) through the embedded STIX Two Math font, with genuine per-glyph metrics (advance width, italic correction, top-accent attachment) and font-wide layout constants (axis height, fraction/radical rule thickness and gaps, script shift amounts) parsed directly from that font's own MATH table — not approximated or hand-tuned. The honest limits: stretchy delimiters render at a fixed size rather than dynamically assembling to their content's own height (the MathVariants subtable isn't parsed), a token's own box height comes from the font's nominal ascent/descent rather than a tight per-glyph ink bound, mover/munder centre geometrically rather than at the font's own declared accent-attachment point, and the operator dictionary and Greek mathvariant mapping each cover a deliberately bounded, common-case set rather than the full specification — see the Gotchas entries above for the exact boundary of each. pdfToOdf (PDF → structured MathML) is not attempted, on either direction — recovering a semantic operator tree (is this pair of glyphs a fraction, or a coincidentally stacked pair of ordinary characters? is a raised glyph a superscript, or just a smaller font size used for emphasis?) from nothing but positioned glyphs and paths is a categorically different, OCR-adjacent problem, with no geometry-reconstruction analogue anywhere else in this package: reconstructWordprocessing/reconstructPresentation recover paragraph/shape structure from geometry, never semantic meaning the way recognising a fraction would require.

PDF → docx/pptx/odt/odp is necessarily a best-effort reconstruction from geometry: a PDF page is just positioned glyphs and images, with no semantic paragraph or shape structure to recover. Reading order, bold/italic/colour/font-size, and page/slide count are preserved; paragraph and text-block boundaries are inferred from baseline spacing and left-margin indentation, not recovered exactly.

PDF → odg (reconstructDrawing) is a best-effort reconstruction too, but for the opposite reason: not because a drawing's structure is hard to infer, but because a drawing has no semantic structure to infer at all, so there is no clustering step to get right or wrong in the first place. Every recovered LayoutItem maps close to 1:1 onto a ContentVector/ContentShape, in the exact order it was painted. What is genuinely lossy is upstream of reconstructDrawing, in what a PDF's own content-stream operators can even preserve: position, size, and fill/stroke colour survive within ordinary floating-point tolerance regardless of vector kind, but a filled-and-stroked rect, any ellipse, and any line each come back as a generic path vector rather than their original kind, since PDF has no native rect/ellipse/line primitive beyond one narrow fast-path case — see the reconstructDrawing gotcha above for the exact boundary. The one place this genuinely reorganises content rather than just approximating it: a single wrapped multi-line text box comes back as several separate single-line text boxes, one per line PDF's own line-wrapper produced, since reconstructDrawing maps one LayoutText item to one shape with no clustering — the text survives, its original grouping into one box does not. Verified against real LibreOffice 26.2, not merely against this package's own reader: a richly-varied .odg (overlapping rects, a filled-and-stroked ellipse, a stroked line, a filled-and-stroked Bezier curve, a wrapped text label) round-tripped through odgToPdf then pdfToOdg opens as a valid drawing with correct position, colour, and z-order throughout, the curve genuinely curved rather than polygon-approximated, and only the vector-kind-narrowing and text-splitting above visibly distinguishing it from the source.

PDF → ods (pdfToOds, reconstructSpreadsheet) recovers what was printed, not what was entered. This is a harder, categorically different limit than any other reconstruction direction above, not merely a looser version of the same one: docx/pptx/odp/odg reconstruction can at least recover real text, formatting, and (for odg) exact vector geometry from a PDF's own positioned glyphs and paths. A spreadsheet cell's own value — a formula, a real typed number, a date serial, a currency code — never exists anywhere in a rendered PDF at all; a PDF only ever carries the rendered string Calc or Excel chose to print for that cell. reconstructSpreadsheet is honest about this rather than pretending otherwise: every recovered cell is a bare { kind: 'string', value: displayText }, never re-parsed back into a number/date/boolean, and never claimed as a formula, even when the recovered text looks numeric or date-shaped ("42.5", "2024-01-15") — see the pdfToOds gotcha above for the full detection algorithm (a real gridline lattice used directly as cell boundaries when a printed sheet had gridlines enabled, text-position clustering otherwise). Column widths, row heights, and page size are genuinely measured from whichever geometry was used; no print range, scale, fit-to-page, repeat-rows/columns, or manual breaks are ever inferred, since a rendered page carries no trace of print intent, only what was visually printed. Verified against real LibreOffice 26.2, not merely against this package's own reader: a genuine, gridline-and-headers-enabled four-column, four-row employee-record .ods (mixed string/date/boolean-looking cell content) round-tripped through odsToPdf then pdfToOds opens as a valid spreadsheet with every cell's text recovered in its correct row/column position, via the gridline-lattice path specifically (confirmed by inspecting the recovered printSettings.gridlines), and every recovered cell an honest string.

Neither direction is round-trip-lossless, and no conversion is the exact inverse of its own reverse direction — pdfToDocx(docxToPdf(x)) will not reproduce x exactly, and neither will pdfToOdg(odgToPdf(x)) or pdfToOds(odsToPdf(x)); neither is intended to. This is a deliberate, permanent contrast with ooxml.js's own packageCodec, which genuinely is a lossless round trip. docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec/pdfCodec share packageCodec's mechanism (z.codec(), schema-validated both ways) but not its guarantee — wrapping a lossy conversion in z.codec() validates the shape of what comes out, not its fidelity to what went in.

The six cross-format bridges (odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds) are a categorically different case from every conversion above: they bypass the PDF pivot entirely, so the "not round-trip-lossless" caveat that applies to every PDF-pivot conversion in this section does not carry over to them. There is no layout engine (no flow, no line-wrapping, no pagination) and no geometry-based reconstruction (no baseline clustering, no gridline-lattice detection) anywhere in a bridge's own call path — each is nothing more than buildYPackage(readXContent(decodePackage(bytes))), composing the identical reader/builder pair the PDF-pivot conversions on either side of the bridge already use, because both formats in each pair read into and build from the exact same ContentDocument variant. Concretely, for odt ⇄ docx and odp ⇄ pptx: text, run styling (bold/italic/underline/colour/font/size), paragraph styleId, list membership and nesting level, table structure and cell content, and (for odp ⇄ pptx) speaker notes all survive completely — proven by src/convert/bridges.test.ts's own dedicated round-trip suite, exercised in both directions from both starting formats, and cross-checked by opening genuinely LibreOffice-produced source files and their bridged output in real LibreOffice (see that test file and this repo's own verification notes). The one confirmed gap is a table shape nested inside an odp/pptx slide shape, not the document/presentation structure itself — see the gotcha above. ods ⇄ xlsx preserves cell values, formulas (verbatim), and merged ranges completely, and column widths within a roughly one-pixel rounding tolerance on the odsToXlsx hop — but, being built on ooxml.js's brand-new readXlsxContent/buildXlsxPackage, carries several real, honestly-documented format-boundary limits of its own (percentage/currency downgrading to a plain number, time collapsing into date, xlsx column widths not surviving the xlsxToOds return hop, and a formula written in one dialect showing as a genuine formula error in a REAL spreadsheet application expecting the other) — see the ods ⇄ xlsx gotcha above for the full, specific list. None of this is layout drift or reconstruction guesswork; every gap listed is a genuine format-boundary limit (a cell type, a value kind, or a write-side omission that exists independently of this bridge), not an approximation introduced by the bridge itself.

.odb table extraction (readOdbTables, all three tiers) is a genuine, verified data extraction, not an approximation — but it recovers only what a .odb's own embedded database storage actually carries, which differs by tier. Tier 1 (HSQLDB TEXT script) parses real DDL/DML text, so a table's own declared column types survive as the literal SQL clause they were declared with, and row values are the literal INSERT statement literals. Tier 2 (HSQLDB CACHED-table binary row store) shares Tier 1's own DDL-derived column types — a CACHED table's DDL still lives in database/script as ordinary TEXT — but decodes its actual row values from a separate binary page-cache file, database/data, cross-verified field-by-field against a real HSQLDB JDBC oracle on the identical fixture (see the Gotchas entry above). Tier 3 (Firebird) decodes a real gbak backup stream — every cell value, NULL, and column name is genuinely read from the file, cross-verified field-by-field against real LibreOffice's own SDBC query on the identical fixture (see the Gotchas entry above for the full verification transcript) — but a column's own HsqldbColumn.type label is synthesised from the field's binary metadata (BLR type + length + scale), not lifted from source SQL text the way Tier 1/2's is, since a gbak backup carries no DDL text at all. No tier recovers a database's own forms, reports, or queries (names only, never content — see the gotcha above), and none has a reverse (xlsx/CSV → .odb) direction. Tier 3 additionally has three real, bounded, honestly-scoped gaps, all documented in code comments at the exact spot each applies: no BLOB column content (the blob's own reference is consumed for stream alignment, but its column value is always recorded empty); no FB4+-only types (INT128/DECFLOAT, i.e. a NUMERIC/DECIMAL column wider than 18 digits of precision — this reader's own real fixtures are Firebird 3.0-era output, which has no such types to begin with); and a blob-VALUED metadata attribute (a relation/field/index/trigger's own description, default value, or BLR body) uses a different, compound wire encoding this reader's generic attribute-skip does not yet handle — never encountered by either real fixture this reader was verified against, but a real gap on a .odb whose tables carry comments or computed columns.

Optional real-world corpus. test/corpus/ (gitignored, never committed) holds a pnpm test:corpus vitest project for manual conformance checking against real PDFs a hand-built fixture can't fully stand in for — a Word "Save as PDF", a PowerPoint "Save as PDF", a Chrome "Print to PDF", a LibreOffice export. It is not part of pnpm test and does not gate CI; drop files in locally before a significant parser change.

Release and publishing

.github/workflows/ci.yml runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to main where those all pass, release.config.ts drives semantic-release: commit history since the last tag decides the version bump, CHANGELOG.md and package.json are committed back to main, a GitHub Release is cut, and the package publishes to npmjs.org — via npm's OIDC trusted publishing, so no NPM_TOKEN exists anywhere in the pipeline.

Whether that release actually published a new version is detected by diffing package.json's version before and after the release step, not by trusting a third-party action's own detection. Two further jobs gate on that: one republishes the same build under the scoped @exadev/documents.js alias to GitHub Packages (which has no OIDC exchange of its own, so it authenticates with GITHUB_TOKEN instead), and one packs the release into its own directory, generates an SPDX SBOM (pnpm sbom), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.

Contributing

Commits follow Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint (commitlint.config.ts) via a husky commit-msg hook and a CI commitlint job — semantic-release's version bump depends on these being well-formed, not just style. A husky pre-commit hook runs lint-staged (eslint --fix on staged *.ts files) and pre-push runs the test suite. There is a single main branch and no open pull request workflow established so far.

References

  • ooxml.js — the sibling package this depends on for all docx/pptx/xlsx ⇄ JSON handling and cascade-resolved typed reading, including its own readXlsxContent/buildXlsxPackage (a ContentDocument-shaped xlsx reader/writer pair), consumed directly by src/convert/convert.ts's odsToXlsx/xlsxToOds bridge but not re-exported from this package's own public surface.
  • document-content-model — the sibling package that owns ContentDocument/LayoutDocument themselves; both ooxml.js and documents.js import from it rather than each maintaining an independent copy.
  • odf.js — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on document-content-model. A dependency of documents.js for: this package's Odt/Ods/Odp/OdgBytesSchema (src/model/bytes.ts), which validate against its ODF_MEDIA_TYPES table; src/interop.test.ts, a type-level guard that ooxml.js's and odf.js's raw XmlElement/XmlNode/Attribute/Package container types stay structurally compatible; src/odf/odt/read.ts's readOdtContent, a thin adapter over odf.js's own readOdt, feeding odtToPdf/pdfToOdt (src/convert/convert.ts); src/odf/odp/read.ts's readOdpContent, the same adapter over odf.js's readOdp, feeding odpToPdf/pdfToOdp; src/odf/ods/read.ts's readOdsContent, the same adapter over odf.js's readOds, feeding odsToPdf/pdfToOds, and reused directly by src/edit/ods/print-settings.ts's own readSheetPrintSettings (findStyleElement/resolvePageLayoutProperties/parsePageSize/parseMargins, the same style-chain-resolution primitives readOds's own readPrintSettings is built on); src/odf/odg/read.ts's readOdgContent, the same adapter over odf.js's readOdg — including its own typed/shared/path.ts, the real-LibreOffice-output-verified svg:d/draw:points parser this package's writePath content is ultimately sourced from, and which src/edit/odg/svg-path.ts's buildSvgPathData (the write-side inverse) also cross-checks its own output against directly — feeding odgToPdf/pdfToOdg (the latter re-reading a rebuilt package's own real geometry through this same readOdg, not just writing one); src/odf/formula/read.ts's readOdfFormulaContent/readOdfEmbeddedFormula, thin adapters over odf.js's own readOdfFormula, feeding odfToPdf and the odt/odp embedded-formula paths respectively; src/edit/odt/*'s StyleRegistry/resolveStyle (style interning), src/edit/odp/shape.ts's applyOdfTransform/resolveOdfShapeGeometry (rotation), and src/edit/odt/automatic-styles.ts's ensureAutomaticStyles/nextStyleName (reused by src/edit/odg/style.ts's own graphic-family style writer and src/edit/ods/print-settings.ts's own page-layout/master-page/table-style minting), all consumed directly rather than reimplemented. odt, odp, ods, and odg → ContentDocument reading and PDF conversion are now all integrated both ways.
  • STIX Two Math — the embedded math font src/pdf/math-font.ts parses and odfToPdf (and the odt/odp embedded-formula paths) render through, vendored at assets/fonts/STIXTwoMath-Regular.otf and embedded into dist/ as a base64 string (src/mathml/assets/stix-two-math-font.ts, generated by scripts/generate-math-font-asset.mjs) rather than read from disk at runtime. Copyright 2001-2021 The STIX Fonts Project Authors, licensed OFL-1.1 — see assets/fonts/NOTICE.md for the exact source commit and version this was vendored from.
  • firebirdsql/firebird — the ground truth src/firebird/ is built against, since Firebird's own gbak backup format has no ratified public specification: src/burp/burp.h (the rec_type/att_type enumerations and their own per-block numbering, and the backup-format version history), src/burp/backup.epp/restore.epp (the write/read reference implementation src/firebird/reader.ts's attribute framing and RLE decompression are restated from), src/burp/canonical.cpp (the per-SQL-type XDR shape a row's own field values use), src/burp/mvol.cpp (the backup-header attributes and their own presence-means-true encoding), src/common/xdr.cpp (the underlying big-endian XDR primitive encodings, including the xdr_hyper high-word-first ordering this reader's own construction initially got backwards), src/jrd/align.h/src/include/firebird/impl/blr.h (the BLR-type-opcode-to-physical-storage-type mapping), and src/common/classes/NoThrowTimeStamp.cpp (the DATE/TIME encoding algorithms). Not a dependency of this package at build or runtime — read and cited as source material only, per commit state at the time src/firebird/ was built.

License

MIT

About

Bidirectional docx/pptx <-> PDF conversion, a read-and-write live-view editor, and a hand-written PDF codec, built on ooxml.js.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages