Converts between any two compatible document formats through a shared content/layout pivot — docx, pptx, odt, odp, ods, odg, xlsx, and markdown all read into and build from the same
ContentDocument/LayoutDocumentmodel, with PDF simply the one format every variant can reach (docx/pptx/odt/odp/ods/odg/xlsx/markdown ⇄ PDF, fourteen pairs, all round-tripping both ways), plus ten further cross-format bridges, five pairs (odt⇄docx, odp⇄pptx, ods⇄xlsx, markdown⇄docx, markdown⇄odt) that bypass PDF entirely for pairs already sharing a pivot variant directly. Also included: a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents,.odb(ODF database front-end) table extraction to xlsx/CSV from an embedded HSQLDB TEXT script (Tier 1), HSQLDB's own binary CACHED-table row-store format (Tier 2), and an embedded Firebird database's own gbak logical-backup format (Tier 3), plus Form/Report structure reading (bound controls, bands/groups/functions), a bounded single-table SQLSELECTengine that runs a.odb's own saved queries over that extracted data, a Report Builder rpt formula engine that evaluates a report's group breaks and footer totals over the result, and a structural report renderer that turns the printed bands into a realContentDocument, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, docx comment/footnote/header-footer/numbering-definition exposure viareadDocxExtras, real font resolution for ordinary text (a source document's own embedded faces extracted and rendered through, ahead of caller-supplied faces, metric-compatible vendored substitutes, and finally the standard 14), a hand-written MathML presentation-layer typesetting engine with embedded-font PDF rendering (odf → PDF, plus formulas embedded inside odt/odp) and a matching MathML → OMML translator so an embedded formula reaches a docx as real, editable Word math, and a fully hand-written PDF codec, built on ooxml.js, odf.js, and markdown-codec.
documents.js depends on ooxml.js for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions ooxml.js deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-and-write manipulation API for docx/pptx content — ooxml.js's own typed readers (readDocx/readPptx) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are provided by pdf-codec, a sibling package extracted from this one: a hand-written, dependency-minimal PDF codec with no external PDF library (pdf-lib, pdfjs-dist, mupdf, or any other) as a dependency — see pdf-codec's own README for how it's built and what it embeds (including the vendored STIX Two Math font this package renders formulas through). src/mathml/ (the MathML typesetting engine) stays in this package and is hand-written too, for the same "no supply-chain surface beyond what's already declared" reason, but consumes pdf-codec's embedded math font through a structurally-typed port rather than any font-parsing code of its own — see Architecture. CommonMark+GFM markdown reading/writing is provided by markdown-codec, the same "hand-write the format instead of wrapping a third-party library" bet applied to markdown: no micromark/remark/marked/markdown-it/commonmark/mdast/unified/turndown/showdown dependency anywhere in that package.
graph TD
schema("document-schema.js")
ooxml("ooxml.js")
odf("odf.js")
pdfcodec("pdf-codec")
mdcodec("markdown-codec")
documents("documents.js")
cli("document-cli")
schema --> ooxml
schema --> odf
schema --> pdfcodec
schema --> mdcodec
schema --> documents
ooxml --> documents
odf --> documents
pdfcodec --> documents
mdcodec --> documents
documents --> cli
odf --> cli
click schema "https://github.com/ExaDev/document-schema.js" "document-schema.js"
click ooxml "https://github.com/ExaDev/ooxml.js" "ooxml.js"
click odf "https://github.com/ExaDev/odf.js" "odf.js"
click pdfcodec "https://github.com/ExaDev/pdf-codec" "pdf-codec"
click mdcodec "https://github.com/ExaDev/markdown-codec" "markdown-codec"
click documents "https://github.com/ExaDev/documents.js" "documents.js"
click cli "https://github.com/ExaDev/document-cli" "document-cli"
style documents fill:#f9a825,stroke:#333,stroke-width:3px
Converting docx/pptx to PDF and back is usually solved by wrapping a mature third-party PDF library. This package takes the opposite approach for the PDF side of the equation: pdf-codec hand-writes every layer of the PDF format — the object model, the cross-reference table, the content-stream operators, standard-font metrics, the parser's cross-reference/object-stream resolution and content-stream interpreter — against the ISO 32000-1 specification, rather than wrapping one. That is a genuinely large undertaking, and it comes with an honest trade-off spelled out in Fidelity below and in pdf-codec's own README: this is not, and does not attempt to be, as robust against adversarial or badly malformed real-world PDFs as a library with 15+ years of hardening. What it buys instead is a dependency-free, fully auditable PDF implementation, with documents.js's own supply-chain surface staying limited to ooxml.js, odf.js, document-schema.js, pdf-codec, markdown-codec, and fflate.
The read-and-write editor exists because ooxml.js's own typed readers are a deliberate one-way, lossy projection — reading is fine, but there is no way to add a paragraph, style a run, or insert an image and get a valid docx/pptx back out. documents.js's editors are live views directly over the XmlElement objects inside a decoded Package: a mutation edits that tree in place, and everything you don't touch round-trips byte-faithful, because it never stopped being the original XML.
Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).
pnpm installInstall as a dependency in another project:
pnpm add documents.js
# or
npm install documents.jsThe twelve round-trip ergonomic conversions between the six formats with their own layout engine and PDF (docx/pptx/odt/odp/ods/odg ⇄ PDF, all round-trip both ways), plus a thirteenth pair with the identical ergonomic shape and options — xlsxToPdf/pdfToXlsx, which composes the ods⇄xlsx bridge with the ods⇄pdf layout pair internally, since xlsx has no layout engine of its own — and a fourteenth, markdownToPdf/pdfToMarkdown, which DOES lay markdown out directly (it reuses the identical wordprocessing layout engine docx/odt already share):
import { docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToXlsx, pptxToPdf, xlsxToPdf } from 'documents.js';
const pdfBytes = docxToPdf(docxBytes);
const docxBytes2 = pdfToDocx(pdfBytes);
const pdfFromSlides = pptxToPdf(pptxBytes);
const pptxBytes2 = pdfToPptx(pdfFromSlides);
const pdfFromOdt = odtToPdf(odtBytes);
const odtBytes2 = pdfToOdt(pdfFromOdt);
const pdfFromOdp = odpToPdf(odpBytes);
const odpBytes2 = pdfToOdp(pdfFromOdp);
const pdfFromOdg = odgToPdf(odgBytes);
const odgBytes2 = pdfToOdg(pdfFromOdg);
const pdfFromOds = odsToPdf(odsBytes);
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed, then heuristically re-types it -- see Fidelity
const pdfFromXlsx = xlsxToPdf(xlsxBytes); // composes xlsxToOds -> odsToPdf internally -- still a real, direct, single-call conversion
const xlsxBytes2 = pdfToXlsx(pdfFromXlsx); // composes pdfToOds -> odsToXlsx internally
const pdfFromMarkdown = markdownToPdf(markdownBytes);
const markdownBytes2 = pdfToMarkdown(pdfFromMarkdown); // the lossiest conversion in the whole package -- see FidelityEach accepts an optional signal (AbortSignal) and either a onSubstitution callback (docx/pptx/odt/odp/ods/odg/xlsx/markdown → PDF, called once per character not representable in a standard-14 font) or a sink (PDF → docx/pptx/odt/odp/ods/odg/xlsx/markdown, called once per recoverable parse diagnostic).
Every X → PDF conversion additionally accepts fonts (extra ProvidedFont faces to make available) and onFontSubstitution (called once per requested family+weight+style that resolved to something else). Neither is needed for the common case: the conversion already extracts the source document's own embedded fonts and renders through them, so a docx or odt saved with font embedding turned on comes out in its real typeface at its real metrics with no caller involvement at all — see Fonts below for the full resolution order.
Ten further conversions, five pairs, bypass PDF entirely: odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds, and markdownToDocx/docxToMarkdown, markdownToOdt/odtToMarkdown each compose a direct readXContent → buildYPackage pivot copy, since both sides of each pair already read into and build from the identical ContentDocument variant — no layout engine, no font measurement, and no geometry-based reconstruction in between. See Fidelity for what that means in practice, and for markdown specifically, why "no layout/reconstruction lossiness" is not the same claim as "no lossiness at all".
import { odtToDocx, docxToOdt, markdownToDocx, docxToMarkdown } from 'documents.js';
const docxBytes = odtToDocx(odtBytes);
const odtBytes2 = docxToOdt(docxBytes);
const docxFromMarkdown = markdownToDocx(markdownBytes);
const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct -- dropped on this hop, not merely approximatedEach takes an optional { signal } — there is no onSubstitution/sink option here, since there is no font substitution or PDF-parse degradation to report; a wrong-kind ContentDocument throws outright rather than becoming a diagnostic. odtToDocx/markdownToDocx/docxToOdt/docxToMarkdown additionally take onMathDiagnostic, called once per formula construct that degraded or was approximated as an embedded formula crossed the bridge — into OOXML math when building a docx, back out of it when reading one (see Architecture's src/omml/ entry). Either way it reports only what the target vocabulary genuinely has no counterpart for, never the whole formula. docxToPdf takes it too, for the read direction.
The same conversions behind a swappable port, for a caller that wants to inject a different implementation later without changing call sites:
import { createLocalDocumentConverter } from 'documents.js';
const converter = createLocalDocumentConverter();
const { document, diagnostics } = await converter.convert(
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
{ signal: new AbortController().signal },
);DocumentFormat includes xlsx and markdown alongside docx/pptx/odt/odp/ods/odg/pdf — xlsx because createLocalDocumentConverter's { source, targetFormat } contract already generalises past "targetFormat always means pdf" (xlsx has no PDF conversion of its own; markdown genuinely does, see markdownToPdf/pdfToMarkdown above). odt→docx, docx→odt, odp→pptx, pptx→odp, ods→xlsx, xlsx→ods, markdown→docx, docx→markdown, markdown→odt, and odt→markdown are ten further entries in the same conversions list, routed to the ten bridge functions above with an empty diagnostics array.
Getting back the intermediate DocumentPackage (content + layout, from document-schema.js) a conversion built internally, instead of only the target bytes — every ergonomic conversion function above accepts an onDocument callback for this, and the port surfaces the same value as package on its ConversionResult:
import { docxToPdf } from 'documents.js';
const pdfBytes = docxToPdf(docxBytes, {
onDocument: (pkg) => {
console.log(pkg.content.kind); // 'wordprocessing'
console.log(pkg.layout?.pages.length); // populated for every X-to-PDF/PDF-to-X conversion
},
});
// or via the port:
const { document, package: pkg } = await converter.convert(
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
{ signal: new AbortController().signal },
);For the ten PDF-bypassing bridges, pkg.layout is always undefined — a bridge never runs a layout engine, so there is nothing to populate it with; running one purely to fill this field would be wasted work no caller asked for.
Turning that DocumentPackage into self-describing JSON — re-exported from document-schema.js, which owns the pivot schemas and the published .schema.json files (see that package's own README) — via documentPackageWithSchema, which stamps a $schema property pointing at the matching schema file for the currently installed document-schema.js version, and reading one back via documentFromJson, which uses that same $schema property to work out which of DocumentPackage/ContentDocument/LayoutDocument a value is before validating it:
import { documentFromJson, documentPackageWithSchema } from 'documents.js';
const tagged = documentPackageWithSchema(pkg);
writeFileSync('converted.doc.json', JSON.stringify(tagged, null, 2));
const { kind, value } = documentFromJson(JSON.parse(readFileSync('converted.doc.json', 'utf8')));
// kind: 'DocumentPackage' (here) | 'ContentDocument' | 'LayoutDocument'contentDocumentWithSchema/layoutDocumentWithSchema are the ContentDocument/LayoutDocument equivalents — these operate on the identical ContentDocument/ContentDocumentSchema this package imports and re-exports from document-schema.js above (a discriminated union of wordprocessing/presentation/spreadsheet/drawing variants wrapping ContentSection/ContentSlide/ContentSheet/ContentDrawPage), so no separate import or conversion step is needed to construct one for contentDocumentWithSchema.
Reading and editing docx/pptx content directly, without going through PDF at all:
import { openDocx, createDocx } from 'documents.js';
const editor = openDocx(existingDocxBytes);
const paragraph = editor.body.appendParagraph({ alignment: 'center' });
const run = paragraph.appendRun({ text: 'Hello' });
run.bold = true;
run.color = { r: 1, g: 0, b: 0 };
const bytes = editor.toBytes();
// or start from nothing:
const fresh = createDocx();
fresh.body.appendParagraph().appendRun({ text: 'New document' });A docx's own comments, footnotes, headers/footers, and numbering (w:abstractNum/w:num) definitions never fit ContentDocument's section/block shape, so readDocxContent never carried them — readDocxExtras is a second, independent read of the same package that returns exactly that data as its own real type, for a caller that wants it without reaching for ooxml.js's own readDocx directly:
import { readDocxExtras } from 'documents.js';
import { decodePackage } from 'ooxml.js';
const { comments, footnotes, headers, footers, numbering } = readDocxExtras(decodePackage(docxBytes));
console.log(comments[0]?.author, comments[0]?.text, footnotes[0]?.text, headers[0], footers[0]);
console.log(Object.values(numbering)[0]?.levels['0']?.format); // numbering is keyed by numId, each level by its own level indexopenPptx/createPptx and PptxSlide/PptxShape are the pptx equivalent (slide.addTextBox, slide.addImage, shape.setParagraphs for multi-paragraph styled text).
openOdt/createOdt and OdtParagraph/OdtRun/OdtTable/OdtList are the odt equivalent, built on ODF's own style-name-referencing model (run.bold = true interns or reuses a named style:style in office:automatic-styles, rather than writing an inline attribute — see Conventions below). A list item reads back as well as appends: OdtListItem.paragraphs() and .nestedLists() return live views on its own text:p children and any text:list nested inside it (the read counterparts to appendParagraph/addNestedList), and .text is those paragraphs newline-joined, matching OdtTableCell.text/OdpShape.text's own convention — a nested list's text belongs to that list's own items, not to the item containing it, since ODF nests lists structurally rather than flagging membership per paragraph. editor.body.appendFormula(formula, frame) writes a real embedded formula: a whole nested ODF formula sub-document inside the same package, referenced from a draw:frame/draw:object, which is how ODF embeds a formula at all (see Architecture's src/odf-package/ entry) — the odt counterpart to DocxParagraph.appendOfficeMath. openOdp/createOdp and OdpSlide/OdpShape are the odp equivalent of PptxSlide/PptxShape (slide.addTextBox, slide.addImage, slide.notes), and reuse OdtParagraph/OdtRun/OdtList directly for a shape's own text content — a draw:frame's draw:text-box holds the identical text:p/text:span model office:text does, interned into the same content.xml style registry:
import { createOdp } from 'documents.js';
const editor = createOdp();
const slide = editor.addSlide();
const title = slide.addTextBox({ frame: { xPt: 40, yPt: 30, widthPt: 640, heightPt: 80 }, text: 'Title' });
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter -- PptxShape has the equivalent a:xfrm/@rot setter now too, see Architecture below
const bullets = slide.addTextBox({ frame: { xPt: 40, yPt: 130, widthPt: 300, heightPt: 200 }, text: '' });
bullets.paragraphs()[0].remove();
bullets.addList().addItem().appendParagraph({ text: 'A real bulleted text:list' });
slide.notes = 'Speaker notes for this slide';
const bytes = editor.toBytes();createOds/openOds and OdsEditor/OdsSheet/OdsCell are the spreadsheet equivalent — cell addressing has no docx/pptx analogue at all, so this is the one editor family built from scratch rather than reusing OdtParagraph/OdtRun. Setting a cell far from the origin does not materialise every cell in between: the underlying table:number-columns-repeated/table:number-rows-repeated runs are split in place at exactly the target position, the same repeat-compression convention odf.js's own reader already reads. OdsSheet.printSettings is a genuine getter/setter too (src/edit/ods/print-settings.ts) — a set mints a fresh style:page-layout/style:master-page/style:style[family="table"] chain and repoints the sheet at it, rather than mutating whatever it was pointing at before, matching this package's own append-only style-editing convention throughout.
import { createOds } from 'documents.js';
const editor = createOds();
const sheet = editor.addSheet('Sheet1');
sheet.printSettings = { pageSize: { widthPt: 595, heightPt: 842 }, margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, gridlines: true, headers: true, pageOrder: 'downThenOver' };
sheet.cell(0, 0).value = { kind: 'string', value: 'Total' }; // 0-based (row, column) -- there is no A1-string overload
sheet.cell(0, 1).value = { kind: 'currency', value: 42.5, currency: 'USD' };
sheet.cell(500, 50).value = { kind: 'boolean', value: true }; // does not materialise 500x50 empty cells
const bytes = editor.toBytes();createOdg/openOdg and OdgEditor/OdgPage are the drawing equivalent — a page-level container (draw:page), extended with the vector-primitive setters a drawing carries that a presentation typically doesn't. OdgPage.addTextBox/.addImage return real OdpShape instances (draw:frame's content model is byte-for-byte identical between odp and odg — see Architecture); addRect/addEllipse/addLine/addPath return OdgBoxVector/OdgLineVector/OdgPathVector, writing real draw:rect/draw:ellipse/draw:line/draw:path elements — addPath takes whatever ContentSubpath[] the caller passes, lines and cubics both, with no fixed or preset shape vocabulary of its own. OdgPage.vectors() is the read counterpart to those four (shapes() is the counterpart to addTextBox/addImage): it returns a live handle on every vector already on the page, in paint order, as an OdgVector union discriminated on kind ('rect'/'ellipse'/'line'/'path', the same vocabulary ContentVector uses) — so a vector's fill, stroke, frame, and rotation stay editable long after the add* call that created it, exactly like every other live view in this package. A vector's own paint order is purely document order — the same convention real LibreOffice output already uses, so an earlier add* call paints behind a later one, with no draw:z-index attribute ever written.
import { createOdg } from 'documents.js';
const editor = createOdg();
const page = editor.addPage();
page.addRect({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 60 }, fill: { r: 1, g: 0.5, b: 0 } });
page.addEllipse({ frame: { xPt: 140, yPt: 20, widthPt: 100, heightPt: 60 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 } });
page.addPath({
frame: { xPt: 20, yPt: 100, widthPt: 80, heightPt: 80 },
subpaths: [{ start: { xPt: 0, yPt: 80 }, closed: true, segments: [{ kind: 'line', to: { xPt: 60, yPt: 80 } }, { kind: 'cubic', control1: { xPt: 80, yPt: 80 }, control2: { xPt: 80, yPt: 0 }, to: { xPt: 40, yPt: 0 } }] }],
fill: { r: 1, g: 1, b: 0 },
}); // a genuine Bezier curve -- writes a real svg:d/svg:viewBox pair, not a polygon approximation
page.addTextBox({ frame: { xPt: 20, yPt: 200, widthPt: 300, heightPt: 30 }, text: 'A label on top' });
const bytes = editor.toBytes();buildOdsPackage bridges a spreadsheet ContentDocument (either one from readOdsContent, or a best-effort one from reconstructSpreadsheet) to a fresh package built entirely through the same primitives — pdfToOds's own package-building half, mirroring buildOdtPackage/buildOdpPackage's role for pdfToOdt/pdfToOdp. buildOdgPackage bridges a drawing ContentDocument (either one from readOdgContent, or a best-effort one from reconstructDrawing) to a fresh package built entirely through the same primitives — pdfToOdg's own package-building half.
Reading and writing PDF bytes directly, without going through docx/pptx:
import { readPdf, writePdf } from 'documents.js';
const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/link items
const bytes = writePdf(layout);The same nine round trips (PDF ⇄ LayoutDocument, docx ⇄ PDF, pptx ⇄ PDF, odt ⇄ PDF, odp ⇄ PDF, ods ⇄ PDF, odg ⇄ PDF, xlsx ⇄ PDF, markdown ⇄ PDF) are each also available as a schema-validated z.codec() pair, mirroring ooxml.js's own packageCodec — z.decode/z.encode validate both the raw bytes (against the magic-byte schemas below) and the parsed value (against LayoutDocumentSchema) on every call, catching a malformed value that a bare function call wouldn't. This is the no-extra-options form: readPdf/writePdf/docxToPdf/etc. remain the entry points for cancellation (signal), diagnostics (sink), or substitution reporting (onSubstitution), none of which fit z.codec()'s fixed decode(input)/encode(output) signature.
import { z } from 'zod';
import { docxPdfCodec, pdfCodec, pptxPdfCodec } from 'documents.js';
const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes has no %PDF- header
const pdfBytes2 = z.encode(pdfCodec, layout);
const pdfFromDocx = z.decode(docxPdfCodec, docxBytes);
const docxBack = z.encode(docxPdfCodec, pdfFromDocx);The ten PDF-bypassing bridges above get the same treatment: odtDocxCodec, odpPptxCodec, odsXlsxCodec (odt bytes ⇄ docx bytes, odp bytes ⇄ pptx bytes, ods bytes ⇄ xlsx bytes), and markdownDocxCodec/markdownOdtCodec (markdown bytes ⇄ docx bytes, markdown bytes ⇄ odt bytes) — the no-options form again, odtToDocx/docxToOdt/markdownToDocx/docxToMarkdown/etc. remain the entry points for signal.
readDocxContent/readPptxContent/readOdtContent/readOdpContent/readOdsContent/readOdgContent/readMarkdownContent (docx/pptx/odt/odp/ods/odg/markdown → ContentDocument), buildMarkdownText (ContentDocument → markdown text, markdown's own write-side counterpart — MarkdownEditor.toMarkdownText (src/edit/markdown/editor.ts) calls it directly as its own save step rather than wrapping a byte-level writer, so this remains the whole write path even though markdown now has a live-view editor), convertWordprocessingToLayout/convertPresentationToLayout/convertSpreadsheetToLayout/convertDrawingToLayout (ContentDocument → LayoutDocument), and reconstructWordprocessing/reconstructPresentation/reconstructSpreadsheet/reconstructDrawing (LayoutDocument → ContentDocument) are each exported individually too, for a caller that wants one stage of the pipeline without the rest. readDocxContent and readOdtContent both produce the identical wordprocessing-variant ContentDocument shape from two completely unrelated package formats (OOXML and ODF), which is what lets odtToPdf feed convertWordprocessingToLayout without a single line of that engine changing; readMarkdownContent produces that identical shape too, from markdown-codec's own readMarkdown, making markdown the third format sharing this one pivot and layout engine — not just a second data point; readPptxContent and readOdpContent do the same for the presentation variant and convertPresentationToLayout. readOdgContent/convertDrawingToLayout has no OOXML-side counterpart at all (no drawing-equivalent OOXML format this package reads); readOdsContent/convertSpreadsheetToLayout now does have one on the read side — ooxml.js's own readXlsxContent — but only for the PDF-bypassing odsToXlsx/xlsxToOds bridge below, not for the PDF pivot: xlsx has no PDF conversion of its own, so convertSpreadsheetToLayout still has no xlsx-layout counterpart to reuse or be reused by. Both convertSpreadsheetToLayout and convertDrawingToLayout are genuinely new layout algorithms, since a spreadsheet's addressed-grid-with-print-settings semantics and a drawing's vector-primitive vocabulary (rect/ellipse/line/path) have no flow/pagination or direct-placement analogue; convertDrawingToLayout does still reuse convertPresentationToLayout's own shape-conversion logic (convertShape, exported from src/layout/slides.ts) verbatim for whatever text/image/table content a drawing page also carries. reconstructDrawing is reconstructWordprocessing/reconstructPresentation's drawing-side counterpart, but does no baseline/paragraph clustering at all — a drawing has no semantic structure to recover, only a near-1:1 LayoutItem → ContentVector/ContentShape mapping to make, in the same paint order the items were recovered in. reconstructSpreadsheet is a genuinely different geometry-recovery problem from either: a real gridline lattice on the page (drawn by a printed sheet with gridlines enabled) is used DIRECTLY as cell boundaries when one is detected; absent one, text is clustered into a 2D grid from geometry alone. It recovers what was printed, not what was entered: every cell keeps its rendered string verbatim in displayText, and additionally gets a heuristically re-typed value (number/percentage/currency/date/boolean) wherever exactly one reading of that string is defensible — an explicitly probabilistic step, reported per cell through ReconstructOptions.onCellTypeInference, and never extended to claiming a formula (see Fidelity). reconstructWordprocessing/reconstructPresentation additionally recover a page's vector primitives and, gated strictly on a real drawn gridline lattice, a real table — see the Gotchas entries on each.
One further conversion, odmToPdf, is shaped differently from every conversion above: a .odm (ODF master document, a "book" of chapters) never carries its own chapters' content — each text:section is a bare external reference to a standalone .odt file, confirmed against real LibreOffice output (see Gotchas below) — so producing a PDF needs a caller-supplied resolveSubDocument callback to hand back each chapter's own bytes given that section's href. Every chapter's own ContentSection[] is concatenated in text:section document order into one combined document, with an explicit page break marking each chapter boundary, and fed through the same convertWordprocessingToLayout engine every wordprocessing-variant conversion above already uses unmodified:
import { readFileSync } from 'node:fs';
import { odmToPdf, OdmUnresolvedSectionError } from 'documents.js';
const chapterBytes = new Map([
['../chapter1.odt', new Uint8Array(readFileSync('chapter1.odt'))],
['../chapter2.odt', new Uint8Array(readFileSync('chapter2.odt'))],
]);
try {
const pdfBytes = odmToPdf(odmBytes, {
resolveSubDocument: (href) => chapterBytes.get(href),
});
} catch (error) {
if (error instanceof OdmUnresolvedSectionError) {
console.error('missing chapters:', error.hrefs); // every unresolved href, not just the first
}
}odmToPdf is not one of the fourteen round-trip conversions or the ten bridges above, has no z.codec() pair, and is not wired into the DocumentConverter port below — see Gotchas for why.
.odb (ODF database front-end) support: readOdbTables extracts every table an embedded database declares, and odbToXlsx/odbToCsv turn that straight into xlsx or CSV bytes. Every embedded storage shape LibreOffice's own two embedded engines can produce is supported, dispatched automatically from the package's own connection URL and, for HSQLDB, its own per-table storage shape and script format: a MEMORY/TEXT table's rows inline in database/script as ordinary TEXT-format SQL (Tier 1, src/hsqldb/script.ts), a CACHED table's rows in a separate binary page-cache file, database/data (Tier 2, src/hsqldb/cache.ts/rowformat.ts — LibreOffice's own embedded-HSQLDB default, see Architecture/Gotchas for the exact scope and version pinning), a Firebird database's own database/firebird.fbk part — LibreOffice's modern default embedded engine since 4.1, a genuine gbak logical-backup stream rather than a raw on-disk database file (Tier 3; see the Gotchas entry below for the empirical finding this rests on) — and HSQLDB's own whole-script BINARY (hsqldb.script_format=1) and COMPRESSED (=3) serialisations of database/script itself (Tier 4, src/hsqldb/binary-script.ts). A caller never needs to know which shape, engine, or script format a given .odb used:
import { decodePackage } from 'odf.js';
import { odbToCsv, odbToXlsx, readOdbTables } from 'documents.js';
const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table, a header row of column names then one row per record
const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // exactly one named table as CSV -- required whenever the .odb has more than one table
const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[], for a caller that wants the raw table/column/row data without going through xlsx or CSV -- the identical shape whether the .odb is HSQLDB- or Firebird-backedA .odb's own Form/Report structure (as opposed to readOdbTables' table data): odf.js 2.0.0's OdbInventory.forms/.reports carry each declared component's own name and href, and its readOdbForm/readOdbReport resolve one named component into its real static structure — a form's bound controls, a report's bands/groups/functions — re-exported here unmodified. readOdbForms/readOdbReports are this package's own "read every declared one at once" convenience, the readOdbTables-shaped one-call ergonomic this data did not have before odf.js made forms/reports real:
import { decodePackage } from 'odf.js';
import { readOdbForms, readOdbReports } from 'documents.js';
const forms = readOdbForms(decodePackage(odbBytes)); // OdbForm[] -- each form's own bound controls (form:text/form:data-field/etc), plus its content read as an ordinary ODT document via odf.js's readOdt
const reports = readOdbReports(decodePackage(odbBytes)); // OdbReport[] -- each report's own bands (report-header/detail/report-footer/...), groups, and functions, with each control's own data-bound field name resolved from its rpt:formula
// A caller wanting exactly one named form/report can call odf.js's own readOdbForm/readOdbReport directly instead -- both are re-exported unmodified alongside the two convenience functions above.This is structure, not rendering — but rendering is now a real thing this package does with it, and readOdbReportContent below is the whole chain in one call: it resolves the report's own query against the data, evaluates its bands' formulas over the result, and lays the printed bands out as a real ContentDocument. What is not offered is a pixel-faithful reproduction of Report Builder's own page output; see Fidelity for exactly where that line falls.
readOdbTables takes a decoded Package (matching readOdtContent/readOdsContent/etc.'s own convention), while odbToXlsx/odbToCsv take raw bytes and decode them internally, matching every other ergonomic conversion in this package. .odb has no odbToPdf ergonomic conversion and no reverse (xlsx/CSV → .odb) direction, and — like odmToPdf — is not wired into the DocumentConverter port below: the write direction would need a real embedded SQL engine this package deliberately does not implement, and .odb has no single natural target format, since a database front-end's tables, its saved queries, and its reports are three unrelated output shapes rather than one. A rendered report is nevertheless an ordinary wordprocessing ContentDocument, so a caller wanting one as a PDF, a docx, or an odt feeds readOdbReportContent's own output to convertWordprocessingToLayout/buildDocxPackage/buildOdtPackage exactly as for any other document of that variant.
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[] shapeA .odb's own saved queries arrive as SQL text (OdbQueryInfo.command, via readOdbInventory), which on its own answers nothing about the data. parseSelect/evaluateSelect (src/odb/sql/) close that gap: a bounded single-table SELECT engine that runs directly over the HsqldbTable[] readOdbTables produces, in memory, with no database engine anywhere in the path:
import { decodePackage, readOdbInventory } from 'odf.js';
import { evaluateSelect, parseSelect, readOdbTables } from 'documents.js';
const pkg = decodePackage(odbBytes);
const [query] = readOdbInventory(pkg).queries; // e.g. { name: 'HighValueSales', command: 'SELECT "SALES"."REGION", ... ORDER BY "SALES"."AMOUNT" DESC' }
const { columns, rows } = evaluateSelect(parseSelect(query.command), readOdbTables(pkg)); // columns: string[]; rows: ContentCellValue[][]
// Or write the query yourself, against whatever readOdbTables returned:
const byRegion = evaluateSelect(parseSelect('SELECT REGION, COUNT(*), SUM(AMOUNT) FROM SALES GROUP BY REGION ORDER BY REGION ASC'), readOdbTables(pkg));The grammar is a closed allowlist: SELECT a column list or * (or COUNT/SUM/AVG/MIN/MAX) FROM one table, with optional WHERE (comparisons, AND/OR/NOT with parentheses, IS [NOT] NULL, [NOT] LIKE, [NOT] IN, [NOT] BETWEEN), GROUP BY, and a multi-column ORDER BY. JOINs, subqueries, UNION, DISTINCT, HAVING, row limits, aliases, and every scalar function beyond those five aggregates throw HsqldbSqlUnsupportedError naming the construct — never a silently partial or wrong result set. tokenizeSql is exported too, for a caller that wants the token stream without the grammar. See Gotchas for the full boundary, and Fidelity for the semantics (three-valued NULL logic, NULL ordering, group ordering).
A Report's own bands go one step further than a query: each bound control carries an rpt:formula attribute, and a report declares nested groups whose break tests and per-group totals are written in that same little language. runRptReport (src/odb/formula/) evaluates it over the result set the query engine just produced, turning a report's static structure into the band instances a renderer would lay out — each carrying its own evaluated values:
import { decodePackage, readOdbInventory } from 'odf.js';
import { evaluateSelect, parseSelect, readOdbReports, readOdbTables, rptDefinitionFromReport, runRptReport } from 'documents.js';
const pkg = decodePackage(odbBytes);
const [report] = readOdbReports(pkg); // e.g. { name: 'SalesByRegion', command: 'HighValueSales', commandType: 'query', groups: [...], functions: [...] }
const query = readOdbInventory(pkg).queries.find((candidate) => candidate.name === report.command);
const rows = evaluateSelect(parseSelect(query.command), readOdbTables(pkg));
const { bands } = runRptReport(rptDefinitionFromReport(report), rows);
// bands: one entry per printed band, in print order -- 'report-header', then per row the 'group-header's that open at it, the
// 'detail' band, and the 'group-footer's that close after it, then 'report-footer'. Each carries `values`, one evaluated
// ContentCellValue per band element (undefined for an element with no formula of its own, e.g. a fixed-content label).The function set is a closed allowlist here too: rpt:HASCHANGED(X) (the group-break test — true when X differs from its value on the preceding row), rpt:LEFT(X;n) (note the semicolon separator, LibreOffice's own formula-language convention), and rpt:SUM/COUNT/AVG/MIN/MAX, plus the separate field:[COLUMN] bound-field form, which is a plain value passthrough rather than a computation. Every other rpt function — and Report Builder ships many — throws RptFormulaUnsupportedError naming it. parseRptFormula is exported too, for a caller that wants one formula's AST without running a report. See Gotchas for the group-scoping rule, which is the substance of this engine.
readOdbReportContent (src/odb/report/) is all of the above in one call — the report's data binding resolved, its query run, its formulas evaluated, and its printed bands rendered as a real ContentDocument:
import { decodePackage } from 'odf.js';
import { readOdbReportContent } from 'documents.js';
const document = readOdbReportContent(decodePackage(odbBytes)); // a 'wordprocessing' ContentDocument -- one section, one block per printed band
const another = readOdbReportContent(decodePackage(odbBytes), { report: 'SalesByRegion' }); // required whenever the .odb declares more than oneResolving the report's own rpt:command/rpt:command-type binding is the one part the formula engine never saw: "table" means the command names a table and the report reads all of it (turned into a real SELECT * FROM "<table>" and run through the same engine, rather than a second resolution rule that could disagree with it), "query" means it names a saved query in the .odb's own db:queries whose db:command holds the SQL, and "command" means the command is the SQL. Rows arrive in that command's own ORDER BY order, and the report's rpt:sort-expression is deliberately not applied on top — a group's sort expression is a bare column name, so re-sorting by it would discard whatever finer ordering the command already asked for (the real fixture's saved query orders REGION, QUARTER, then AMOUNT descending, and the two group sort expressions name only the first two).
Each printed band becomes one single-row ContentTable, one cell per control, in document order — the same shape the band has in the report file itself, where every band is a table:table whose cells hold its controls. Every cell's paragraph carries the band's own name as its styleId (Report Header, Page Header, Group Header 1, Detail, Group Footer 1, Report Footer, …), so which band a block printed from survives into the document rather than having to be inferred from its position. Its three stages stay independently usable like every other .odb stage: odbReportCommandSql (a report → the SQL it issues), resolveOdbReportRows (a package + a report → those rows), and renderOdbReportContent (a report + any equivalently-shaped rows → the document — useful for rendering the same report over an unfiltered table, say). See Fidelity for what "structural, not pixel-faithful" means here in detail.
A standalone .odf (an ODF formula document) converts to PDF via odfToPdf, rendering the formula's own real MathML through a hand-written typesetting engine (src/mathml/) and the embedded STIX Two Math font, not a static image or a StarMath-text placeholder. Its onDocument callback reports a real 'formula'-kind ContentDocument, the same as every other conversion reports its own pivot:
import { odfToPdf } from 'documents.js';
const pdfBytes = odfToPdf(odfBytes); // a single formula (or small formula document), faithfully typeset -- see FidelityodfToPdf is not one of the fourteen round-trip conversions above either: there is no pdfToOdf (recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one — see Fidelity), no z.codec() pair, and — unlike odmToPdf — it is wired into the DocumentConverter port below, as a DocumentFormat: 'odf' source with only a 'pdf' target.
Standalone .odf files are rare in practice; a formula embedded inside an odt paragraph or an odp slide is the far more common real-world case, and odtToPdf/odpToPdf already render one automatically wherever readOdtContent/readOdpContent find a draw:frame referencing an embedded formula sub-object — no extra code needed at the call site:
import { odtToPdf } from 'documents.js';
// odtBytes contains an ordinary paragraph followed by an embedded formula object (LibreOffice: Insert > Object > Formula) --
// the formula renders as real typeset MathML in the output PDF, at the position and approximate size of its own source frame.
const pdfBytes = odtToPdf(odtBytes);The formula's real MathML travels inside the ContentDocument: readOdtContent/readOdpContent return a bare ContentDocument (exactly like readDocxContent/readPptxContent), and an embedded formula is an ordinary ContentEmbeddedObjectBlock whose own document is a genuine 'formula'-kind ContentDocument carrying { mathml, starMath? } — document-schema.js's fifth ContentDocument variant. There is no side-channel map to thread anywhere:
import { convertWordprocessingToLayout, formulaOfBlock, readOdtContent } from 'documents.js';
const document = readOdtContent(pkg);
const block = document.sections[0].blocks.find((b) => b.kind === 'embeddedObject');
formulaOfBlock(block); // -> { mathml, starMath? }, or undefined for a non-formula embedded object
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer });
const pdfBytes = writePdf(layout, { formulas: positioned }); // writePdf's own formula-aware option -- see ArchitecturewritePdf's formulas option is the one place a formula still travels beside its document rather than within it, and for a different reason: a rendered formula's CID-font glyph runs have no LayoutItem kind to be (see pdf-codec's own README), so convertWordprocessingToLayout/convertPresentationToLayout return the positioned results alongside the LayoutDocument.
layoutFormula (the typesetting engine's own entry point) and loadMathFont (the embedded STIX Two Math font, parsed and cached once per process) are each exported individually too, for a caller that wants to lay out a formula directly:
import { layoutFormula, loadMathFont } from 'documents.js';
const { metricsAt } = loadMathFont();
const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), sizePt: 12, color: { r: 0, g: 0, b: 0 } });
// box: a MathBox -- positioned glyph runs, fraction/radical rules, and radical-hook strokes, ready for pdf-codec's own math-content-write.ts
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see FidelitybuildOfficeMath/buildOfficeMathParagraph are the write-side counterpart, translating the same MathML into real OMML (OOXML's own math markup) rather than into positioned glyphs — buildDocxPackage uses them for every embedded formula, and they are exported for a caller assembling OOXML math itself, e.g. into a docx opened through openDocx:
import { buildOfficeMathParagraph, openDocx } from 'documents.js';
const editor = openDocx(existingDocxBytes);
const { diagnostics } = editor.body.appendParagraph().appendOfficeMath(mathml); // appends a real m:oMathPara > m:oMath equation
// diagnostics: an 'unsupported-element' or 'approximated-element' entry per construct OMML has no faithful counterpart for -- see Gotchas
const { element } = buildOfficeMathParagraph(mathml); // or build the fragment directly, for a caller placing it itselfreadOfficeMath/collectOfficeMathElements are the read-side inverse — an OOXML equation back to real MathML. readDocxContent runs them over every paragraph itself (see Architecture's src/omml/ entry), so an equation in a docx arrives as an ordinary formula-carrying ContentEmbeddedObjectBlock with no caller involvement; these are exported for a caller mining equations out of a docx directly:
import { collectOfficeMathElements, readOfficeMath } from 'documents.js';
for (const equation of collectOfficeMathElements(paragraphElement.children)) {
const { mathml, diagnostics } = readOfficeMath(equation);
// mathml: the children of a <math> root -- exactly what ContentFormula.mathml holds, and what layoutFormula above consumes
// diagnostics: an 'unsupported-element' or 'approximated-element' entry per OMML construct MathML has no faithful counterpart for -- see Gotchas
}Every module under src/ is also directly deep-importable by its package-relative path, without going through the barrel — useful for a caller that wants exactly one conversion function and nothing else pulled in:
import { emuToPt } from 'documents.js/model/units';
import { buildOdtPackage } from 'documents.js/edit/odt/content';This works via a "./*" wildcard entry in package.json's exports map, resolving any subpath to the correspondingly-named file under dist/ — the same directory structure src/ has, one output file per source module, so src/edit/odt/content.ts becomes dist/edit/odt/content.js/.cjs/.d.ts/.d.cts.
Every X → PDF conversion (docxToPdf, pptxToPdf, odtToPdf, odpToPdf, odsToPdf, odgToPdf, plus markdownToPdf/xlsxToPdf/odmToPdf) resolves each requested typeface through a real FontRegistry, in this order:
- The source document's own embedded faces. A docx that was saved with font embedding on carries the exact bytes it was authored against, in
word/fontTable.xml'sw:embed*parts (obfuscated per ECMA-376 Part 4, 2.8.1 — the first 32 bytes XORed against a key derived from the accompanyingw:fontKeyGUID); a pptx carries them inp:embeddedFontLst(unobfuscated.fntdataparts); an ODF package carries them underFonts/, declared byoffice:font-face-decls'ssvg:font-face-uri(also unobfuscated). All three are extracted automatically — the caller does nothing. - Faces the caller supplied through
options.fonts, for a family the source document did not embed. - pdf-codec's vendored Carlito and Caladea faces, genuinely metric-compatible with Calibri and Cambria, embedded as real subsetted TrueType font programs.
- The standard 14, for everything else — where Helvetica/Times-Roman remain metric-compatible with Arial/Times New Roman and a width-correction factor approximates the rest.
The same registry drives both halves of a conversion: the TextMeasurer that decides where lines break and the writer that emits the glyphs. That is load-bearing rather than tidy — measuring against Helvetica's metrics and then drawing through a real Carlito face would wrap text at positions that do not match what was painted.
import { docxToPdf } from 'documents.js';
// Nothing to configure: a docx that embedded its fonts renders in its real typeface.
const pdfBytes = docxToPdf(docxBytes);
// A face for a family the document didn't embed, plus a report of anything that still fell back.
const withFallbackFace = docxToPdf(docxBytes, {
fonts: [{ family: 'Brand Sans', bold: false, italic: false, bytes: brandSansTtfBytes }],
onFontSubstitution: (substitution) => console.warn(substitution.requestedFamily, '->', substitution.resolvedFamily),
});A document that embeds nothing and asks for no family a vendored substitute covers writes byte-identical output to the standard-14-only pipeline this package had before font resolution existed — proven by a real before/after byte comparison across all six conversions in src/convert/convert-fonts.test.ts, against a reference that reproduces the old pipeline exactly.
Two honest limits, both structural rather than provisional. An embedded face is normally subsetted by the application that saved it, so it can legitimately lack a character this package synthesises rather than reads (a list bullet, sheets.ts's ### column-overflow marker); pdf-codec reports that per character through onMissingGlyph and falls back for that one character, never for the run or the document. And odfToPdf accepts both font options and consults neither — a standalone formula document emits no positioned text at all, only the embedded STIX Two Math font's own glyphs, which are not registry-resolvable.
extractOoxmlEmbeddedFonts/extractOdfEmbeddedFonts, extractSourceFonts, and createDocumentFontRegistry are exported for a caller composing readXContent → convertXToLayout → writePdf themselves rather than going through an ergonomic conversion.
The package is layered from generic primitives outward to the two conversion directions:
-
src/model/— thin, documents.js-specific additions on top of the siblingdocument-schema.jspackage, 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 —LayoutPathis a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kindwritePath, pdf-codec's own content-write.ts, turns into PDFm/l/c/hcontent-stream operators) andContentDocument(the semantic pivot: a discriminated union ofwordprocessing,presentation,spreadsheet,drawing, andformulavariants — the first four sharing paragraph/run/table/image building blocks,drawing's ownContentVectorvocabulary — rect/ellipse/line/path — being the vector-primitive counterpart to the sharedContentShape, andformulacarrying a real MathML tree rather than any of them) are both imported, not defined here —document-schema.jsexists specifically soooxml.js,odf.js,pdf-codec, anddocuments.jsshare one schema instead of each maintaining an independent, drift-prone copy. What remains local:bytes.ts(magic-byte-validatedUint8Arrayschemas for docx/pptx/PDF, plusOdt/Ods/Odp/OdgBytesSchema, which check the package's actual declared media type againstodf.js'sODF_MEDIA_TYPEStable rather than only the generic ZIP signature the OOXML schemas are limited to),units.ts(OOXML EMU/twip/point/half-point conversions), andgeometry.ts/color.ts/style.ts, each now mostly a thin re-export ofdocument-schema.js'sBox/Margins/PageSize/Color/Alignment/LayoutFont— the one genuinely PDF-specific piece each still adds locally isgeometry.ts'sflipY(the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates);LayoutFont/DEFAULT_LAYOUT_FONTmoved todocument-schema.jstoo (sinceLayoutText, part of the pivot, needs the field), leaving only the standard-14 font resolution logic that consumes it (pdf-codec's ownfonts.ts/font-read.ts) as PDF-specific, now external to this package entirely.ContentDocument/ContentDocumentSchema/CONTENT_FORMAT_VERSIONthemselves have no local file at all any more — every consumer imports them directly fromdocument-schema.js, which owns the envelope as well as everything it wraps.paint-order.ts'smergeByPaintOrdermerges a drawing page's two arrays (shapes,vectors) back into one true-paint-order walk through the sharedpaintOrderfield both carry; it lives here rather than beside either caller becausesrc/layout/drawing.tsandsrc/edit/odg/content.tsboth need the identical merge andsrc/layout/*deliberately imports noodf.js/editcode.formula.tsholds the small helpers around document-schema.js's ownContentFormula: the'formula'-kindContentDocumentenvelope, theContentEmbeddedObjectBlockan odt/odp reader produces for an inline formula, the narrowing back out of such a block, and the plain-text stand-in (formulaPlaceholderText) every consumer that cannot typeset MathML writes instead. It declares no formula type of its own — the side-channelEmbeddedFormulait used to define is gone, replaced by the real schema type.PositionedFormula(the equivalent side-channel shape for aLayoutDocument) now lives inpdf-codecitself, which redeclares its own structurally-identical copy of it and ofMathBox— see Architecture below and pdf-codec's own README for why a realMathBoxthis package'slayoutFormulaproduces crosses that package boundary with zero cast or wrapper.embedded-drawing.tsisformula.ts's exact counterpart for the'drawing'objectKind:buildDrawingBlockpackages a page's recoveredContentVectors as aContentEmbeddedObjectBlockcarrying a one-page drawingContentDocument(the only container in the shared schema with avectorsarray at all, which is why a recovered rect lives there rather than directly inContentSection.blocks/ContentSlide.shapes),drawingOfBlocknarrows back out of one, andembeddedDrawingVectorsflattens a block's vectors into whatever coordinate space the container about to write them uses — translating by the block's own frame plus the container's, never scaling. -
The hand-written PDF codec, and the generic byte/image primitives it depends on, are now the external
pdf-codecdependency rather than localsrc/pdf//src/bytes//src/image/directories — see that package's own README for its internal architecture (the object model, cross-reference handling, content-stream interpreter, standard-14 font resolution, the embedded math-font writer, and the generic byte/PNG/JPEG primitives it exports for a layout engine like this package's ownsrc/layout/to build on). -
src/ports/— the injectable ports this package's own "identity, clock, and observability are first-class ports" convention calls for:abort.ts'sthrowIfAborted(a signal-check helper called at row loop boundaries insrc/layout/sheets.ts/reconstruct.ts— the codebase has noawaitpoint for cancellation to hook into implicitly, since the local pipeline is synchronous end to end, so every long-running loop checks explicitly instead;pdf-codecneeded the identical helper for its own page loops and now carries its own independently-duplicated copy rather than depending on this package for it) andclock.ts'sClockPort/systemClock/fixedClock(an injectable "now", for deterministic PDF output in tests).ClockPortis exported and tested in isolation but not yet consumed by any conversion path —writePdf's own/CreationDate//ModDatecome directly fromLayoutDocument.metadata.createdIso/modifiedIsowhen present, with nothing in pdf-codec's own write path callingnew Date()to fill in a missing one, so there is currently no real call site forClockPortto inject into. A real, tracked gap in wiring, not a documentation gap: a future default-timestamp write path should consume it rather than reaching fornew Date()directly. -
src/xml/andsrc/opc/— parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built overooxml.js'sPackage/XmlNode, needed becauseooxml.js's own XML nodes have no parent pointers andooxml.jsnever writes new parts into an existing package.src/xml/odf-text.tsis the one ODF-specific module in this directory:encodeOdfText/decodeOdfTextconvert between a plain string and ODF's own whitespace-run element sequence (text:sfor 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 flatw:trun text) — see the Gotchas entry below on why every ODF text getter in this codebase must calldecodeOdfText, neverooxml.js's own plain-text-nodetextContent(). -
src/odf-package/— the ODF-side counterpart tosrc/opc/:manifest.tsre-exportsodf.js's own manifest read/build/write/sync/validate functions (odf.jsalready ownsMETA-INF/manifest.xmlend to end — reading, deriving, writing, syncing, and validating it — unlikeooxml.js's read-only OPC relationship handling) and adds exactly one thing of its own,syncOdfManifest:odf.js'sbuildManifestsynthesises amanifest:file-entryfor every embedded sub-document directory it finds (any"<dir>/content.xml"prefix) but resolves that entry's media type by file EXTENSION, which a directory has none of, so it comes out empty unless a caller supplies an override.syncOdfManifestderives each one from what the sub-document actually is — the single element inside its ownoffice:body, the same discriminant everyodf.jsreader keys on — and every part-mutating helper here syncs through it, so adding an image to a document that already embeds a formula cannot blank the formula object's own entry on the way past.media.ts'saddImageMediainserts a binary image part underPictures/(the real-world LibreOffice/OASIS convention, confirmed againstodf.js's own round-trip/manifest fixtures) — one step simpler than OOXML's ownaddImageMedia(src/opc/media.ts) since ODF references a media part directly by its package path (xlink:href) rather than through a relationship-ID indirection.formula.ts'saddFormulaObjectis the newer sibling and a genuinely different shape of insertion: an embedded ODF formula is not a markup vocabulary inside the hostcontent.xmlthe way OOXML's own OMML is, it is a WHOLE NESTED DOCUMENT stored under its own directory prefix in the same zip (Object 1/content.xml, anoffice:document-content>office:body>office:math>math:mathtree), referenced from the host by adraw:frame/draw:objectnaming that directory — precisely whatodf.js's ownreadOdfFormulareads back, and whatreadOdfEmbeddedFormula(src/odf/formula/read.ts) resolves out of the outer package's flat parts record. The formula's own MathML nodes are written straight through with no translation and no re-serialisation, sincedocument-schema.js'sMathMlNodeandodf.js'sXmlNodeare structurally identical; themath:mathelement declares the MathML namespace both as themath:prefix and as the default, so a prefixed tree (real LibreOffice output) and a bare one (whatsrc/omml/read.tsrecovers from an OOXML equation) are each genuinely namespaced.OdpSlide.addImage/OdpShape(src/edit/odp/image.ts) isaddImageMedia's real caller — and, throughsrc/edit/odg/*'s wholesale reuse ofOdpShape(see thesrc/edit/entry below),OdgPage.addImagetoo;OdtBody.appendFormula(viasrc/edit/odt/formula.ts) isaddFormulaObject's;src/odb/read.tsalso reusesmanifest.ts'sreadManifestdirectly, to checkdatabase/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 actualXmlElementobjects inside a decodedPackage, plusbuildDocxPackage/buildPptxPackage/buildOdtPackage/buildOdpPackage/buildOdsPackage/buildOdgPackagebridging aContentDocumentto a fresh package built entirely through those same primitives —pdfToOdt/pdfToOdp/pdfToOds/pdfToOdgeach call the matching one.DocxParagraph.appendOfficeMathandOdtBody.appendFormulaare the formula-writing primitives, and they are deliberately shaped differently because the two formats embed a formula in genuinely different ways:appendOfficeMathappends a real OMML display equation (m:oMathPara>m:oMath) built bysrc/omml/write.tsINLINE in the paragraph, whileappendFormulawrites a whole nested formula sub-document into the package (src/odf-package/formula.ts) and appends adraw:frame/draw:objectreferencing it.buildDocxPackage/buildOdtPackageuse them to write an embedded formula as genuine, editable math in each format instead of a plain-text stand-in.src/edit/odp/*reusessrc/edit/odt/*'s own paragraph/run/list/style-interning classes WHOLESALE rather than reimplementing them for presentations: adraw:frame'sdraw:text-boxholds the identicaltext:p/text:spancontent modeloffice:textdoes, interned into the identicalcontent.xmloffice:automatic-stylesregistry (src/edit/odt/props.ts'sapplyStyleChange) —OdpShape.appendParagraph/.paragraphs()/.addList()return realOdtParagraph/OdtListinstances, not odp-specific lookalikes. The genuinely new odp-specific work isdraw:page/draw:framemechanics (a slide is adraw:page, a shape's geometry is explicitsvg:x/svg:y/svg:width/svg:heightrather than pptx's placeholder-inheritance-heavy model) and rotation:OdpShape.rotationDegis a genuinedraw:transformsetter built onodf.js's ownapplyOdfTransform/resolveOdfShapeGeometry(typed/shared/transform.ts) — the write-side inverse of the exact function odf.js's own reader uses.PptxShape.rotationDeg(src/edit/pptx/shape.ts) is the DrawingML analogue, a plaina:xfrm/@rotattribute setter (60,000ths of a degree, clockwise, ECMA-376 20.1.7.6) needing no group-composition logic of its own, sinceooxml.js's owncomposeShapeRotationDegalready collapses to a bare passthrough ofxfrm.rotationDegfor a top-level, ungrouped shape. That write side now lives insrc/edit/geometry.ts(buildTransformAttr/applyOdfGeometry), a peer of the per-format edit directories rather than insideodp/, becauseOdgBoxVector.rotationDeg/OdgPathVector.rotationDegneed the identical machinery fordraw:rect/draw:ellipse/draw:path— odf.js resolves all four element kinds through oneresolveOdfShapeGeometry, so there is exactly one correct inverse of it. A table INSIDE a slide shape (not a document-level table) is now writable too:OdpSlide.addTablebuilds adraw:framewhose only child is atable:tabledirectly (nodraw:text-boxwrapper) and reusesOdtTable/buildTableWHOLESALE for it, the same content-model-is-identical-wherever-it-lives argumentOdpShape's own paragraph/list reuse already rests on;PptxSlide.addTable(src/edit/pptx/table.ts) is the genuinely new DrawingML-side work, since a table shape lives in its ownp:graphicFrame— a shape kind distinct fromp:sp/p:pic, with its own frame on a directp:xfrmchild rather than nested in ap:spPr— and a DrawingML table's own merge model is a THIRD distinct convention from both docx's gridSpan-collapses-the-row scheme and ODF's covered-table-cell elements: every row always carries exactly as manya:tcas there are grid columns, and a covered cell is marked by a plainhMerge/vMerge="1"attribute on that same element, never an omitted or a differently-tagged one.src/edit/ods/*has no docx/pptx/odt/odp analogue to reuse for its core concern (cell addressing) but still reusessrc/edit/odt/*'s style interning andsrc/edit/odt/content.ts'spopulateParagraphfor cell text content —src/edit/ods/address.tsis the write-side counterpart toodf.js's own read-sidetable: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 hazardodf.js's owntyped/shared/a1.tsalready solved.src/edit/ods/print-settings.tsis the newest addition:OdsSheet.printSettings's own getter/setter, miningstyles.xml'soffice:automatic-styles/office:master-stylesdirectly (a part no othersrc/edit/ods/*module needed to touch before) rather thancontent.xmlalone, reusingodf.js's own exportedfindStyleElement/resolvePageLayoutProperties/parsePageSize/parseMarginsfor the read half andsrc/edit/odt/automatic-styles.ts'snextStyleName(already generic over whichoffice:automatic-styleselement it scans) for the write half's own fresh-name minting.src/edit/odg/*reusesOdpShape/buildTextBoxFrame/insertImageFrameMediaWHOLESALE fordraw:frametext/image content (a drawing page'sdraw:framecontent model and geometry resolution — rotation included — are byte-for-byte identical to a presentation's, both resolved throughodf.js's own sharedreadDrawFrame), so there is no separateOdgShapeclass at all; the genuinely new work is the vector-primitive classes (a per-kind attribute vocabulary:svg:x/y/width/heightfor rect/ellipse/path,svg:x1/y1/x2/y2for a line) and their own fill/stroke, which needed a small, self-contained graphic-family style writer (src/edit/odg/style.ts) sinceodf.js's ownStyleRegistryrecognises'graphic'as a style family but itsStylePropertiesSchemaonly ever models text/paragraph formatting — it has no fill/stroke fields and never emits astyle:graphic-propertieselement. A path vector's ownsvg:dis generated bysrc/edit/odg/svg-path.ts, the write-side inverse ofodf.js's owntyped/shared/path.tsparser — always absolute, always space-separated commands, anchoringsvg:viewBoxat"0 0 {widthPt} {heightPt}"so the written numbers are the exact sourceContentPathPointvalues with no rescaling arithmetic either way (see Gotchas below for the cross-check against that exact parser). That vector writer is no longer odg-only:buildVectorElement/appendVectorTo(src/edit/odg/vector.ts) are the single dispatch pointOdgPage.addVector,OdpSlide.addVector, andOdtBody.appendVectorsall go through, so adraw:rect/draw:ellipse/draw:line/draw:pathis built exactly one way whichever ODF document kind it lands in — the same wholesale-reuse argumentOdpShape's own paragraph/list reuse rests on.src/edit/drawingml/vector.tsis the OOXML half of the same idea and a peer of the per-format directories for the same reasonsrc/edit/geometry.tsis: it holds everything inside a DrawingML shape-properties element, which docx and pptx express identically (CT_ShapePropertiesis one type in both), leaving only the per-format wrapper tosrc/edit/docx/vector.ts(a page-anchoredw:drawing/wp:anchorcarrying awps:wsp) andsrc/edit/pptx/vector.ts(a plainp:sp). See the vector write-side gotchas below for the geometry mapping and the anchoring choices each makes. -
src/fonts/— source-embedded font extraction, and the registry composition every X → PDF conversion builds from it (see Fonts above for the resolution order this produces).obfuscation.tsimplements ECMA-376 Part 4, 2.8.1:deriveFontKeyturns aw:fontKeyGUID into the 16-byte XOR key — reading its 32 hex digits as byte pairs in REVERSE order, sokey[0]is the GUID's LAST pair, verified against the specification's own worked example — anddeobfuscateEmbeddedFontapplies it twice across the part's first 32 bytes. One function covers docx and pptx both, by sniffing the leading sfnt signature FIRST and only deobfuscating bytes that are not already a recognisable font, rather than branching on source format: pptx's own.fntdataparts are stored clear and carry no font key at all, and a docx producer that stored a clear part stays readable too.ooxml.tsresolvesword/fontTable.xml(orppt/presentation.xml) through the package's own relationship graph rather than assuming a conventional path, reads eachw:embedRegular/w:embedBold/w:embedItalic/w:embedBoldItalic(orp:regular/p:bold/p:italic/p:boldItalic) reference, and produces pdf-codec'sProvidedFontshape.odf.tsdoes the same forstyle:font-face'ssvg:font-face-src/svg:font-face-uri— no relationship indirection, no obfuscation, and a face's weight/style taken fromloext:font-weight/loext:font-stylewhere a producer wrote them and from the font's OWNOS/2fsSelectionbits where it did not (the better signal of the two: aloextattribute is a producer's claim about a file,fsSelectionis that file's own declaration about itself).registry.ts'screateDocumentFontRegistrycomposes a source package plus any caller-supplied faces into a realFontRegistry, expressing the whole precedence chain as data (sourceFontsahead offontsahead of the vendored substitutes) rather than as a branch. A face is deliberately never filtered by what the document actually uses: an embedded face is normally subsetted, so a character this package synthesises rather than reads can legitimately be absent from a face that is otherwise exactly right, and that is resolved per character by pdf-codec's ownonMissingGlyph, not by dropping the whole face. -
src/mathml/— a MathML presentation-layer typesetting engine, comparable in scope to pdf-codec's own standard-14 text-layout half — genuinely self-contained: no import frommodel,pdf-codec, orodf.jsat all (not evendocument-schema.js), matchingsrc/layout/'s own "pure conversion algorithm" isolation one tier further down.nodes.tsdefinesMathMlNode/MathMlElementas a local, structurally-compatible mirror ofodf.js's ownXmlNode(the same "mirror the shape, don't import the package" tricksrc/interop.test.tsalready proves holds betweenooxml.jsandodf.js), soodf.js'sreadOdfFormula's real return value type-checks against it with zero cast.variant.tsmapsmathvariantto 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 ownUnicodeData.txt, not transcribed by hand).operators.tsis a deliberately bounded operator dictionary (lspace/rspace/stretchy/largeop/movablelimits per operator), not the MathML3 spec's own multi-thousand-entry table.layout.tsis 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 injectedMathFontMetricsport (metrics.ts) rather than any font-parsing code of its own — pdf-codec's ownmath-font.tsis the real implementation, consumed only through this structural port, never imported directly.compose.ts/radical.ts/length.tsare 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).layout.tsadditionally stretches a row's own vertical fences through the sameMathFontMetricsport (itsstretchmethod resolves the font's OpenType MATHMathVariantsdata into positioned glyph IDs), emitting them asMathAssembledGlyphsitems — the one item kind addressed by glyph ID rather than by Unicode text, because most of the glyphs such a construction names have no code point at all. Output is a flatMathBox(positioned glyph runs, rules, strokes, and assembled glyph placements, box-local top-left/y-down coordinates), passed with zero cast into pdf-codec'swritePdf({ formulas })— see pdf-codec's own README for the structural-typing mechanism that makes this work across a package boundary with no shared class or branded type. -
src/omml/— the MathML ⇄ OMML (Office Math Markup Language, ECMA-376 Part 1 §22.1's ownm:vocabulary) structural translator, both directions.write.ts'sbuildOfficeMath/buildOfficeMathParagraphare the write side, the counterpart tosrc/mathml/'s own typesetting engine, covering the identical construct set deliberately, so a formula rendered to PDF and the same formula written into a docx degrade in exactly the same places rather than one being silently better than the other: each MathML construct maps onto its real OMML element (mfrac→m:f,msqrt/mroot→m:radwithm:radPr/m:degHideand the degree/radicand order reversed,msub/msup/msubsup→m:sSub/m:sSup/m:sSubSup,munder/mover→m:limLow/m:limUppandmunderover→ the two nested,mtable/mtr/mtd→m:m/m:mr/m:ewith per-columnm:mcs/m:mcjustification, and every token element → anm:r/m:trun whosemathvariantbecomes OMML's ownm:scrscript +m:stystyle pair).read.ts'sreadOfficeMath/collectOfficeMathElementsare the read side, the structural inverse of every one of those mappings, and read STRICTLY MORE than the writer writes — deliberately, since the writer only ever has to express what MathML can say while the reader has to cope with whatever Word itself authored:m:d(Word's representation of every parenthesised sub-expression),m:nary(a sum/product/integral with limits AND its own operand),m:acc,m:bar,m:func, andm:sPreeach have one exact MathML inverse and no writer counterpart at all. Both directions emit no geometry, measure nothing, and load no font — this is a vocabulary translation, not a rendering. The directory lives outsidesrc/mathml/for that directory's own isolation rule:write.ts's whole output type (andread.ts's whole input type) isooxml.js'sXmlElement, andsrc/mathml/imports no package at all.shared.tsholds what neither direction owns: theOmmlDiagnosticshape both report through, the onemathvariant⇄m:scr/m:stytable each reads in its own direction, andmi's own intrinsic-variant default.buildDocxPackageandreadDocxContentare their real callers; a construct with no counterpart in the target vocabulary degrades on its own, with a diagnostic, exactly assrc/mathml/layout.ts's ownunsupportedfallback does for the PDF path. -
src/ooxml/— resolves aPackageinto aContentDocument:docx/read.tsandpptx/read.tsare now thin adapters overooxml.js's ownreadDocx/readPptx, wrapping their{ metadata, sections }/{ metadata, slides }result intoContentDocument'swordprocessing/presentationshape. The docx style cascade (docDefaults→ named-stylebasedOnchains → paragraph-mark run properties → character styles → direct formatting), the pptx placeholder → layout → master → theme inheritance cascade, and DrawingML geometry/colour resolution all now live upstream inooxml.jsitself, not in this package.docx/formula.tsis the one piece of genuinely local reading work left: a second, independent pass over the sameword/document.xml, splicing every OOXML math equationsrc/omml/read.tsrecovers into the sectionsreadDocxproduced — needed becausereadDocxhas nom:oMathhandling at all, exactly the waysrc/odf/odt/read.tsneeds its own pass for a formulaodf.js'sreadOdtlikewise does not read. Positioning is derived rather than approximated, and by a shorter route than the ODF side's own block-counting mirror needs: everyw:pproduces exactly one top-levelContentParagraphblock and nothing else produces one, so the Nthw:pin the body IS the Nth paragraph-kind block. Aw:pcarrying nothing but its equation is CONSUMED by the formula block rather than emitted alongside it, which is what keeps adocx → odt → docxround trip from accumulating one blank paragraph per formula per hop.docx/extras.ts'sreadDocxExtrasis a second, independent re-projection of that samereadDocxcall, for the datareadDocxContentgenuinely cannot carry throughContentDocument's section/block shape at all: comments, footnotes, headers/footers, and numbering (abstractNum/num) definitions. It callsreadDocxa second time rather than being fused ontoreadDocxContent's own return value — an accepted cost matching every other "each pipeline stage independently exported" pair in this codebase — and reusesooxml.js's ownComment/Footnote/NumberingDefinitionstypes directly rather than mirroring them locally. -
src/odf/— the ODF-side counterpart tosrc/ooxml/, resolving anodf.jsPackageinto aContentDocument:odt/read.ts'sreadOdtContentis a thin adapter overodf.js's ownreadOdt, wrapping its{ metadata, sections }result into the identicalwordprocessingshapereadDocxContentproduces — the concrete proof that odt and docx genuinely share one pivot and one layout engine.odp/read.ts'sreadOdpContentis the same adapter overodf.js's ownreadOdp, wrapping{ metadata, slides }into the identicalpresentationshapereadPptxContentproduces.ods/read.ts'sreadOdsContentwrapsodf.js'sreadOds's{ metadata, sheets }into thespreadsheetContentDocumentvariant, andodg/read.ts'sreadOdgContentwrapsodf.js'sreadOdg's{ metadata, pages }into thedrawingvariant —odgstill has no OOXML-side sibling adapter at all (no drawing-equivalent OOXML format this package reads);odsnow does,ooxml.js's ownreadXlsxContent/buildXlsxPackage, consumed directly bysrc/convert/convert.ts'sodsToXlsx/xlsxToOdsbridge (see below) but deliberately not re-exported from this package's own public surface, mirroring thereadDocx/readPptxnon-re-export choice above.buildOdtPackage/buildOdpPackage/buildOdsPackage/buildOdgPackage(src/edit/{odt,odp,ods,odg}/content.ts) each bridge aContentDocumentback to a fresh package built on that format's own live-view editor, closing the PDF → odt/odp/ods/odg direction (pdfToOdt/pdfToOdp/pdfToOds/pdfToOdgeach call the matching one) — see thepdfToOdsgotcha below forbuildOdsPackage's own printSettings-writing addition.formula/read.ts'sreadOdfFormulaContent/readOdfEmbeddedFormulaare the same thin-adapter pattern overodf.js's ownreadOdfFormulaDocument, for a standalone.odf(the whole'formula'-kindContentDocument) and an embedded sub-object (its bareContentFormula) respectively — the latter reading the sub-object's owncontent.xmldirectly out of the outer package's flatPackage.partsrecord, no separate unzip step needed;formula/detect.ts'scollectFormulaFrames/collectSlideFormulaFramesare genuinely new work with noodf.js-side equivalent at all —odf.js's ownreadDrawFrameContentdoesn't recognise adraw:object-bearingdraw:frameyet, soodt/read.tsandodp/read.tseach run one of these as a second pass over the same package's rawcontent.xmlto find and inject a formula's own embedded-object block.collectFormulaFramesis a deep walk (a frame directly in the container, one nested inside adraw:ggroup with that group's owndraw:transformcomposed exactly aswalkDrawShapescomposes it, and one anchored inline inside a paragraph's own run content);collectSlideFormulaFramesreplicatesodf.js's ownwalkDrawShapestraversal precisely so each formula'sContentShapeindex is derived rather than guessed. See the Gotchas entry below for where each detected formula's block actually lands. -
src/markdown/— a third, independent counterpart tosrc/ooxml//src/odf/, resolving markdown text into aContentDocumentvia the externalmarkdown-codecdependency rather than a package format:read.ts'sreadMarkdownContentis a thin adapter overmarkdown-codec's ownreadMarkdown, re-stampingdocuments.js's ownCONTENT_FORMAT_VERSIONonto a fresh envelope (markdown-codec'sreadMarkdownalready produces a fulldocument-schema.jsContentDocument, structurally identical to but nominally separate from this package's local one) — mirroringreadOdtContent/readDocxContentexactly, and the concrete third proof (after odt/docx) that this pivot and layout engine are genuinely format-agnostic.write.ts'sbuildMarkdownTextis the reverse, a thin wrapper overmarkdown-codec's ownwriteMarkdown— deliberately living besideread.tsrather than undersrc/edit/markdown/, sinceMarkdownEditor(src/edit/markdown/editor.ts) calls it directly as its owntoMarkdownTextrather than this module reaching back intosrc/edit/.MarkdownEditordoes now exist, alongsideDocxEditor/OdtEditor/etc., but it holds a mutable in-memoryContentDocumentrather than a realXmlElementtree inside a decodedPackage— markdown has no such tree at all — so everyMarkdownParagraph/MarkdownRun/MarkdownTable/MarkdownTableCellit produces holds a direct reference into that plain object instead, and saving is nothing more than callingbuildMarkdownTextagain.text.ts'sdecodeMarkdownText/encodeMarkdownTextare the byte↔text boundary neitherreadMarkdown/writeMarkdownnormarkdownCodec's ownMarkdownBytesSchemasit on (both operate on strings, not bytes) — the step every bytes-in/bytes-out ergonomic conversion inconvert.tsneeds, using a fatal-modeTextDecoderso a non-UTF-8 input throws immediately rather than silently producing replacement characters. -
src/layout/— the pure conversion algorithms, importingmodel, (for formula placement)mathml, and — for line-wrapping/pagination itself — several primitives sourced from the externalpdf-codecdependency: the injectedTextMeasurerport andwrapRunsToWidth(pdf-codec's ownmeasure.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(pdf-codec's ownmath-font.ts, for formula placement), pdf-codec'smatrix.ts'srotatePointAboutCenter(slides.ts's own shape-rotation placement), and pdf-codec'safm-widths.ts/fonts.ts'sSTANDARD_METRICS/resolveStandardFont(reconstruct.ts's own font-matching when reconstructing from aLayoutDocument) — this package's one dependency on external font/text-measurement primitives, since text layout is inherently coupled to the one font model (pdf-codec's own standard-14 resolution) every conversion direction ultimately renders through:engine.ts(ContentDocumentwordprocessing →LayoutDocument: flow, line-breaking, pagination — fed identically by docx-, odt-, and markdown-sourced content; also returnsWordprocessingLayoutResult.formulas, every embedded formula block it laid out viasrc/mathml'slayoutFormula, positioned in PDF page space — see the Gotchas entry below on why a formula can't become an ordinaryLayoutItem),slides.ts(ContentDocumentpresentation →LayoutDocument: direct EMU-to-point placement, no pagination needed — fed identically by pptx- and odp-sourced content; also exportsconvertShape, the single-ContentShape-to-LayoutItem[]conversiondrawing.tsbelow reuses verbatim, now optionally formula-aware via its own trailingformulaContextparameter sodrawing.ts's existing call site keeps compiling unchanged),sheets.ts(ContentDocumentspreadsheet →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" guaranteeengine.ts'sensureRoomdocuments, emit pages indownThenOver/overThenDownorder, then per page paint cell backgrounds/gridlines/cell borders/headers/cell text, honouring a cell's ownalignment/verticalAlignmentwhere it declares one and falling back to the value-kind default and bottom where it doesn't, with###/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts anAbortSignal, since a 50k-cell sheet needs cancellation where a docx/pptx page count never did; also returnsSpreadsheetLayoutResult.formulas, every cell-anchored embedded formula it laid out viasrc/mathml'slayoutFormula, resolved against the anchor cell's own already-positioned axis geometry plus the frame's cell-relative offset and positioned in PDF page space — the sheets-side counterpart toengine.ts's andslides.ts's own formula output),drawing.ts(ContentDocumentdrawing →LayoutDocument: oneContentDrawPageper PDF page, direct placement likeslides.ts, with one new emission path — an unrotatedContentVectorrect/ellipse/linemaps onto the pre-existingLayoutRect/LayoutEllipse/LayoutLinekinds, apathvector's local, viewBox-relative subpath points are resolved through the vector's own frame offset then a single page-space flip into aLayoutPathvalue, and a rotated rect/ellipse/path becomes aLayoutPathof rotated points since neitherLayoutRectnorLayoutEllipsemodels rotation; the page'sshapesandvectorsare merged into one true-paint-order walk through their sharedpaintOrderfield rather than painted as two sequential arrays),reconstruct.ts(LayoutDocument→ContentDocument:reconstructWordprocessing/reconstructPresentationdo baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs;reconstructDrawingdoes no clustering at all, since a drawing has no such structure to infer in the first place — everyLayoutItemmaps close to 1:1 back onto aContentVectorrect/ellipse/line/pathor aContentShape, in the exact z-order it was painted, bucketed intoContentDrawPageSchema's ownshapes/vectorsarrays with each item's walk position stamped as itspaintOrder, so the relative order between the two arrays survives;reconstructSpreadsheettries a real gridline lattice first — scanning the page'sLayoutLine/stroked-single-segment-LayoutPathitems 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'sclusterIntoLinesfor rows and a parallel recurring-x-position generalisation ofclusterIntoParagraphs's owndominantLeftXfor 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.odbdecoders, 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)extractsCREATE TABLE's own column names/types andINSERT INTO's own row values intoHsqldbTable[], tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwingHsqldbScriptParseErrorfor 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()switcheshsqldb.default_table_typetocachedspecifically for storage-backed access, confirmed against the decompiled engine source) — a CACHED table's DDL still lives indatabase/scriptas ordinary TEXT (Tier 1 parses it unmodified) but its row data lives in a separate binary page-cache file,database/data.rowformat.tsdecodes one column's own binary field at a time (HsqldbDataCursor, a big-endianDataViewcursor;readHsqldbColumnValue, one branch per SQL type code);cache.tswalks a table's own AVL row-position tree (readHsqldbCachedTableRows, following each row's persistediLeft/iRightchild 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 positionparseHsqldbIndexRootsrecovers from each table's ownSET TABLE ... INDEX'...'script line, usingparseHsqldbProperties's reading ofdatabase/properties(cache-file scale, engine version) to resolve byte offsets;decodeHsqldbCachedTablesis the orchestrationsrc/odb/read.tscalls, 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.binary-script.ts(Tier 4): HSQLDB's own whole-script BINARY (hsqldb.script_format=1) and COMPRESSED (=3) serialisations ofdatabase/scriptitself —parseHsqldbBinaryScriptreads the leadingorg.hsqldb.Resultrecord carrying the database's DDL, rejoins its statements into exactly the TEXT-format script text the same database would have written atscript_format=0, feeds that to Tier 1, and then decodes the per-table row sections that follow throughrowformat.ts's existing per-column decoder;inflateHsqldbCompressedScriptis the zlib unwrap=3needs first,fflate'sunzlibSync, the one place insrc/hsqldb/with a dependency beyonddocument-schema.js. All tiers mirror pdf-codec's own isolation discipline:script.tsimports onlydocument-schema.js'sContentCellValuetype;rowformat.tsimports the same plus nothing else;cache.tsimports only those two andscript.ts's own types — no odf.jsPackage/XmlElementknowledge anywhere insrc/hsqldb/— the caller is responsible for handing every function its raw bytes/text already extracted from a real.odbpackage.HsqldbTable/HsqldbColumnare also the shared pivot shapesrc/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.odbdecoder: a reader for Firebird's own gbak logical-backup format (database/firebird.fbk), the artifact a real Firebird-embedded.odbactually 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.tsholds the two distinct byte-level primitives the format mixes (FirebirdBackupReader, the generic little-endian tag+length+value attribute framing everyrec_*/att_*record uses, plus its own RLE/"PackBits"-style decompression foratt_data_datawhen 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.tsmaps a field's own BLR type opcode (att_field_type) onto its physical storage representation, sourced directly from Firebird's ownblr.h/align.h.date.tsrestates Firebird's own MJD-epoch DATE and 1/10000-second-tick TIME encoding, taken fromNoThrowTimeStamp.cpp.schema.tswalksrec_relation/rec_field(column definitions gbak has ALREADY resolved from the live engine's system tables at backup time — see the Gotchas entry).data.tswalksrec_relation_data/rec_data(a relation's own rows, addressed by name), decoding each row's XDR-and-possibly-RLE-compressed field-value sequence intoContentCellValue[].backup.ts'sreadFirebirdBackupis the top-level entry point, producing the identicalHsqldbTable[]shapeparseHsqldbScriptdoes. -
src/odb/— the decoder-selection and pivot-mapping layer sitting between odf.js's.odbsupport andsrc/hsqldb//src/firebird/:read.ts'sreadOdbTables(pkg)calls odf.js's ownreadOdbInventoryto classify the package's connection (throwingOdbNoEmbeddedDataSourceErrorfor an external-only datasource) and its embedded engine, then routes a genuine HSQLDB TEXT script toparseHsqldbScriptand a BINARY/COMPRESSED one tosrc/hsqldb/binary-script.ts'sparseHsqldbBinaryScript(which recovers the identical TEXT-format DDL either way), then — whenever adatabase/datapart is present — hands that result tosrc/hsqldb/cache.ts'sdecodeHsqldbCachedTablesto splice in every CACHED table's real rows (a.odbwith no CACHED table at all, the common case, never even looks fordatabase/data, leaving the script-derived result untouched), or routes a Firebirddatabase/firebird.fbkpart toreadFirebirdBackup— throwingOdbUnsupportedFormatErrorfor an embedded engine, or an engine storage shape, it has no reader for at all.spreadsheet.ts'sodbTablesToSpreadsheetDocumentmapsHsqldbTable[]onto the sameContentSheet-basedContentDocumentspreadsheet variantreadOdsContent/buildOdsPackagealready produce and consume, feedingodbToXlsx's call intobuildXlsxPackagedirectly.csv.ts'sbuildOdbTableCsvwrites exactly one named table as CSV bytes, with noContentSheet/xlsx machinery involved at all, throwingOdbTableNotSpecifiedError/OdbTableNotFoundError(naming every available table) when the caller's owntableoption doesn't resolve to exactly one table. -
src/odb/sql/— a bounded single-table SQLSELECTengine over theHsqldbTable[]src/odb/read.tsproduces, in four modules with a strictly downward dependency chain:errors.ts(the three failure classes —HsqldbSqlUnsupportedErrorfor real SQL this engine recognises and deliberately does not implement,HsqldbSqlParseErrorfor input that is not well-formed SQL under this grammar,HsqldbSqlEvaluationErrorfor a statement that parsed but cannot be executed faithfully against the data — each carrying the offending SQL text),lexer.ts(tokenizeSql: identifiers with SQL's own quoted/unquoted case rule,''- and""-escaped literals, numeric literals including a leading-dot and exponent form, the six comparison operators, and a symbol-level rejection list naming arithmetic,||, comments, parameter placeholders, and!=individually),parser.ts(parseSelect: a real recursive-descent grammar — see that module's own top-of-file production list — preceded by a single-pass scan that rejects every recognised out-of-scope construct by name, so a JOIN is reported as a JOIN rather than as a baffling unexpected keyword; an identifier immediately followed by(is necessarily a scalar function, since the five aggregates lex as keywords), andevaluate.ts(evaluateSelect: WHERE filtering under genuine three-valued NULL logic, projection, GROUP BY partitioning with the five aggregates, and a stable multi-column ORDER BY). It importsdocument-schema.js'sContentCellValue,src/hsqldb/script.ts'sHsqldbTabletype, andsrc/odb/values.ts(below) and nothing else — no odf.jsPackageknowledge, no PDF knowledge, mirroringsrc/hsqldb/'s own isolation discipline. There is no write direction: this engine reads SQL, it never generates it. -
src/odb/values.ts— theContentCellValuecomparison and aggregation semanticssrc/odb/sql/andsrc/odb/formula/share:cellComparisonKey/compareCellKeys/compareCellValues(values compare within three classes — numeric, boolean, text — and never across them),cellValuesEqual(the total counterpart, since a cross-class pair is unambiguously unequal where an ordering comparison has to throw; this is whatrpt:HASCHANGEDneeds), andaggregateCellValues(the five aggregates over SQL's own NULL-skipping rules). Both engines implement the identical five aggregates over identical inputs, so the semantics live here once rather than in each — a fix to one would otherwise silently leave the other wrong. What it deliberately does not own is which error a violation raises: every function takes afailfactory and throws what the caller builds, so the same comparison failure surfaces as anHsqldbSqlEvaluationErrorquoting the statement or anRptFormulaEvaluationErrorquoting the formula. -
src/odb/formula/— a LibreOffice Report Builder rpt formula engine over the result setsrc/odb/sql/produces, in four modules:errors.ts(the same three-class policy as the SQL engine —RptFormulaUnsupportedErrornaming a genuine Report Builder function outside the implemented set,RptFormulaParseErrorfor text that is not a well-formed formula,RptFormulaEvaluationErrorfor one that parsed but cannot run against the report's own data — plusRptReportStructureErrorfor a failure about the report rather than any one formula),parser.ts(parseRptFormula: a self-contained recursive-descent scanner with no separate lexer, since this language has no keyword vocabulary or operator precedence to keep out of the grammar —field:[X]andrpt:NAME(arg{;arg}), with[NAME]and"NAME"as one reference concept and a semicolon argument separator),evaluate.ts(runRptReport: the group-break cascade, group instance ranges, and per-band formula evaluation, the substance of the engine — see the Gotchas entry below), anddefinition.ts(rptDefinitionFromReport: the only file here that knows odf.js's ownOdbReportshape, flattening its nestedrpt:grouptree into the outermost-first chain the evaluator's level-indexed scoping assumes).evaluate.ts/parser.tsimportdocument-schema.js'sContentCellValue,src/odb/sql/'sSqlResultSettype, andsrc/odb/values.tsonly — the same isolation disciplinesrc/odb/sql/follows, with odf.js knowledge quarantined indefinition.tsexactly assrc/odb/read.tsquarantines it for the decoders. There is no write direction here either: this engine reads formulas, it never generates them. -
src/odb/report/— the renderer that turns everything above into a document, in three modules matching the three questions rendering a report actually poses:source.ts(odbReportCommandSql/resolveOdbReportRows: what data does this report bind to? — therpt:command/rpt:command-typetriple of table name, saved-query name, and inline SQL, all three resolved to one statement run throughsrc/odb/sql/, so an unknown table fails with that engine's own message naming every table the.odbreally has rather than through a second resolution rule that could disagree with it),render.ts(renderOdbReportContent: what does a printed band look like as content? — one single-rowContentTableper band instance, one cell per control, the same shape the band has in the file itself, plus the two page bands the formula engine deliberately never emits, evaluated here throughevaluateRptBandOutsideDataunder this renderer's own single-logical-page model), andcontent.ts(readOdbReportContent: the composition, plusOdbReportNotSpecifiedErrorfor a package declaring no report or more than one with none named — mirroringcsv.ts's own table-selection convention).render.tsis the only module here that knows what aContentDocumentis, andsource.tsthe only one that reads aPackage; both flattening the report'srpt:grouptree and evaluating a band's formulas aresrc/odb/formula/'s (via that module's exportedodbReportGroupChain, so a band instance's own group level and theOdbReportGroupits controls come from can never index different chains). There is no reverse direction: aContentDocumentholds a report's output, not the band/group/formula design that produced it. -
src/convert/—convert.ts(the fourteen PDF-pivot round-trip ergonomic wrappers — docx/pptx/odt/odp/ods/odg each with a genuine layout-engine edge,xlsxToPdf/pdfToXlsxcomposing the ods⇄xlsx bridge with the ods⇄pdf layout pair internally, andmarkdownToPdf/pdfToMarkdownreusing the wordprocessing layout engine directly — plus a dedicated "cross-format bridges" section, ten functions across five pairs:odtToDocx/docxToOdt,odpToPptx/pptxToOdp,odsToXlsx/xlsxToOds, andmarkdownToDocx/docxToMarkdown,markdownToOdt/odtToMarkdown, each a directreadXContent→buildYPackagecomposition bypassing PDF entirely — see Fidelity —odmToPdf, the one further conversion shaped around a caller-suppliedresolveSubDocumentcallback rather than being purely bytes-in/bytes-out, since a.odmmaster document's own chapters are external references odf.js'sreadOdmnever inlines — see Gotchas —odbToXlsx/odbToCsv, thin compositions overreadOdbTablesandsrc/odb/'s own pivot/CSV mapping, andodfToPdf, a standalone.odfformula document → PDF viareadOdfFormulaContent→src/mathml'slayoutFormula→writePdf's own formula-aware option, with no reversepdfToOdfat all),codec.ts(docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec/xlsxPdfCodec/markdownPdfCodecplusodtDocxCodec/odpPptxCodec/odsXlsxCodec/markdownDocxCodec/markdownOdtCodec, az.codec()pair over each —odmToPdf/odbToXlsx/odbToCsv/odfToPdfhave 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 swappableDocumentConvertercontract and its synchronous local implementation, coveringdocx/pptx/odt/odp/ods/odg/odf/xlsx/markdown→pdf,pdf→docx/pptx/odt/odp/ods/odg/xlsx/markdown, and the ten bridge functions —DocumentFormatincludesxlsxeven though xlsx has no PDF conversion of its own (the port composes one, seexlsxToPdf);odmandodbare deliberately notDocumentFormatmembers, since neitherodmToPdfnorodbToXlsx/odbToCsvis wired into this port at all;odfIS a member, but with only the oneodf → pdfentry — nopdf → odf). Every conversion function that builds aContentDocument/LayoutDocumentinternally (the fourteen PDF-pivot conversions and the ten bridges;odfToPdfaccepts but never invokes it) also accepts anonDocumentcallback, andConversionResultcarries the same value through the port as an optionalpackagefield — the fullDocumentPackage(content + layout, fromdocument-schema.js) that conversion built, not just its target bytes.ConversionOptionscarriesfonts/onFontSubstitutionalongsidesignalfor the same reasonDocumentToPdfOptionsdoes (see Fonts), reaching only thetoPdfedges — a PDF-to-X reconstruction reads a page's already-positioned glyphs and a bridge runs no layout engine, so neither resolves a face at all — and the local implementation reports every substitution as afont/substituteddiagnostic as well as through the caller's own callback.
Dependency direction among this package's own local modules is downward and checkable, with one deliberate exception (layout, noted below): mathml/ports import nothing local (mathml is fully self-contained — no dependency on model, document-schema.js, or any ODF package, since it consumes only its own locally-mirrored MathMlNode input and its own injected MathFontMetrics port); model imports nothing local at all any more — formula.ts's former type-only MathMlNode import from mathml is gone with the local EmbeddedFormula type it served, since document-schema.js now owns a fully-specified MathMlNode of its own; ooxml/* imports no local module at all (now a thin adapter over ooxml.js's own readDocx/readPptx — see the src/ooxml/ entry above — with no model/xml/* dependency of its own left, since ContentDocument/CONTENT_FORMAT_VERSION now come straight from document-schema.js; no PDF knowledge either); odf/* imports model only, and only for formula.ts's block/document builders and geometry.ts's Box/PAGE_SIZE_A4 (its own ContentDocument/CONTENT_FORMAT_VERSION usage is document-schema.js-direct too now — no PDF knowledge, no xml/* — odf.js already owns its own XML query helpers); markdown imports model only, and only for formula.ts's stand-in text on the write side (write.ts flattens a formula block markdown cannot represent), plus the external markdown-codec dependency directly (no PDF knowledge, no odf.js/ooxml.js knowledge at all — the one adapter package in this family whose source format is not a zip archive); omml imports mathml (its node helpers, operator dictionary, mathvariant type, and length parser) and xml/* (fragment.ts's el/txt, entities.ts's encodeXmlText) only, plus ooxml.js for its own XmlElement output type — never model, layout, or any ODF package, and never in the other direction: mathml still imports nothing local at all, which is exactly why this translator is a sibling of it rather than a file inside it; hsqldb imports document-schema.js only (no odf.js knowledge); firebird imports document-schema.js (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 and outward, several text-measurement/font-metric/matrix primitives from the external pdf-codec dependency (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); fonts imports no local module at all either — only ooxml.js/odf.js for the two package shapes it reads and pdf-codec for the ProvidedFont/FontRegistry shapes it produces, so it sits beside layout rather than under it despite both feeding the same conversion; odb imports hsqldb+firebird+model+odf-package+odf.js only, and its own odb/sql and odb/formula subtrees import strictly less than that — odb/values.ts plus document-schema.js's ContentCellValue plus hsqldb's HsqldbTable type for the former, and odb/values.ts plus ContentCellValue plus odb/sql's SqlResultSet type for the latter, with odf.js reaching odb/formula only through its one definition.ts adapter; odb/report is the one subtree that imports more than odb itself rather than less, since rendering is where the two halves finally meet — odb/sql, odb/formula, odb/read.ts, hsqldb's displayTextFor, model's PAGE_SIZE_A4, document-schema.js's content vocabulary, and odf.js's OdbReport shape — and it still keeps each of those to one module: Package reaches only source.ts/content.ts, and ContentDocument only render.ts; convert composes everything else, including fonts and pdf-codec directly for readPdf/writePdf/loadMathFont/createFontMeasurer/createFontRegistry and markdown-codec indirectly via markdown/read.ts/markdown/write.ts/markdown/text.ts. Beyond this package's own local modules, five external dependencies each own a distinct concern with no overlap: ooxml.js (docx/pptx/xlsx ⇄ JSON), odf.js (odt/ods/odp/odg ⇄ JSON), document-schema.js (the shared ContentDocument/LayoutDocument schemas), pdf-codec (the PDF codec itself, plus the text-layout/font-resolution/byte/image primitives built on it), and markdown-codec (CommonMark+GFM ⇄ ContentDocument). No PdfObject/PdfDict/PdfStream type appears anywhere in this package at all — that type is pdf-codec's own internal concern now, never exposed across the package boundary.
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), 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), a real odsToPdf conversion of a sheet carrying a cell-anchored formula (the same font-resource check, plus asserting convertSpreadsheetToLayout's own reported position lands at the anchor cell rather than the sheet's origin), a real markdownToPdf/pdfToMarkdown round trip plus a markdownToDocx bridge exercise, and real font resolution in docxToPdf (a Calibri run producing a genuine /Type0/Identity-H/CIDFontType2/FontFile2 Carlito font program, alongside an Arial control run that embeds nothing at all), from the built CJS bundleThe optional real-world PDF conformance corpus (test:corpus in the family's earlier layout) now lives in pdf-codec's own repository, since it exercises the PDF codec directly rather than anything docx/pptx/odt/odp/ods/odg-specific — see that package's own README.
To run a single test file: pnpm vitest run src/path/to/file.test.ts.
- Zod-first schema/type/guard, matching
ooxml.js: every model type is inferred from its Zod schema, never hand-written.ContentBlock(recursive, mirroringooxml.js's ownXmlNodetreatment) uses a hand-written structural guard +z.custom, notz.lazy, which collapses tounknownfor recursive element-children in the pinned Zod version. z.codec()for every schema-to-schema round trip, matchingooxml.js'spackageCodec/xmlCodec:pdfCodec(PDF bytes ⇄LayoutDocument),docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec(docx/pptx/odt/odp/ods/odg bytes ⇄ PDF bytes), andodtDocxCodec/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/xlsxToOdsremain the primary entry points wherever a caller needs anAbortSignal, aPdfDiagnosticSink, or anonSubstitutioncallback, sincez.codec()'s fixeddecode(input)/encode(output)signature has no room for side-channel options.PdfObjecthas no Zod schema at all, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser — validating it would just be validating our own output. It narrows natively on its ownkinddiscriminant instead, the same reasoningooxml.jsapplies when it picks a hand-writtenisXmlNodeguard overz.lazy.- No type assertions anywhere. Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
- Live views, not flatten-and-regenerate.
src/edit/*'s editor classes hold a reference directly into the realPackage/XmlElementobjects; saving isencodePackage(pkg), nothing more. This is what makes "everything you didn't touch stays byte-faithful" a structural guarantee rather than a best effort. - A three-tier PDF-read failure policy governs everything
readPdfreports back through its ownPdfDiagnosticSink— throw for a file that cannot be meaningfully processed at all, recover-with-diagnostic for something malformed but salvageable, degrade-with-diagnostic for an individual unsupported feature while the rest of the document still reads. This policy is pdf-codec's own convention now, applied consistently across every one of its read modules — see that package's own README for the full statement. - Conventional commits, enforced via commitlint + husky, matching
ooxml.js.
-
ooxml.js's typed readers (readDocx/readPptx) are now the actual basis for conversion —readDocxContent/readPptxContentare thin wrappers around them, not an independent walk ofword/document.xml/ppt/slides/slideN.xml. They are still deliberately not re-exported from this package's own public surface: 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.readDocx's owncomments/footnotes/headers/footers/numbering— the fieldsContentDocumentdoesn't model at all — are not lost, though:readDocxExtras(src/ooxml/docx/extras.ts) exposes that exact data as its own realDocxExtrasreturn type, re-exported from this package's own surface alongsideComment/Footnote/NumberingDefinitions(ooxml.js's own types, reused directly).readPptxhas no equivalent extras reader yet — pptx's own comments/notes-master data was not in scope for this pass. -
ODF paragraph/heading text content is not a plain string the way a docx run's
w:tis, 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 calldecodeOdfText(src/xml/odf-text.ts) — neverooxml.js's owntextContent(), a plain text-node concatenation with no ideatext:s/text:tab/text:line-breakexist.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.decodeOdfTextdelegates the real work entirely toodf.js's owndecodeOdfText(wrapped in a synthetic container element, sinceodf.js's version operates on a wholeXmlElement'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, sinceodf.jsis 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 ownpackageCodec, which is byte/part-faithful by design. See Fidelity. The five cross-format bridge pairs below (odtToDocx/docxToOdt,odpToPptx/pptxToOdp,odsToXlsx/xlsxToOds,markdownToDocx/docxToMarkdown,markdownToOdt/odtToMarkdown) are a genuinely different case — see the Fidelity section's own paragraphs on them (the first three pairs carry no lossiness of their own at all; the two markdown pairs are a nuanced middle case — see that section for exactly why). -
A
DocumentPackagereturned viaonDocument/ConversionResult.packageis a snapshot from that one conversion pass, not a live view — itslayoutcorrelates with itscontentonly as of the exact read+layout that produced it (document-schema.js's ownDocumentPackageSchemadoc comment), so if a caller mutates the returnedcontentafterwards, thelayoutsitting alongside it silently goes stale; nothing in this package (ordocument-schema.js) detects or rejects that. -
Building the six cross-format bridges surfaced two real, previously-undiscovered gaps in existing
populateParagraphwrite paths, both now fixed.buildDocxPackage'spopulateParagraph(src/edit/docx/content.ts) never wrote a paragraph's ownlistmembership back (ContentParagraph.list, docx's flatnumId/levelmodel) — only read, never written, since no existing caller had ever round-tripped a list-bearing paragraph through it.buildOdtPackage'spopulateParagraph(src/edit/odt/content.ts) never wrote a paragraph's ownstyleIdback at all (readOdtContent/readOdfParagraphinodf.jsreads it unconditionally fromtext:style-name, but nothing on the write side ever set that attribute). Both are now fixed:DocxParagraph.listis set unconditionally alongsidestyleId/alignment, matching that function's own existing pattern;OdtParagraph.styleIdis set conditionally alongsidealignment, matching odt's own local convention.buildOdtPackageadditionally gainedappendBlocks/appendListRun(src/edit/odt/content.ts) — ODF has no flat per-paragraph list property to set the way docx does, so a run of consecutiveContentParagraphs sharinglist.numIdis grouped and written as a real, potentially multi-leveltext:list/text:list-itemtree viaOdtList/OdtListItem, the structural inverse ofodf.js's own list-reading (a freshtext:listpernumIdchange, one level of nesting perlist.levelstep, 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 throughdocx ⇄ odtat all — the PDF-pivot conversions never exercisedbuildDocxPackage/buildOdtPackageon content read back from the OTHER format. -
A table shape inside an odp/pptx slide (a
draw:frame/p:graphicFramewhose own content is a table, not inside a text box) now survivesodpToPptx/pptxToOdpboth ways.buildOdpPackage/buildPptxPackage's ownappendShape(src/edit/odp/content.ts,src/edit/pptx/content.ts) used to silently drop 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 was true;odpToPptx/pptxToOdpare two further, non-PDF-reconstructed callers for which it was not. Fixed byOdpSlide.addTable/PptxSlide.addTable(see thesrc/edit/Architecture entry above for the mechanics, including the third distinct merge convention DrawingML tables use) — verified bysrc/convert/bridges.test.ts's own round trip against theminimalOdpBytes()fixture. -
The
ods ⇄ xlsxbridge's fidelity improved substantially withooxml.js2.6.1's full xlsx number-format engine, but it is still not perfect.buildXlsxPackagenow writes a real numFmt per semantic kind — a"0.00%"-family format forpercentage, a"[$CODE]#,##0.00"-family format forcurrency(the ISO currency code embedded in the format code itself, since xlsx has no dedicated currency cell type), a date-only format fordate, and a"TRUE";"TRUE";"FALSE"format forboolean— andreadXlsxContentreads the format code back to recover the real kind, so an odspercentage/currencycell now survives theodsToXlsxhop with BOTH its value and its semantic kind intact (currency's own ISO code included), and a boolean cell now displays asTRUE/FALSErather than a raw1/0when opened in a real spreadsheet application. xlsx still has only one combinedt="d"wire type for both date and time, but the number-format engine can now tell a date-only format from one that also carries a time component, so an odsdatecell round-trips as genuine'date'rather than a catch-all'dateTime'. An odstimecell has no numeric serial to write at all — its ownContentCellValuecarries an ISO-8601 duration STRING, not a fractional-day number — sobuildXlsxPackagewrites it as a plainstringcell instead of mangling it into a nonsensical date/time value; the value string still survives byte-for-byte, honestly labelled as text. Column widths survive theodsToXlsxhop within roughly a pixel of rounding tolerance (seesrc/convert/bridges.test.ts's ownCOLUMN_WIDTH_TOLERANCE_PT) and now survive the returnxlsxToOdshop too, within double that tolerance —buildOdsPackage(src/edit/ods/content.ts) writesContentSheetColumn.widthPtfor real viaOdsSheet.setColumnWidth. A formula (table:formula/<f>) is still 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:formulaon 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 viareadXlsxContent— 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.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, confirmed permanent rather than an open question: ODF'soffice:value-typeenumeration simply has noerrormember — verified against real LibreOffice 26.2 output, a genuine#DIV/0!formula cell serializes asoffice:value-type="string"with an EMPTYoffice:string-value, the error text surviving only in the cell's owntext:p/displayText, never in anyoffice:value-type-driven wire value. The one place the string"error"appears anywhere in the format is LibreOffice's owncalcext:value-type="error"extension attribute, a private, unstable vendor namespace outside the OASIS ODF 1.3 spec — the identical category of escape hatchodf.js's owntyped/shared/table.tsalready declined forloext:graphic-properties/@draw:fill-colorover the standardfo:background-color, and declined here for the same reason: this package's own convention is OASIS-spec-grounded, not vendor-extension-chasing, and a private namespace a future LibreOffice release can rename or drop is not a foundation to build a public API's data fidelity on.OdsCell.value's own write-side choice for akind: 'error'cell is consequently to write it as a genuine, non-emptyoffice:string-valuecarrying the error's own text — anxlsxToOds→odsToXlsxround trip of a genuine xlsxt="e"error cell therefore turns it into a plainstringcell carrying the identical text; the message survives, theerrorsemantic does not, and no mechanism inside or outside the ODF spec can preserve it. This is a permanent format-boundary limitation, not a gap eitherodf.jsor this package could close by implementing something — there is nothing standards-based left to implement. -
odpToPdf/pdfToOdpneeded zero new layout code.readOdpContent(src/odf/odp/read.ts) produces the identicalpresentationContentDocumentshapereadPptxContentdoes, so it feedsconvertPresentationToLayoutunmodified — including the existing hidden-annotation speaker-notes mechanism below, which carries odp'spresentation:notesthrough to the PDF with no new notes-handling code at all;pdfToOdpreusesreconstructPresentationunmodified too, the same architectural betpdfToOdtalready proved forreconstructWordprocessing. The genuinely new work for the reverse direction was the live-view editor itself (src/edit/odp/*) — see Architecture above. -
OdpShape.rotationDegwrites a realdraw:transform, built onodf.js's own transform machinery. It is the write-side inverse ofodf.js'sresolveOdfShapeGeometry(typed/shared/transform.ts), built on that module's own exportedapplyOdfTransformrather than a hand-rolled rotation matrix, so it inherits that module's own empirically-verified rotate/translate composition order and sign convention by construction.buildOdpPackagewrites a rotated shape's rotation back correctly — verified both by this package's own tests and by opening a fresh, editor-built.odpin actual LibreOffice.PptxShape.rotationDeg(see thesrc/edit/Architecture entry above) is the DrawingML-side counterpart, and a rotated shape now round-trips throughodpToPptx/pptxToOdpboth ways too (src/convert/bridges.test.ts's own dedicated rotation test). -
readPdfrecovers a rect, an ellipse, and a line as their ownLayoutRect/LayoutEllipse/LayoutLinekinds, not merely as generic paths — pdf-codec's own shape-pattern detection, and the reason every vector kind now survives aodgToPdf→pdfToOdground trip. PDF has exactly one shape operator (re, itself defined as a four-point rectangle subpath) and no ellipse or line operator at all, so a writer has no way to record what a path was; pdf-codec recovers it from the geometry instead — an axis-aligned closed four-corner subpath is aLayoutRect(any combination of fill and stroke, and a 90°-rotated CTM as well as an unrotated one), a closed subpath of four cubic segments meeting its bounding box at the four cardinal points with kappa-ratio control points is aLayoutEllipse, and an open single-straight-segment stroke-only subpath is aLayoutLine. See pdf-codec's own README for the tolerances and the honest caveat that these are bounded heuristics: a false positive can change an item's kind, never its geometry. What still narrows to a genericLayoutPath: an off-axis rotation, a freeform curve, a multi-subpath figure. A practical consequence forpdfToOds: a gridline written bysheets.ts's ownrenderGridlinesnow comes back as a realLayoutLine, butreconstructSpreadsheet's lattice detection still accepts the stroked-single-segmentLayoutPathshape too, so a hand-builtLayoutDocumentand one from a producer other thanreadPdfdetect identically. -
pdfToOdsre-types a recovered cell heuristically, and this is explicitly PROBABILISTIC BEST-EFFORT RECOVERY, not a fidelity guarantee. A rendered PDF genuinely never carries a spreadsheet cell's own typed value — a page holds only the string the authoring application chose to print — so every re-typed value below is an inference from that string alone, and a string that looks exactly like a number may genuinely have been a string in the source spreadsheet (a part number, a version, a phone extension). Nothing in this package can tell those apart with certainty, and no further heuristic would change that. What is guaranteed:ContentSheetCell.displayTextis a required field carrying the rendered string verbatim regardless of what was inferred from it, so the printed form is never lost.src/layout/cell-typing.tsre-types only where the string has exactly one defensible reading, which resolves to four concrete requirements: the decimal must be exactly representable as a JS number (checked by round-tripping it, not by a digit-count limit — this is what keeps a 19-digit barcode a string); the separators must be unambiguous (.reads as the decimal separator and,as grouping, but a lone comma group like"1,234"is declined, since the competing European reading of the identical string is 1.234, a thousandfold error —"1,234,567"and"1,234.50"have no such competing reading and are accepted); a leading zero ("007","01.5") is declined outright, since a spreadsheet never prints a numeric value with one; and a date's component roles must be stated by the text itself — ISO ordering ("2024-01-15") or a named month ("15 Jan 2024","Jan 15, 2024") is accepted, an all-numeric separated date ("01/02/2024") is declined regardless of whether one component happens to exceed 12 in that particular cell, because resolving it per cell would type one column inconsistently.TRUE/FALSEare re-typed as booleans;Yes/No/Y/N/On/Offare declined, since no mainstream spreadsheet prints a boolean that way by default, so a"Yes"cell is far more likely genuine text. Percentages recover ODF's own fraction convention ("15%"→0.15); a currency symbol names an ISO code only where it identifies exactly one (£→GBP,€→EUR;$and¥re-type ascurrencywith the code leftundefined).'time'/'dateTime'/'error'are deliberately out of scope, and a formula is never claimed. Two ways to tell an inferred value from an untouched one:value.kind !== 'string'is itself the flag, andReconstructOptions.onCellTypeInferencereports every decision — both a re-typing (with the rule that fired) and a deliberate refusal (with the named ambiguity), the latter being information the output alone cannot carry, since a declined cell is indistinguishable from one that was never number-shaped at all.inferCellValueis exported standalone for a caller who wants to replay the same decision over their own text. -
reconstructWordprocessing/reconstructPresentationrecover a page's vector primitives too, in a nested drawing document, and all four OOXML/ODF builders now write them out as real shapes. Both directions used to filter each page down to its text and image items and discard every stroke and fill; they now run the samelayoutItemToVectorclassificationreconstructDrawingdoes (one implementation, not two) and carry the result in aContentEmbeddedObjectBlockwhoseobjectKindis'drawing'and whose nesteddocumentis a real one-page drawingContentDocument— the shared schema's own designed mechanism, sinceContentSection.blocksandContentSlide.shapeshave no vector vocabulary of their own. One honest consequence of the recovery itself: a PDF does not distinguish a stroke drawn to decorate from one drawn as structure, so a rule under a heading, an underline (pdf-codec writes one as a filled rectangle), and a table cell's background fill are all recovered as vectors — that is intended, since discarding real content because it might be incidental is exactly the silent loss this package's conventions rule out, but it does mean a reconstructed document carries more than its text alone. A table's own gridlines are the one case deliberately not double-counted: when the table recovery above claims a lattice, the strokes that formed it are excluded from vector recovery. On the write side,buildDocxPackageandbuildPptxPackageemit real DrawingML (a:prstGeom prst="rect"/"ellipse"/"line", and a genuinea:custGeomwitha:moveTo/a:lnTo/a:cubicBezTo/a:closefor a path), andbuildOdtPackage/buildOdpPackageemit realdraw:rect/draw:ellipse/draw:line/draw:path— see the dedicated write-side gotcha below for how each format wraps them.pdfToMarkdownstill drops the block entirely rather than emitting a marker for it: a rect carries no text to stand in for, unlike a formula, and CommonMark has no vector construct regardless. -
A recovered vector reaches the output FILE for all four formats, but not back through this package's own readers.
buildDocxPackage/buildPptxPackage/buildOdtPackage/buildOdpPackageall write real vector shapes now, so apdfToDocx/pdfToPptx/pdfToOdt/pdfToOdpoutput opens in Word, PowerPoint, or LibreOffice with the recovered geometry in place. Re-reading that same file throughreadDocxContent/readPptxContent/readOdtContent/readOdpContentdoes not give the embedded-drawing block back: those four are thin adapters overooxml.js'sreadDocx/readPptxandodf.js'sreadOdt/readOdp, none of which reads vector geometry into aContentDocumentat all — and for the ODF pair,ContentSection.blocks/ContentSlide.shapeshave no vector vocabulary to read one into regardless. The practical consequence: the six PDF-bypassing bridges (odtToDocxand friends) carry no vectors across, since a bridge isreadXContent→buildYPackageand the read half drops them. Closing this is reader-side work — the OOXML/ODF mirror of the second passsrc/odf/formula/detect.tsalready runs for embedded formulas — a tracked, bounded gap, not a silent one. -
Each format wraps a vector shape in the construct that format actually has for one, and the two families each share one writer.
src/edit/drawingml/vector.tsholds everything inside a DrawingML shape-properties element (a:xfrm, the preset or custom geometry,a:solidFill/a:noFill,a:ln) — identical for docx and pptx, sinceCT_ShapePropertiesis one type in both. Only the wrapper differs: pptx gets a plainp:spon the slide'sp:spTree(nop:txBodyat all — a geometric primitive carries no text, and inventing an empty paragraph would makereadPptxreport a text shape where the source had pure geometry), while docx gets aw:drawing/wp:anchorpositionedrelativeFrom="page"on both axes, withbehindDoc="1"andwp:wrapNone, carrying awps:wspinsidea:graphicData— the wordprocessingShape extension part, the only DrawingML vocabulary WordprocessingML has for a non-picture shape (the pre-2010 alternative is VML, deprecated by ECMA-376 itself and deliberately not written). Anchoring rather thanwp:inlineis what preserves the recovered page-absolute coordinates; the honest limit is that the anchor still belongs to a paragraph, so which page the geometry lands on follows that paragraph if the document reflows differently in Word than it laid out in the source PDF. On the ODF side,buildOdtPackage/buildOdpPackageimportsrc/edit/odg/vector.ts's writer wholesale rather than reimplementing it —draw:rect/draw:ellipse/draw:line/draw:pathcarry byte-for-byte the same attribute vocabulary in a text document, a presentation, and a drawing, andodf.js's ownreadDrawPageContentreads all three through one function. odp appends them directly to the slide'sdraw:page(a slide positions geometry against the page, exactly as a drawing page does); odt anchors them in atext:pof their own withtext:anchor-type="paragraph"plusstyle:horizontal-rel/style:vertical-rel="page"in the graphic style, since a recovered vector's coordinates are page-absolute and a paragraph-relative reference would offset every shape by wherever its anchor paragraph flowed to.style:wrap="run-through"/style:run-through="background"are the ODF counterpart of docx'swp:wrapNone/behindDoc="1"pair. -
ContentStroke.style(solid/dashed/dotted/double) is not written by any vector writer, ODF or DrawingML. Nothing in this package produces one —LayoutLineandLayoutPathboth carry a stroke of colour and width only (document-schema.js'slayout.ts), so no reconstruction path can populate it — anda:prstDashhas nodoublemember to map the fourth value onto regardless. A hand-builtContentVectorsetting it consequently paints solid. A real, bounded gap rather than an oversight. -
pdfToOdsrecovers what was printed, not what was entered.reconstructSpreadsheet(src/layout/reconstruct.ts) tries a real gridline lattice first: it scans the page'sLayoutLine/stroked-single-segment-LayoutPathitems (see theinterpret.tsgotcha above) for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid (MIN_GRIDLINE_COUNT_PER_AXIS = 3per 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 reuseclusterIntoLinesverbatim (a spreadsheet cell's own text is never wrapped across lines, so a text line already IS a row), and columns generaliseclusterIntoParagraphs's own singledominantLeftXto several recurring x-position anchors, first merging directly-adjacent same-line fragments (splitLineByLargeGaps, the same >2em-gap signalreconstructPresentation's own block clustering uses) so a cell whose text arrived as several run-level-splitLayoutTextitems 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 always carries its own extracteddisplayTextverbatim, and additionally carries a heuristically re-typedvaluewhereversrc/layout/cell-typing.tsfinds exactly one defensible reading of that string; a formula is still never claimed. See the dedicated heuristic-re-typing gotcha below and Fidelity for the full framing.buildOdsPackage(src/edit/ods/content.ts) ispdfToOds's own package-building half, mirroringbuildOdtPackage/buildOdpPackage/buildOdgPackage's role forpdfToOdt/pdfToOdp/pdfToOdg. -
OdsSheet.printSettings(src/edit/ods/print-settings.ts) now round-trips every fieldContentSheetPrintSettingsSchemacarries, not just the five it started with.pageSize/margins/gridlines/headers/pageOrderresolve through thetable:style-name→style:style[family="table"]→style:master-page-name→style:master-page→style:page-layout→style:page-layout-propertieschain (odf.js's own exportedfindStyleElement/resolvePageLayoutProperties/parsePageSize/parseMargins); the setter mints a freshstyle:page-layout+style:master-page+style:style[family="table"]triple and repoints the sheet's owntable:style-nameto it on every call, the same append-only style-editing conventionsrc/edit/odg/style.tsalready documents. The remaining, previously-unimplemented fields are now implemented too:printRangereads/writestable:print-rangesdirectly ontable:table;scalePercent/fitToPagesread/writestyle:scale-toandstyle:scale-to-X/style:scale-to-Yon the page-layout-properties element;repeatColumns/repeatRowsare read viascanTableStructure, a scoped-down mirror ofodf.js's own privatereadTable's table-wide column/row cursor tracking (the same walk that function performs before ever calling its ownreadPrintSettings), and written by moving the realtable:table-column/table:table-rowelements covering the given range into a freshtable:table-header-columns/table:table-header-rowswrapper;manualBreaksread/writefo:break-before="page"on the named row/column's own style. WritingrepeatRows/repeatColumnsrequired teachingaddress.ts's row/column addressing that a row/column may now live nested one level inside a header wrapper rather than as a directtable:tablechild, so a subsequent cell/column/row write against a wrapped index finds the real element instead of creating a spurious duplicate outside it; the width/height and manual-break writers all target the samestyle:table-column-properties/style:table-row-propertieselement, so each reads the column/row's current style first and mints a fresh style carrying the merged result, rather than a naive single-property mint clobbering whatever an earlier call had already set. No known gap remains inContentSheetPrintSettingsSchemacoverage. -
OdsSheetnow has a real column-width/row-height setter (setColumnWidth/setRowHeight,src/edit/ods/column-row.ts), closing a gap that escalated from cosmetic to a genuine correctness bug oncexlsxToPdf/pdfToXlsxstarted composing throughbuildOdsPackageinternally.OdsSheet.cell()'s own column/row-materialisation (address.ts) creates a real, explicittable:table-column/table:table-rowelement for any position a caller ever addresses, but previously never gave it a width/height style. This is a genuinely different failure shape from a column/row with NO element at all:sheets.ts's ownresolveAxisonly falls back toDEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PTfor an index with noContentSheetColumn/ContentSheetRowentry whatsoever — an explicit-but-unstyled element reads back atwidthPt/heightPt0 (odf.js's ownresolveColumnWidthPt/readRowLayout), and that explicit zero wins over the fallback. WhilebuildOdsPackage's own output was only ever a terminal deliverable (pdfToOds, or a caller's ownreadOdsContentround trip), this was cosmetic: a real app reopening it would use its own defaults instead of the source's.xlsxToPdf(xlsxToOdsthenodsToPdf) made it a real bug instead — the intermediate ods bytes get laid out again byconvertSpreadsheetToLayout, and a zero-size grid collapses every cell onto the same physical position rather than merely losing precision.setColumnWidth/setRowHeightmint a freshstyle:style[family="table-column"|"table-row"]per column/row and repoint its owntable:style-name, the same append-only style-minting conventionwriteSheetPrintSettings/src/edit/odg/style.tsalready establish;buildOdsPackagenow calls both for everyContentSheetColumn/ContentSheetRowa source sheet carries. Column/row HIDDEN state andContentSheetImage/formulaembeddedObjectsare no longer gaps either, closed in the same phase:OdsSheet.setColumnHidden/setRowHiddenset or cleartable:visibility="collapse"directly on thetable:table-column/table:table-rowelement — a plain attribute, not a style property, so it never interacts with the width/height setters above — andbuildOdsPackagecalls one of these for every column/row whosehiddenfield istrue.OdsSheet.addImage(src/edit/ods/floating.ts) writes a real floatingdraw:frame/draw:imageintotable:shapes(the ODF 1.3 content-model container for spreadsheet floating shapes, alwaystable:table's own first child in a package this editor builds), resolving aContentSheetImage'sanchorRow/anchorColumnplusoffsetXPt/offsetYPtto an absolutesvg:x/svg:yby summing the real, currently-declared width/height of every column/row strictly before the anchor (header-wrapper-aware, hidden columns/rows contributing zero, falling back to the same default column/row size the layout engine assumes once the walk runs past what the sheet has declared) — reusingaddImageMediafor the binary part and manifest entry, the same mechanismsrc/edit/odp/image.tsalready uses for a slide.OdsSheet.addEmbeddedObjectwrites a real embedded ODF formula sub-document forobjectKind === 'formula'(reusingaddFormulaObject, the same mechanismOdtBody.appendFormulaalready uses); every otherobjectKind(wordprocessing/presentation/spreadsheet/drawing) is left unwritten, a documented, bounded gap mirroringbuildOdtPackage's identical narrowing for a'drawing'embedded object, since embedding one would mean writing that document's own package as a nested OLE sub-object, which no writer in this codebase implements.buildOdsPackagecalls both for every sheet's images/embedded objects, after every column/row width/height/hidden call, so an image's own anchor resolves against the sheet's final, real column/row sizing. This is no longer write-only:odf.js2.2.0's ownreadOdsreads a sheet's floating shapes and embedded objects back (it previously hardcodedimages: []and never setembeddedObjects), so a written image now verifies as a genuineContentDocumentre-read round trip — bytes, declared size, and anchor quartet — on top of the direct written-XML structural checks these tests already made. -
reconstructDrawingmaps recovered geometry back onto ODF shapes near-1:1, with no clustering — and every vector kind in this package's own.odgfixture now survives the round trip, where a stroked rect, an ellipse, and a line used to collapse to a genericpath. Every paintedLayoutItemmaps onto aContentVector/ContentShapedirectly, in the exact z-order it was recovered —LayoutRect→rect,LayoutEllipse→ellipse,LayoutLine→line,LayoutPath→path,LayoutText/LayoutImage→ContentShape— a fundamentally more tractable problem thanreconstructWordprocessing/reconstructPresentation's own paragraph/shape geometry clustering, since a drawing has no semantic structure to infer at all. How much kind information survives is decided upstream, by whatreadPdfcan hand it: pdf-codec's own shape-pattern detection (see the gotcha above) now recovers a rect under any fill/stroke combination, a real ellipse from the four kappa-ratio cubicswriteEllipseemits, and a real line, soreconstructDrawingreceives — and therefore emits — the original kind in each case. What still narrows: a rotation that is not a multiple of 90° leaves no axis-aligned pattern to match, so a rect turned by 30° comes back as apathcarrying its four rotated corners exactly. Position, size, and fill/stroke colour survive regardless of kind (within ordinary floating-point/string-formatting tolerance). Apathvector's own reconstructedframeis 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: aviewBox/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:reconstructDrawingmaps each recoveredLayoutTextitem to its own separateContentShape(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) ispdfToOdg's own package-building half, mirroringbuildOdtPackage/buildOdpPackage's role forpdfToOdt/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 thatreconstructDrawing's own real-file verification exposed, not something the reconstruction algorithm itself introduced, and both apply to every.odgthis package writes, not only a reconstructed one: (1)src/edit/odg/style.ts'sgraphicPropertyAttrswrotedraw:fill-coloralone, with no accompanyingdraw:fill="solid"— real LibreOffice 26.2 fills adraw:rect/draw:ellipsethat way fine, but silently renders adraw:pathwith 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(pdf-codec's owncontent-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), butreadPdf's own general path tracking only marks a subpathclosed: truewhen it actually sees an explicith, so a PDF-round-tripped ellipse came back withclosed: false, which correctly-behaving ODF/SVG consumers then refuse to fill even withdraw:fill="solid"set.writeEllipsenow emitshbefore 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 ownStyleRegistry.'graphic'is a recognisedStyleFamilymember (odf.js'ssrc/styles/registry.ts), butStylePropertiesSchema/buildStylePropertyElements(properties.ts/serialize.ts) only ever model text/paragraph formatting and never emit astyle:graphic-propertieselement 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.tsis that writer: it still reusesodf.js's general append-only style-editing invariant (a setter always mints a freshstyle:styleand repointsdraw:style-name, never mutates an existing entry — verified by the sameassertAutomaticStylesOnlyAppendedhelperOdpEditor's own live-view fidelity test uses) andsrc/edit/odt/automatic-styles.ts'sensureAutomaticStyles/nextStyleName(the "find-or-createoffice: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:dis cross-checked againstodf.js's real parser, not merely asserted to "look plausible".src/edit/odg/svg-path.ts'sbuildSvgPathDatais the write-side inverse ofodf.js'sparseOdfPathData;OdgPathVector.subpathsre-derives its value by reparsing the actual writtensvg:viewBox/svg:dthrough that exact function (plusparseOdfViewBox/buildOdfSubpaths) on every read, rather than echoing back whateverContentSubpath[]the caller originally passed toaddPath— so every read is itself a live round-trip proof, and this module's own test suite additionally feedsbuildSvgPathData's output straight intoparseOdfPathDatato confirm point-for-point recovery. -
A newly added vector/shape's paint order is expressed purely as document order, with no
draw:z-indexever written. This matchesodf.js's own reader-side convention exactly (typed/draw/shapes.ts'spaintOrderKey: honour an explicitdraw:z-indexwhen present, otherwise fall back to document order — and real LibreOffice output never emits one, it reorders elements instead), soOdgPage.addRect/addEllipse/addLine/addPath/addTextBox/addImagesimply append todraw:page's own children in call order and nothing more is needed for a lateradd*call to paint in front of an earlier one. -
LayoutPathSchema(document-schema.js) has no quadratic or elliptical-arc segment kind, deliberately — not a scope gap that happens to be unfilled.writePath(pdf-codec's owncontent-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-verifiedsvg:dparser (typed/shared/path.ts) recognisesS/s/Q/q/T/t/A/aas 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, onlyM/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
shapesandvectorsare two separate arrays, but their true relative paint order is carried by a sharedpaintOrderfield on both.ContentDrawPageSchema(document-schema.js) still keeps text/image/table content (shapes) and vector primitives (vectors) apart, butContentVectorandContentShapeeach carry apaintOrder— one monotonically increasing per-page document indexodf.js's own reader stamps on every element it walks (typed/draw/shapes.ts'swalkDrawPageContent/paintOrderKey, honouring a realdraw:z-indexwhere a producer wrote one, falling back to document position otherwise).convertDrawingToLayoutmerges the two arrays back into one true-paint-order walk through that field (src/model/paint-order.ts'smergeByPaintOrder),reconstructDrawingstamps the same field from its own single walk over a page's recovered items, andbuildOdgPackageappends in the same merged order (document order is paint order in a written.odg— this package never emits adraw:z-index). A page that genuinely interleaves the two mid-stack — a text label between two rectangles, a rectangle over a picture — consequently paints in the order its author built it, and survivesconvertDrawingToLayout→reconstructDrawingwith that interleaving intact. The historical "every vector paints before every shape" rule survives only as the documented fallback for a page missingpaintOrderanywhere (a hand-builtContentDocument, or one produced before the field existed), since an item with no value has no defensible position to be sorted into and inventing one would silently reorder content. -
A rotated vector primitive renders as a
LayoutPath, not as a rotatedLayoutRect/LayoutEllipse— because neither of those carries a rotation field at all.ContentVectorSchema'srect/ellipse/pathvariants each carry a realrotationDeg(thelinevariant does not, and needs none — two endpoints already encode any orientation a line can have),odf.js's own reader resolves one through the sameresolveOdfShapeGeometryadraw:frameuses, andOdgBoxVector.rotationDeg/OdgPathVector.rotationDegwrite one back through the same sharedapplyOdfGeometry(src/edit/geometry.ts)OdpShape.rotationDeguses.convertDrawingToLayoutresolves a rotated vector into aLayoutPathwhose own points are the shape's corners/curve controls after rotation — a rotated rect becomes a genuine four-point closed subpath, a rotated ellipse its own four cubics rotated — sinceLayoutRectSchema/LayoutEllipseSchemamodel no rotation and onlyLayoutText/LayoutImagedo (pdf-codec rotates those two through a text/image transformation matrix, which a path-painting operator sequence has no equivalent of). Nothing is approximated by this: an affine rotation maps a straight edge to a straight edge and a cubic Bézier to a cubic Bézier exactly. What a PDF round trip cannot preserve is therotationDegfield — a recovered path records where the corners ended up, never that a right-angled box was turned to get there — sopdfToOdgreturns a rotated rect as an unrotatedpathvector whose geometry is genuinely rotated, the same kind-narrowing every other vector already documents below. -
ContentVector'spathvariant'sfillRuleis read from real ODF markup.odf.js'sreadOdfFillAndStroke(src/typed/draw/shapes.ts) resolves the realsvg:fill-ruleattribute (nonzero/evenodd) when a path declares one, tested against real fixtures including a two-subpath "letter O" donut shape proving the attribute survives the fullreadDrawPageContentpath —fillRuleisundefinedonly when the source markup genuinely has nosvg:fill-ruleattribute at all, in which casewritePathcorrectly falls back to PDF's default nonzero winding rule. -
A cell's declared border still renders solid whatever its
stylesays — but the reason moved from "the layout schema has nowhere to carry a dash pattern" to "pdf-codec doesn't read the field yet".ContentSheetCellSchemaandContentTableCellSchemaboth carry real per-cellbackground/borders(andContentSheetCellSchemaalsoalignment/verticalAlignment),odf.js's own reader populates every one of them from a cell's resolved style chain, andsheets.ts/engine.tsrender all of them: a background becomes a realLayoutRect, each declared border edge a realLayoutLine, and a cell's own alignment/vertical alignment override the value-kind default and the bottom default respectively.ContentBorder.style(solid/dashed/dotted/double) now genuinely reaches theLayoutDocument: as ofdocument-schema.js2.1.0,LayoutLineSchema/LayoutPathSchemaboth carry that same optionalstyleenum, andpushCellBorderLines(src/layout/shared.ts, shared by bothengine.ts's docx/odt table-cell borders andsheets.ts's ods/xlsx sheet-cell borders) sets it fromContentBorder.styleon every emittedLayoutLine— the model-to-model plumbing this task closed. What still renders solid regardless is the PDF byte output: the installedpdf-codec1.8.0's owncontent-write.tswriteLine/writePathalways emit a plain stroke (S) operator with no dash array, so a non-solidstylehas nowhere to render differently yet — a real, separate, pdf-codec-side gap now, not a documents.js schema one. Renderingdoubleas two hand-offset parallel lines was still considered and rejected regardless of which package's gap this is: the offset distance is nowhere in the model, so it would be an invented constant standing in for information the source never carried. -
Ordinary text in PDF output now resolves through a real font registry rather than the standard 14 alone, and the standard 14 are only the last resort in that chain. In order: the source document's own embedded faces (docx's
word/fontTable.xml, pptx'sp:embeddedFontLst, ODF'soffice:font-face-decls— see Fonts), then any face the caller supplied throughoptions.fonts, then pdf-codec's vendored Carlito/Caladea faces (genuinely metric-compatible with Calibri/Cambria, and embedded as real subsetted TrueType programs), then the standard 14. Helvetica/Times-Roman remain metric-compatible substitutes for Arial/Times New Roman, so a document asking for either still resolves to a standard font and embeds nothing. What is still not covered: a family with no embedded face, no caller-supplied face, and no vendored substitute — Aptos, say, or any third-party typeface — still renders through the nearest standard-14 face with a width-correction factor, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation there, not a line-identical reproduction. MathML formula rendering (odfToPdf, and formulas embedded inside odt/odp) is separate from all of this and always was: it embeds the real STIX Two Math font, which is not a registry-resolvable face and cannot be overridden byoptions.fonts— see the CFF-embedding gotcha below, and pdf-codec's own README, for the exact scope of that embedding (the wholeCFFtable, not glyph-subsetted). -
Justified paragraphs now stretch real inter-word gaps in all three layout engines — the flow one (
engine.ts), the direct-placement one (slides.ts), and the spreadsheet one (sheets.ts).justifyLineGapsPt(src/layout/shared.ts) recovers each wrapped line's own word-gap positions from a line's per-fragmentxOffsetPt(a genuine gap wider than floating-point noise means a space stood there; two touching fragments are one word split across a run boundary, and stay touching), divides the line's slack evenly across every detected gap, and returns an all-zero shift whenever there is nothing to stretch (fewer than two fragments, no detected gap, or a line already at or past its target width — this function only ever adds space, never compresses).layoutParagraphFlow/layoutParagraphInCell(engine.ts, covering docx/odt paragraphs, docx/odt tables, and odm-assembled chapters) andlayoutParagraph(slides.ts, covering pptx/odp shape text and slide-table cells) call it for every wrapped, non-final line of a'justify'-aligned paragraph; the paragraph's own final line (or a paragraph that never wraps at all) stays left-aligned, matching Word/LibreOffice/Impress's own convention.sheets.ts'srenderCellTextcalls it too, but a spreadsheet cell only ever renders one line by this module's own documented scope, so the "non-final line" case only arises when a cell's source text carries an explicit line break —wrapRunsToWidththen produces more than one line, of which only the first is ever rendered, and that first line is the genuinely non-final one a justified cell stretches; justification is skipped outright when that line already overflowed its cell (the numeric-###/string-spill-or-truncate fragments no longer reflect the natural layout the stretch needs) or for an ordinary single-line cell, matching every real spreadsheet application's own "justify only wraps, never a single line" behaviour.alignmentOffsetPtitself still returns0for'justify'in all three files, unchanged — the whole-line offset it computes is the wrong shape for inter-word stretching, which is why the stretch lives in a second, sibling function each caller applies on top, not a new branch inside it. No known gap remains in inter-word justification across any layout engine this package has. -
Encrypted-PDF support and
CCITTFaxDecode/JBIG2Decode/JPXDecodeimage decoding are all real, implemented capabilities in pdf-codec now, not scope boundaries. An encrypted PDF is readable whenever it opens without a real password: pdf-codec'ssrc/encrypt.tsimplements the full Standard Security Handler (RC4 and AES-128/256, revisions 2-6,/EncryptMetadata falsehandling, empty-user-password verification), throwing the distinctPdfPasswordRequiredErroronly when a genuine user password is needed andPdfEncryptedErroronly for a handler/version this codec doesn't implement (public-key encryption, say). CCITT Group 3/4 fax, JBIG2, and JPEG2000 images all genuinely decode via hand-written decoders (src/image/{ccitt,jbig2,jpeg2000}.ts), falling back to a diagnostic only for a specific feature within one of those formats the decoder doesn't cover — not unconditionally, as an earlier version of this note claimed. What remains a genuine, permanent scope boundary is adversarial/badly-malformed-input robustness: the parser targets cleanly-generated output from mainstream producers rather than the hardening a 15+-year-old library has. See pdf-codec's own README for the full statement of each. -
PDF → docx/pptx/odt/odp reconstruction recovers a table only from a real drawn gridline lattice, and never from text alignment.
reconstructWordprocessing/reconstructPresentationrun the identical detector, thresholds, and span-consistency checkreconstructSpreadsheetgates its own cell boundaries on (src/layout/lattice.ts), and synthesize a realContentTablewhen — and only when — one fires. Aligned columns of text with wide gaps are deliberately not accepted as evidence: several left-aligned lines separated by a tab-sized gap are indistinguishable, from geometry alone, from a genuinely tabbed paragraph, an indented code sample, or a two-column page layout, so building a table out of one would be inventing structure the source never had rather than recovering structure it did. A wide horizontal gap on a line still becomes a tab character, exactly as before. A lattice with no text inside it is rejected too (a grid of empty boxes is far more likely a decorative frame, a chart's plot area, or a form's field outlines than a table). Where a table IS recovered it reaches the output bytes for real —buildDocxPackage/buildOdtPackagewrite a real table,buildPptxPackage/buildOdpPackagea real slide table — with column widths and row heights measured directly from the drawn boundaries, and the lattice's own strokes reported once, as the table's structure, rather than also as loose vectors alongside it. Gradients and shadings are still not recovered at all. -
A merged table cell (
colSpan/rowSpan) now round-trips as merged, not as an ordinary unmerged one, throughbuildDocxPackage/buildOdtPackage— and docx and ODF express a merge through two genuinely different conventions, so the two writers (src/edit/docx/content.ts,src/edit/odt/content.ts) are not mirror images of each other. docx collapses a horizontal merge into ONE realw:tccarryingw:tcPr/w:gridSpan— no element at all for the columns it consumes — while a vertical merge still needs one realw:tcper covered row, markedw:tcPr/w:vMerge(w:val="restart"on the top cell, a bare<w:vMerge/>on each covered row below);ContentTable.rows[].cellstherefore has exactly one array entry per REALw:tc, which can be fewer than the table's own column count. ODF, by contrast, always writes one array entry per grid position regardless of merge direction: a covered column in the SAME row gets a realtable:covered-table-cellplaceholder element (not just an attribute), and so does a covered row below arowSpan—table:number-columns-spanned/table:number-rows-spannedmark only the mastertable:table-cell. Both writers track active merges by grid-column index as they walk each row (DocxTableCell.colSpan/.verticalMerge,OdtTableCell.colSpan/.rowSpanplusOdtTableRow.appendCell/.appendCoveredCell), and both are proven by a real build-then-read round trip insrc/edit/docx/content.test.ts/src/edit/odt/content.test.ts, not merely by construction. -
docx headers/footers, comments, footnotes, and numbering definitions are now readable — but not through
readDocxContent, and livePAGE/NUMPAGESfield substitution still isn't read at all.readDocxContentstill carries none of the first four through:ContentDocument's section/block shape has nowhere to put a comment, a footnote, a header/footer, or a numbering definition, so it deliberately keeps dropping them, exactly as before. What changed is that they are no longer lost outright:readDocxExtras(see thesrc/ooxml/Architecture entry and the Usage example above) is a second, independent read of the same package that returns them as their ownDocxExtrasvalue.PAGE/NUMPAGESfield substitution has no equivalent — neither function reads it, since it isn't static content at all but a value Word computes at render time from the document's own live layout, which this package has no path to reproduce. Inline images, meanwhile, now ARE read byreadDocxContentitself:ooxml.js2.6.1'sreadDocxgained realw:drawingsupport, andreadDocxContent(a thin adapter over it) inherited that for free, with zero code change on this package's side — see the docx-image round-trip entry directly below for the one thing that DID need a code change. -
A docx inline image now reads as a real
ContentImageBlock, and — sincebuildDocxPackagewas taught to recognise the exact shapereadDocxproduces for one — round-trips back to docx without the extra blank paragraph a naive per-block write would otherwise insert.readDocx(ooxml.js2.6.1+) always represents an inline image as TWO adjacentContentBlocks sourced from the one physical<w:p>: a paragraph block carrying that paragraph's own (often all-empty) text runs, immediately followed by an image block for thew:drawingfound inside it — there is no field anywhere inContentDocumentdistinguishing that pairing from a genuinely separate, intentionally-blank paragraph that happens to sit immediately before an unrelated image; both produce the identical two-block shape.buildDocxPackage'sappendBlocks(src/edit/docx/content.ts) special-cases the patternreadDocxactually produces — a paragraph whose runs are all empty text, directly followed by an image block — and writes it back as the single physical paragraph it came from (paragraph properties applied, theninsertImageAftercalled on that SAME paragraph) rather than as two separate paragraphs. This is what makes a fullreadDocxContent/buildDocxPackageread → build → read cycle equal byte-for-byte again once an image is involved, rather than accumulating one spurious empty paragraph before every image on every round trip. The one honestly-scoped residual: a paragraph that genuinely is separate and blank, immediately followed by an unrelated image in its own paragraph, is indistinguishable from the common inline-image case and gets merged the same way — an edge case, not the common one this fix targets. -
pptx speaker notes survive
pptxToPdf/pdfToPptx, but not through any real PDF feature. PDF has no native concept of hidden presenter notes, soconvertPresentationToLayoutcarriesContentSlide.notesas a hidden/Subtype /Textannotation on the page (the same construct Acrobat's own sticky-note tool uses, marked with theHiddenannotation flag so it never renders or prints), andreconstructPresentationreads it back via a/Tmarker that distinguishes this package's own notes annotation from a genuine third-party sticky note. This is a round-trip mechanism specific to this package's own writer/reader pair — a PDF produced by anything else will never carry it, and a PDF consumer other than this package's ownreadPdfwill never see it as anything but an invisible, empty sticky note. -
odmToPdfis 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 — eachtext:sectionis a bare external reference (text:section-source'sxlink:href+text:filter-name) to a standalone.odtfile, confirmed against real, unmodified LibreOffice 26.2 output while buildingodf.js's ownreadOdm: a self-closingtext:section-sourcewith noxlink:show/xlink:type, no manifest entry for the linked part, and no chapter text anywhere in the master document's owncontent.xml. There is consequently no way forodmToPdfto read a chapter's content from the.odmbytes alone — it takes anoptions.resolveSubDocumentcallback, called once per section with that section's ownhref, to hand back the chapter's own.odtbytes. Every section left unresolved (no callback given, or the callback returnsundefinedfor thathref) is collected across the whole document before anything throws, and reported together in oneOdmUnresolvedSectionErrornaming every unresolvedhref— not just whichever section the read loop happened to reach first.odmToPdfis consequently not one of the fourteen round-trip conversions or ten bridges above, and is deliberately not wired into theDocumentConverterport either: that port'sconvert(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 wantingodmToPdfbehind the port can wrap it in their own adapter.OdmSection.inlineContent(declared byodf.js's ownreadOdmfor schema-completeness, covering a producer that caches a chapter's content inline rather than only linking it) is handled too, via the samereadOdfParagraph/readOdfTableprimitivesodf.js's ownreadOdtcalls internally — but the installedodf.js1.10.0 never actually populates it for any real documentreadOdmwas tested against, so this branch is exercised only by a directly-constructedOdmSectionin this package's own test suite, not by any.odmfixture. -
.odbhas noodbToPdfof its own, and does not need one. All three parts of rendering a Report are now real —src/odb/sql/'sparseSelect/evaluateSelectrun the report's own query overreadOdbTables' output,src/odb/formula/'srunRptReportevaluates its rpt formulas and group breaks over the result, andsrc/odb/report/'sreadOdbReportContentrenders the printed bands into aContentDocument— and because that document is an ordinarywordprocessingone, every consumer of that variant already accepts it:convertWordprocessingToLayoutlays it out,writePdfwrites it,buildDocxPackage/buildOdtPackagebuild a docx or odt from it. Adding anodbToPdfwrapper would pick one of those targets arbitrarily and imply.odbhad a single natural output format, which it does not: a database front-end's tables, its saved queries, and its reports are three unrelated output shapes, which is also why.odbstays out ofDocumentFormatand theDocumentConverterport entirely. What no part of this chain does is reproduce Report Builder's own page output — see Fidelity for exactly what "structural, not pixel-faithful" excludes. -
The rpt formula engine's group scoping cascades an enclosing break inward, and that is the one part of it most easily got subtly wrong. An aggregate is scoped to the band it appears in — a
rpt:SUM([AMOUNT])in an inner group's footer totals only that group instance's rows, one in the outer group's footer totals that whole instance, one in the report footer totals every row. The catch is when an instance ends: a group at level L starts a new instance when its own group-expression breaks or when any enclosing group breaks, unconditionally. The real fixture demonstrates exactly why. Its inner group breaks onrpt:HASCHANGED("LEFT_QUARTER")and its outer onrpt:HASCHANGED("REGION"); between the rows(North, Q2)and(South, Q2)the quarter does not change, so the inner expression is false there — yet the region does, and a "Q2" subtotal spanning North's Q2 rows and South's Q2 rows would be a number no reader asked for. The cascade lives in the report structure, not inHASCHANGED: that function is implemented exactly as its name says (the referenced value differs from its value on the immediately preceding row, and true on the first row), with no knowledge of groups at all, andsrc/odb/formula/report.test.tsproves both halves separately against the same real rows — the two-group report splits South's and West's Q2 rows, and the identical expression as the only group merges them. Two further consequences worth stating: aggregates are computed over a group instance's complete row range rather than accumulated row by row (the result set is already fully in memory, so aSUMin a group header is the true total for the group about to print, not a running total of its first row), and a group expression may not transitively depend on an aggregate — that is genuinely circular, since group expressions decide the very boundaries an aggregate's range is defined by, so it throwsRptFormulaEvaluationErrorfrom a static walk of the named-function graph before a single row is read. -
The rpt formula engine's function set is a closed allowlist, and its argument separator is a semicolon.
rpt:HASCHANGED(X),rpt:LEFT(X;n), andrpt:SUM/COUNT/AVG/MIN/MAX, plus the separatefield:[COLUMN]bound-field form — every other rpt function throwsRptFormulaUnsupportedErrorcarrying the function name and the offending formula, the same policysrc/odb/sql/andsrc/hsqldb/script.tsfollow. The separator is;, not,(LibreOffice's formula languages use the Basic/Calc convention throughout, and the real fixture'srpt:LEFT([QUARTER];2)is the confirmation); a comma-separated argument list is rejected outright rather than accepted as a second convention. The two reference spellings,[NAME]and"NAME", are treated as one concept and resolve by one rule, since the real fixture writesrpt:HASCHANGED("REGION")with quotes andrpt:SUM([AMOUNT])with brackets to no observable difference; a name matching both a declaredrpt:functionand a data column is ambiguous and throws rather than letting one shadow the other. Three further bounded refusals, each a place where guessing would produce a plausible wrong value rather than a visible failure: a group expression that does not evaluate to a boolean break test (real Report Builder writesrpt:HASCHANGED(...)and nothing else there, so a "group by this value's changes" reinterpretation has no real output to verify against);rpt:LEFTover a non-text value (a report's own number format lives in its band styles, which this engine does not read, so formatting a number to text here would mean inventing one); and a per-row formula in the report header or footer, which print outside the data and so belong to no row. -
The rpt formula engine emits no page headers or footers, deliberately — the renderer places them, under a single-logical-page model it states rather than hides. Which rows land on which page is a layout decision the formula engine has no basis for making, so
RptReportDefinitioncarries no page bands andrptDefinitionFromReportdrops odf.js's ownpageHeader/pageFooterexplicitly rather than silently.src/odb/report/render.tsis the renderer that decides: having no pagination engine, it declares the whole report one logical page, prints each page band once (the page header below the report header and above the body, matching the banded-report convention where a report's title sits above the column labels that then repeat on every page; the page footer above the report footer), and evaluates their formulas throughevaluateRptBandOutsideDataat report scope — which for a single page is not an approximation but exactly the right scope, since that page's rows are every row. Two failure modes need no special-casing because both already fail correctly: a per-row formula in a page band (field:[X],rpt:HASCHANGED) throws for belonging to no row, exactly as it does in the report header, andrpt:PAGENUMBERor any other genuinely page-dependent function throws from the parser as an unsupported function rather than being rendered as a plausible-looking wrong value. In the real fixture the page header carries onlyrpt:fixed-contentlabels and the page footer declares no controls at all — a band with no controls prints no block, which is why nothing sits between the last region total and the grand total in the rendered output. -
The SQL engine is a closed allowlist, not a partial SQL implementation, and every gap in it is a thrown error rather than an ignored clause.
src/odb/sql/parser.ts's grammar covers exactly one statement shape (its own top-of-file production list is the full statement of it):SELECTa column list or*or an aggregateFROMone table, optionalWHERE(the six comparison operators,AND/OR/NOTwith parentheses,IS [NOT] NULL,[NOT] LIKEwith%/_,[NOT] INover a literal list,[NOT] BETWEEN), optionalGROUP BYwithCOUNT/SUM/AVG/MIN/MAX, optional multi-columnORDER BYwith per-columnASC/DESC. Everything else — JOINs (including the comma form), subqueries anywhere,UNION/INTERSECT/EXCEPT,DISTINCT,HAVING,LIMIT/OFFSET/FETCH/TOP,ASaliases and bare table aliases,CASE,EXISTS,WITH, schema-qualified table names,ORDER BYordinals or aggregates,LIKE ... ESCAPE,NULLS FIRST/LAST, arithmetic, string concatenation, SQL comments, parameter placeholders,!=, and any scalar function at all — throwsHsqldbSqlUnsupportedErrorwith aconstructfield naming which one. This issrc/hsqldb/script.ts's own closed-allowlist policy (quoted in full at the top ofsrc/odb/sql/errors.tsas the precedent) applied to a grammar: silently dropping aHAVINGor aDISTINCTwould return rows that look plausible and are wrong, which is strictly worse than returning nothing. -
Four SQL semantics decisions the engine makes explicitly, each of which a caller can otherwise get wrong by assumption. (1)
NULLisContentCellValue's own{ kind: 'empty' }, andWHEREuses genuine three-valued logic — a comparison with a NULL operand is UNKNOWN,NOT UNKNOWNis still UNKNOWN, and a row survives only on TRUE; a non-match against anINlist containing NULL is UNKNOWN too, which is whyx NOT IN (1, NULL)correctly keeps nothing. (2) Values compare within three classes (numeric, boolean, text) and a comparison ACROSS classes throws rather than coercing — coercion is exactly how a query engine silently returns wrong rows; text comparison is UTF-16 code-unit order, correct for the ISO-8601 date/time strings this package's readers produce but deliberately not an implementation of any database's own collation. (3)GROUP BYputs all NULLs in one group and returns groups in first-appearance order (SQL defines no order withoutORDER BY, and first-appearance is the one deterministic choice);COUNT(*)counts rows,COUNT(column)counts non-NULL values,SUM/AVG/MIN/MAXignore NULLs and return NULL for a group with no non-NULL value; an aggregate with noGROUP BYtreats the whole post-WHERErow set as one group and still returns exactly one row when that set is empty. (4)ORDER BYsorts NULLs last underASCand therefore first underDESC, and the sort is stable, so rows tied on every term keep their original order. -
An unquoted SQL identifier folds to upper case and may match a real column case-insensitively; a double-quoted one matches only exactly. That is SQL's own rule, and both HSQLDB and Firebird implement it — real LibreOffice-generated
.odbqueries quote every name, so they resolve exactly. Where an unquoted name matches more than one real column case-insensitively, resolution throws rather than picking one. A table qualifier ("SALES"."REGION") is checked against the single table inFROMand rejected if it names anything else, since there is no second table it could legitimately refer to. -
odf.js2.0.0 turnedOdbInventory.forms/.reportsfromstring[](names only) intoOdbComponentInfo[](name + href), and madereadOdbForm/readOdbReportreal — a form's own bound controls (form:text/form:data-field/etc, plus its content read as an ordinary ODT document viaodf.js's ownreadOdt) and a report's own bands/groups/functions (rpt:report-header/rpt:group/rpt:detail/etc, with each control's data-bound field name resolved from itsrpt:formula) are now real, readable structures rather than bare names. Neither was wired intoreadOdbTables(scoped to table DATA, not form/report STRUCTURE), soreadOdbForms/readOdbReports(src/odb/components.ts) are this package's own "read every declared one at once" convenience — callingodf.js's ownreadOdbForm/readOdbReportonce per name discovered viareadOdbInventory, the samereadOdbTables-shaped one-call ergonomic this data did not have before. Both single-name functions are also re-exported unmodified for a caller that wants exactly one named form/report, matching the "each pipeline stage independently usable" conventionreadOdbTables/decodeHsqldbCachedTables/readFirebirdBackupalready follow. -
All four
.odbdecoder tiers are implemented: HSQLDB TEXT-script rows (MEMORY/TEXT tables, Tier 1), HSQLDB's own binary CACHED-table row-store rows (Tier 2), a Firebird-backed embedded database's own gbak logical-backup format (Tier 3, see the dedicated Tier 3 entries below), and HSQLDB's own whole-script BINARY (hsqldb.script_format=1) and COMPRESSED (=3) serialisations (Tier 4). Tier 4 turned out to be far closer to a sibling of Tier 2 than the earlier, unimplemented-tier framing suggested, and needed no new value decoding at all:ScriptWriterBinarywrites the database's DDL as oneorg.hsqldb.Resultrecord — the identicalResultDatabaseScript.getScriptbuilds for the TEXT writer, serialised throughResult.write/RowOutputBinaryrather than printed — followed by a per-table section carrying each MEMORY/TEXT table's rows in exactly the per-column binary encodingsrc/hsqldb/rowformat.tsalready decodes for a CACHED table's row store. SoparseHsqldbBinaryScriptrecovers the DDL statements, rejoins them into ordinary TEXT-format script text, hands that to Tier 1's ownparseHsqldbScriptfor the table/column definitions, and splices in the rows the binary section carried; because that recovered text still contains the sameSET TABLE ... INDEX'...'lines, a BINARY-format script belonging to a database with CACHED tables composes with Tier 2 exactly as a TEXT one does.hsqldb.script_format=3is that identical byte stream wrapped in ordinary zlibDEFLATE(RFC 1950 —ScriptWriterZipped's ownDeflaterOutputStream, whose default framing is zlib, never gzip), inflated throughfflate'sunzlibSyncand then parsed by the same reader;classifyScriptBytesdetects the real zlib header rather than gzip's, which a real HSQLDB-produced COMPRESSED file never carries. Verified against two real databases generated by the bundled HSQLDB 1.8.0.10 jar itself — the same content written atscript_format=1and=3, each re-opened by that same engine and dumped back through JDBC as the ground-truth oracle, both oracles byte-identical to each other and to what this reader decodes. An external-only connection (no embedded engine at all — MySQL/PostgreSQL/JDBC/ODBC) is the one permanent scope boundary, not a missing tier:readOdbTablesthrowsOdbNoEmbeddedDataSourceErrorrather 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 realhsqldb.jarLibreOffice 26.2 bundles (Specification-Version: 1.8.0.10in that jar's ownMETA-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 viajava.sqldirectly 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'sembeddedHsqldbCachedOdbBytes) matched that oracle exactly;parseHsqldbPropertiesthrows for ahsqldb.compatible_versionoutside the1.7.x/1.8.xfamily 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 headlesssofficemacro 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. -
A CACHED table's own index count comes from its
SET TABLE ... INDEX'...'line's own token count, which is what makes a multi-index table decodable at all. A row's on-disk record carries one 16-byteorg.hsqldb.DiskNodeper table index ahead of its column data (CachedRow.getRealSize():getIndexCount() * 16 + rowOutput.getSize(row)), so the column data's byte offset depends entirely on that count. The count is recorded, positionally, in the index-roots line itself:Table.setIndexRoots(String)— the engine's own reader for that exact line — reads preciselygetIndexCount()integers and then one trailing identity-sequence bigint, sotokens.length - 1is the index count, and the first token is always index 0's root (the primary key, or HSQLDB's own internal row-position index for a table with none declared). Traversing index 0's tree suffices whatever the count, since every index's tree spans the identical live row set. An earlier revision rejected any multi-index table outright, on the premise that the count could only come from countingCREATE INDEXstatements in the DDL — where aUNIQUEconstraint's own auto-generated index genuinely is invisible; that premise was wrong about where the count is recorded. Verified against a real HSQLDB 1.8.0.10 fixture generated and read back by the bundled jar itself: a three-index table (PRIMARY KEY+UNIQUE(CODE)+ an explicitCREATE INDEX→INDEX'136 32 240 0'), a two-index table with no primary key at all (INDEX'664 664 0'), and an ordinary single-index one (INDEX'528 0'), every row of each matching the JDBC oracle field-for-field. 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 need to know which timezone the database was written in, and the file does not record it — so it is a caller option (
timeZone), defaulting to the reading process's own local zone.org.hsqldb.HsqlDateTimeresolves every date/time value through ajava.util.Calendarcarrying no explicitTimeZone(i.e. the writing JVM's own default), and the row store's own encoding is a bare epoch-millisecondlongwith no timezone or offset recorded anywhere alongside it — confirmed empirically: the checked-in fixture's ownDATEvalues straddle both GMT and BST, and decoding via UTC (rather than local-timezone)Datemethods recovers the wrong calendar day for every summer date.readOdbTables,odbToXlsx,odbToCsv,decodeHsqldbCachedTables,readHsqldbCachedTableRows, andreadHsqldbColumnValuetherefore all accept{ timeZone }(an IANA name, e.g.'Europe/London'), resolving the instant's calendar fields throughIntl.DateTimeFormatin that zone; omitting it keeps the original local-timezone behaviour exactly — correct whenever a.odbis read on the same machine/region that created it, the overwhelmingly common case, and the only sensible default given the file itself is silent on the question. It affects Tier 2 and Tier 4 only: Tier 1's TEXT script carries date/time values as already-formatted literal text, and Tier 3's Firebird backup carries a genuine timezone-free day count, so neither has an epoch instant to reinterpret.src/hsqldb/cache.test.tspinsprocess.env.TZto'Europe/London'to exercise the default path against the fixture's own real generation environment, and separately reads the identical bytes back under an explicit'America/New_York'/'UTC'override to prove the option genuinely shifts the recovered calendar day. -
A BIGINT/DECIMAL/NUMERIC value beyond what a double can represent exactly no longer silently loses precision — HSQLDB's CACHED-table decoder and Firebird's row decoder both now carry the exact value alongside the approximation.
document-schema.js'sContentCellValuenumber/percentage/currency variants have long accepted an optionalexactValuedecimal-string sidecar for exactly this case;readHsqldbColumnValue's BIGINT/DECIMAL/NUMERIC cases and Firebirddata.ts'sdecodeRowValues(short/long/int64 physical types) previously cast straight throughNumber()regardless, discarding it. Both now build the exact digit string viaBigIntdigit manipulation — never a floating multiply/divide, which would risk rounding for a large magnitude — and attach it asexactValueonly whenString(Number(exactValue))would not round-trip back to that exact string,document-schema.js's own documented contract for the field; trailing fractional zeros are trimmed first, since a fixed-scale value like"250.00"carries no more precision than"250"and would otherwise spuriously gain a sidecar even thoughNumber()already represents it exactly. Firebird's short/long/int64 cases previously scaled viaraw * 10 ** field.scale, a floating multiplication carrying the identical precision risk for a large stored integer; they now decode through the same exact-digit-string path.ContentCellValuestill has no arbitrary-precision kind of its own —exactValueis an optional sidecar a consumer may read for the full value, not a replacement for thenumberfield — but the information is no longer discarded at the format boundary. -
.odbTier 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 owndatabase/firebird.fbkpart 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 standalonegbakcommand-line tool uses) into the.odbpackage on save, and restores it into a throwaway temp.fdbfile 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.fdbpath 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.odbthis 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.cppandsrc/common/xdr.cpp/src/common/classes/NoThrowTimeStamp.cppin 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'sRDB$RELATIONS/RDB$RELATION_FIELDS/RDB$FIELDSsystem tables before writing anything,src/firebird/schema.tsnever bootstraps those system tables itself — a realrec_relation/rec_fieldrecord 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_formatattribute — burp.h's own version-history comment identifies format 10 as "FB2.5 → FB3.0" output.readFirebirdBackupchecks this explicitly and throwsFirebirdBackupFormatErrornaming 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 setatt_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, andsrc/firebird/reader.ts'sreadCompressedPayload(a signed-run-length/"PackBits"-style codec, restated frombackup.epp's owncompress/restore.epp's owndecompress) exists specifically because of that correction. -
Three genuine real fixtures back this reader's own tests, each generated via a headless LibreOffice 26.2 UNO automation session and never hand-edited afterward (
src/test-support/firebird.tsdocuments each in full): a rich one (two tables, varied column types —INTEGER/VARCHAR/DOUBLE PRECISION/DATE/BOOLEAN/DECIMAL/NUMERIC— four and three rows respectively, including deliberateNULLs in every nullable column, an apostrophe-escaped string, and a zero value distinct fromNULL), a blob-bearing one (see the BLOB entry below), and theExaDev/odf.jsrepository'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 freshlycreateInstance()'dDatabaseContextentry fails withSQLException: No storage or URL was givenunless.DatabaseDocument.storeAsURL()is called FIRST to give the embedded engine real backing storage to connect to — setting.URLalone 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— seesrc/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 viagetConnection, and runs a realSELECT * 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. -
BLOB column content is genuinely decoded, and pinning down the record's own framing corrected two real mistakes in this reader's first implementation — one of which made a blob-bearing
.odbfail outright rather than merely lose its blob.backup.epp's ownput_blobwritesrec_blob att_blob_field_number <int32> att_blob_max_segment <int32> att_blob_number_segments <int32> att_blob_type <int32> att_blob_data (<2-byte little-endian segment length> <that many raw bytes>)*and then simply returns: there is noatt_endterminator, andrestore.epp's own reader correspondingly loops onlywhile (get_attribute(&attribute, tdgbl) != att_blob_data). Reading on in search of anatt_endconsumed the next record's own tag and desynchronised the whole stream. Second, a NULL blob writes norec_blobrecord at all ("If the blob is null, don't store it. It will be restored as null." —put_blob's own comment), so a field with no record is genuinely null rather than missing data. A blob is matched to its column byatt_blob_field_numberagainst the field's ownatt_field_number— exactly what the engine itself does (field->fld_number == field_number), deliberately not by positional index, which coincides in some real files and not others. One further asymmetry is real rather than an omission: a blob's segments are always raw, never RLE-compressed, even in a compressed backup, becausebackup.eppcalls its owncompress()at exactly one site (put_data's row payload) whileput_blobwrites every segment through plainput_block. AContentCellValuehas no binary kind at all, so a TEXT blob (att_field_sub_type1) arrives as an ordinary UTF-8 string and a BINARY blob as a base64data:URI — self-describing and losslessly decodable, and distinguishable from a string the column could genuinely have held. That is a real, tracked schema gap belonging indocument-schema.js(a variant able to say "these are bytes"), not a decoding limit: the bytes are fully recovered either way. Verified against a third real fixture built for exactly this (BLOB_FIXTURE_FBK_BASE64): a text blob, a 256-byte binary blob holding every byte value0x00..0xFFin order (so no byte range can be mangled unnoticed), NULL blobs in both columns, and a second blob-free table after the blob-bearing one to prove the stream stays aligned across it — cross-checked field-by-field against real LibreOffice's own SDBCSELECT *(getStringfor the text blob,getBytesfor the binary one) in a separate process that reopened the saved file fresh from disk. -
FB4+-only types (
INT128/DECFLOAT) are not a deferred decoding task but an environmental hard stop: LibreOffice cannot produce a.odbcontaining one, so there is no real fixture to verify a decoder against, and this package does not guess a wire format from documentation alone. LibreOffice 26.2.5.2 bundleslibfbclient.dylib.3.0.7andsecurity3.fdb, and its embedded engine reports itself asFirebird (engine12) / 3.0.7through SDBC's owngetDatabaseProductVersion. Confirmed empirically rather than inferred from those version numbers, by attempting each type's own DDL against that engine through the same headless UNO route the fixtures are generated with:INT128fails withSQL error code = -607 ... Specified domain or source column INT128 does not exist;DECFLOAT(16)andDECFLOAT(34)fail with-104 Token unknown;TIMESTAMP WITH TIME ZONEfails with-104 Token unknown - WITH(the same reason this reader'sunsupported-tzphysical type is unreachable in practice);NUMERIC(38,2)fails with-842 Precision must be from 1 to 18, which is precisely the ceiling above which Firebird would needINT128storage; andNUMERIC(18,2)— the last width FB3 supports — succeeds.decodeRowValuesaccordingly throws a named error for these physical types rather than decoding them speculatively. A Firebird 4/5 server could be run separately to produce such a backup, but the result would be a standalone.fbkat a differentatt_backup_formatversion than the 10 this reader pins, and no.odbcould ever contain it — widening the reader for a file its only caller cannot encounter would be speculation, not root-cause work. -
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-killedsofficeprocess leaves macOS's own native "reopen windows after a crash" alert showing on every subsequent launch — invisible in headless/--invisiblemode (no window to click), sosofficehangs 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 — persoffice --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-bitSSHORT) 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 asxdr_hyper's own in-memorytemp_longarray layout suggests on first reading) was caught exactly this way: aDECIMAL(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 is embedded as a whole, unmodified
CFFtable rather than glyph-subsetted — pdf-codec's own font-embedding scope decision, not this package's. See pdf-codec's own README for the full CFF-embedding scope statement. -
A stretchy fence in an
mrowgenuinely stretches to its content on the VERTICAL axis, via the font's ownMathVariantsdata, for anymothe operator dictionary calls stretchy — matched now by a genuine HORIZONTAL stretch for an over/under-brace spanning its own base inmunder/mover/munderover(see the next bullet).layoutRowChildren(src/mathml/layout.ts) targets twice the larger of the row's own non-stretchy children's half-extents about the maths axis (MathML's defaultsymmetricbehaviour for a fence), asks theMathFontMetrics.stretchport for a construction reaching it, and emits aMathAssembledGlyphsitem: one or more glyph IDs at explicitly computed positions, rather than the Unicode text aMathGlyphRuncarries. It has to be glyph IDs — every pre-built larger variant in this font is unencoded, as are the radical's and the over-brace's assembly pieces, with only the bracket family's own pieces given code points (the U+239B–U+23AD block). What genuinely stretches today: parentheses, square brackets, curly braces, floor/ceiling, angle brackets, and the vertical-bar/norm pair, each drawn either from a larger pre-built variant or from a real multi-part assembly sized to the content. What does not, and why:msqrt/mrootradical signs keep the hand-drawn hooked sign + vinculumsrc/mathml/radical.tsbuilds. This is not aMathVariantsgap — the font's own radical construction stretches perfectly well, and a bare stretchy<mo>√</mo>in anmrowdoes now use it — but a radical construct needs a vinculum spanning its radicand's width, which no vertical glyph construction supplies, so switching it over means replacing the whole hand-built sign, not just its stem.- A multi-character
mo("||", an operator with combining marks) is never stretched: the font'sMathVariantsdata is keyed per glyph, so there is no single construction to look up.
-
An over/under-brace (
U+23DE/U+23DF) spanning its ownmunder/mover/munderoverbase now genuinely stretches horizontally, via the identicalMathFontMetrics.stretchport used for a vertical fence, just called withaxis: 'horizontal'and a width target instead of a height one.layoutUnderOverChild(src/mathml/layout.ts) is the call site: for each over/under script thatisStretchyOperatoraccepts, it asksstretchHorizontalOperatorto stretch to the base's ownwidthPt, computed independently for the over and under script (no shared or synchronised target — real\overbrace{content}^{label}semantics needs none), and falls back to the ordinary glyph run wherever the font declines (no horizontalMathVariantsconstruction for that glyph, or the base is already wide enough that the construction's own base form already reaches it).horizontallyStretchedBoxisstretchedBox's horizontal-axis sibling, not a shared axis-branching function: its box width is the construction's own achievedsizePt(neveradvanceWidthPt, which for a horizontal assembly is only the widest individual glyph's own natural advance, far short of the whole span), and its ascent/descent come directly and unclamped from the construction's own real ink — one of the two is genuinely negative for both the over-brace and the under-brace, since each glyph's ink sits almost entirely on one side of its own drawing origin, andlayoutUnderOveralready stacks over/under content against the base's own edges using exactly that ascent/descent, with no further change needed on its part. -
A stretched fence's own glyphs have no ToUnicode mapping, so pdf-codec wraps them in an
/ActualTextspan carrying the operator's real text. Text extraction, search, and copy/paste still recover(from a six-piece assembled bracket. A fence the base glyph already covers is left as an ordinaryMathGlyphRunrather than converted to glyph IDs, so a plain inline(x + 1)renders and extracts exactly as it did before any of this existed. -
The operator dictionary calls
∑/∏/⋃and the rest of the big-operator family NOT stretchy, matching MathML3's own dictionary. This became load-bearing once a stretchy operator genuinely stretches: STIX Two Math does declare verticalMathVariantsfor a summation sign, so a wrongstretchy: truethere would visibly deform a∑standing next to a tall fraction. A big operator grows by selecting a larger designed size in display style — thelargeopmechanism — never by stretching to its row. -
A token element's (
mi/mn/mo/mtext) own box height is the union of its characters' real per-glyph ink bounds, falling back to the font's nominal design ascent/descent only for a glyph that carries none.src/mathml/still parses no glyph outlines itself — the bounds arrive throughMathFontMetrics.glyph's owninkAscentPt/inkDescentPt, which pdf-codec computes by walking the embedded font's Type 2 charstrings (see its README's owncff-bounds.tsnote). The fallback is not dead code: a glyph that draws nothing (a space) or whose charstring that reader declines to walk reports neither bound, and takes the font-wide nominal extent instead, which is also what aMathFontMetricsimplementation with no outline parsing at all would supply for every 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/munderovercentre an over/under-script at the base glyph's own font-declared accent-attachment point (MathTopAccentAttachment) when one is available, falling back to geometric centring otherwise.src/mathml/layout.ts'sresolveTopAccentXPt/AccentAttachment/layoutUnderOverresolve the embedded font's realMathTopAccentAttachmentmetric for a base that is a single-codepoint token under a genuineaccent="true"/accentunder="true"mark, tested insrc/mathml/layout.test.tsagainst both a real attachment-point case and the geometric-fallback case (a multi-character base, where there is no single glyph's attachment point to resolve). Geometric centring survives as the correct fallback for exactly that multi-character/non-token case, not as the general rule. -
Greek
mathvariantmapping covers the plain alphabet, nabla (∇), partial differential (∂), and the six OpenType/Unicode Greek "symbol variant" glyphs (lunate epsilon/theta/kappa/phi/rho/pi symbols — U+03F5/U+03D1/U+03F0/U+03D5/U+03F1/U+03D6 — styled to bold, italic, bold-italic, bold-sans-serif, and sans-serif-bold-italic; Unicode never assigned symbol-variant glyphs for plain sans-serif, script, fraktur, or double-struck). Every entry is generated directly from Unicode's ownUnicodeData.txt(seesrc/mathml/variant.ts's own generation note) rather than transcribed by hand. -
A formula anchored to a spreadsheet cell renders for real now (
src/layout/sheets.ts'srenderAnchoredFormulas), and closing it needed both sibling packages to move first — it was never something this module could wire around on its own.odf.jshad to learn to emit a cell-anchored formula sub-object at all (2.1.0 gavereadOdsa realTableCursorwalk and true row/column anchoring, but its embedded-object classifier still recognised only wordprocessing/presentation/spreadsheet/drawing sub-documents, so a formula was skipped outright; 2.2.0 classifies one), anddocument-schema.jshad to giveContentEmbeddedObjectsomewhere to record which cell it belongs to (2.2.0's optionalanchorRow/anchorColumn/offsetXPt/offsetYPtquartet, mirroring whatContentSheetImagealready carried). That quartet is exactly what makes placement possible: a cell-anchoreddraw:frame's ownsvg:x/svg:yis relative to that cell's top-left corner, not the sheet's origin, so without an anchor there is no coordinate space its frame can be interpreted in at all.sheets.tsresolves the anchor against its own already-positioned column/row axes — so band membership, the repeat band, the header gutter, and fit-to-page scaling are all accounted for by construction — and applies the cell-relative offset unscaled, matching this module's own existing treatment of every other cell-local inset (cell text padding, header-label padding): fit-to-page scales the grid's geometry, never a cell's internal padding or its text's point size. Three consequences worth naming. (1) The print range widens to cover a formula's anchor cell when the sheet declares no explicittable:print-ranges— a cell-anchored drawing genuinely extends a sheet's used area in Calc/Excel, and without this a formula anchored past the last populated cell would fall outside every band and silently never render; the union is over anchor cells only, never each formula's own rendered box, so an oversized formula overflows over whatever follows exactly as it does in Calc rather than reserving empty rows nothing occupies. An explicit print range is still honoured verbatim, so a formula anchored outside one is correctly not printed. (2) A formula anchored inside a repeat row/column band renders on every page that band appears on, which is what a repeat band means — no special case, since the band is simply present in every page's own axis. (3) A formula anchored to a hidden row or column is skipped outright, exactly as that cell's own content is.ContentSheet.imagesremains the separate, still-open gap, and now on the layout side alone:buildOdsPackagewrites a real floatingdraw:frame/draw:imagefor one andodf.js2.2.0'sreadOdsreads it back, butsheets.tsstill emits noLayoutImagefor it. -
convertSpreadsheetToLayoutreturns{ document, formulas }, not a bareLayoutDocument— the sameSpreadsheetLayoutResultshapeconvertWordprocessingToLayout/convertPresentationToLayouthave always returned, for the same reason: a formula's CID-font glyph runs cannot travel throughLayoutDocument.pages[].itemsat all (see the Gotchas entry on why), so they come back alongside the document and are handed towritePdf({ formulas }).odsToPdfthreads them through exactly asodtToPdf/odpToPdfalready did. A caller of the exportedconvertSpreadsheetToLayoutreads.documentwhere it previously used the return value directly.convertDrawingToLayoutstill returns a bareLayoutDocument, sincereadOdgContentruns no formula detection and a drawing page consequently never carries a formula block. -
The formula-size heuristic (
formulaSizePtFromFrame,src/layout/shared.ts) is now one shared function rather than three copies, consumed identically byengine.ts(flow placement),slides.ts(shape placement), andsheets.ts(cell-anchored placement): half the embedded object's own declared frame height, floored at 8pt. Its documented limit is worth restating with a measured example, since the ods fixture exercises it directly: it assumes a roughly single-line formula (total height a little over twice the base font size), so a frame sized for a genuinely stacked formula over-estimates.src/test-support/ods-formula.ts's real LibreOffice file declares a 4.5cm-tall frame for a fraction-plus-radical expression, which this heuristic renders at ~64pt — visually much larger than Calc itself draws it, and wide enough to run past the page's right edge. Position is correct; size is an approximation, exactly as the heuristic says. Replacing it with a two-pass fit (lay out once at a reference size, rescale by the frame's own width/height ratio, lay out again —layoutFormula's output scales linearly insizePt, so no iteration is needed) is a real, tractable improvement, but a cross-engine one that would change docx/odt/odp output too, so it is tracked rather than done here alongside the sheets work. -
Embedded-formula detection inside odt/odp is genuinely new work with no
odf.js-side equivalent (readDrawFrameContentdoesn't recognise adraw:object-bearingdraw:frameat all yet — see thesrc/odf/architecture entry above), and each format's own placement is now derived from the exact walkodf.jsitself used, rather than approximated. For odt (src/odf/odt/read.ts): a formula frame is found wherever it actually is — a direct child ofoffice:text, one nested inside adraw:ggroup, one anchored inline inside a paragraph's own run content (text:anchor-type="as-char", the shape LibreOffice writes for a formula typed into a sentence), and one inside a list item's own paragraph. Each block lands at its true position among the paragraphs/tablesodf.jsalready read, because this adapter mirrorsreadOdt's ownreadBlockswalk to count how manyContentBlocks eachoffice:textchild contributes — the per-element bookkeeping that was previously missing and forced every formula to be appended at the end (atext:listunwraps into oneContentParagraphper item at every nesting level, so "one raw child = one block" does not hold, which is exactly why counting rather than indexing is required). Two bounded, honest details remain: an inline formula's block is placed immediately after the paragraph containing it rather than truly inside it (ContentRunis text-only, soContentBlockhas no inline slot for an embedded object, and splitting the paragraph around the formula would invent a boundary the source never had), and an inline frame carriessvg:width/svg:heightbut nosvg:x— so its recovered frame is the declared size at a zero origin the text flow replaces, which is all the wordprocessing layout engine reads from it anyway. For odp (src/odf/odp/read.ts): every formula on every slide is detected, groups included.collectSlideFormulaFramesreplicatesodf.js's ownwalkDrawShapestraversal exactly — document order, recursing into adraw:g's children with that group's owndraw:transformcomposed, one shape perdraw:framewhose geometryreadDrawFrameresolves and none for any it cannot — so the shape index it counts is the indexreadOdpassigned. The previous "skip the whole slide if it contains anydraw:g" narrowing existed only because the old correspondence was "Nth top-level frame =shapes[N]", which a group breaks by splicing its own frames into the same flat array; deriving the index from the same walk removes the ambiguity rather than working around it. ods needs no detection pass of its own at all, unlike odt and odp:odf.js2.2.0's ownreadOdswalks eachtable:table-cell's children with a realTableCursorand classifies an embedded formula sub-document directly (readOdfFormulaDocument, alongside the wordprocessing/presentation/spreadsheet/drawing kinds its 2.1.0 classifier already recognised), so a cell-anchored formula arrives as an ordinaryContentSheet.embeddedObjectsentry already carrying its own anchor.src/layout/sheets.tsconsumes that directly — see the cell-anchored-formula gotcha below. -
A formula crossing a boundary that cannot typeset it degrades to its own plain-text stand-in — its StarMath annotation, or the literal
[formula]— never to nothing. The docx bridges are no longer part of that list.buildDocxPackagenow writes a genuine OMML display equation (m:oMathPara>m:oMath, structurally translated bysrc/omml/write.ts— see the architecture entry above), so a formula crossingodtToDocx, or reaching a docx through any otherbuildDocxPackagecaller, arrives as real, editable Word math rather than text. The stand-in survives there for exactly one case: a formula whose MathML produces no OMML content at all (an emptymathmlarray). An individual MathML construct with no OMML counterpart degrades on its own, inside the equation, as a literal-text run with anunsupported-elementdiagnostic reported throughbuildDocxPackage's ownonMathDiagnostic(threaded fromodtToDocx/markdownToDocx'sDocumentBridgeOptions) — it never drags the whole formula down to text.buildOdtPackageis no longer on that list either: it writes a real embedded formula sub-document (a nestedObject N/content.xmlwith its owndraw:frame/draw:objectreference and manifest entry — see thesrc/odf-package/architecture entry), with the identical single-case fallback, a formula carrying no MathML nodes at all. The markdown writer is the only genuinely stand-in-only path left, since CommonMark/GFM has no math construct whatsoever.odmToPdfis not part of this list either: a chapter's formula is an ordinary block inside that chapter's ownContentDocument, so it survives concatenation into the combined document exactly as a paragraph does and renders as genuine typeset MathML. That used to be a documented gap — the formulas travelled in a side-channel map keyed bysourcePath, and re-keying every entry against the combined document's own renumbered block indices was intractable — which moving a formula's content into theContentDocumentremoved outright rather than solved. -
OMML is read as well as written, but the two directions are deliberately not symmetric in coverage.
readDocxContentrecovers a docx equation as a realContentEmbeddedObjectBlockcarrying its own MathML — the identical shapereadOdtContentproduces for an ODF embedded formula — sodocxToPdftypesets a Word-authored equation, andodt → docx → odtcarries a formula through as a formula. The reader covers strictly more than the writer emits, because it has to read what Word wrote rather than only what this package wrote:m:d,m:nary,m:acc,m:bar,m:func, andm:sPrehave exact MathML inverses and no writer counterpart at all (see thesrc/omml/architecture entry). What that asymmetry costs in practice: adocx → odt → docxround trip of a Word-authoredm:dcomes back as explicitmofence tokens inside anmrowrather than as an auto-growingm:ddelimiter again, anm:narycomes back as a scripted operator followed by its operand rather than as anm:nary, and anm:sPredegrades outright on the way back out, sincemmultiscriptsis one of the constructssrc/omml/write.tshas no OMML expression for. The mathematics survives every one of those hops; only the specific OMML construct that expressed it does not. Three further real, tracked read-side boundaries: an equation inside a TABLE CELL is not recovered (a cell's paragraphs are blocks of aContentTableCell, not top-level blocks, so they neither participate in thew:p-ordinal correspondence nor have a top-level position to splice into — the same scope linebuildDocxPackage's ownappendCellBlockdraws on the write side); OMML records no geometry whatsoever, so a recovered block'sframeis a stand-in whose only meaningful field isheightPt, taken from the equation's ownw:rPr/w:szwhen it states one and from Word's own 11pt body default otherwise, stated as the exact inverse ofsrc/layout/engine.ts'sframeHeightPt / 2size estimate; and anmtextthat carried an explicitmathvariantwas written as an ordinary styled math run, which OMML gives no way to distinguish from a styledmi, so it reads back asmi/mn/morather than asmtext. -
The OMML translator covers exactly the construct set
src/mathml/layout.tstypesets, no more — the two are kept aligned deliberately, not by accident.mrow/mstyle/semanticsflatten (every OMML argument slot already holds a sequence, so OMML has no row element of its own);mi/mn/mo/mtextbecomem:r/m:truns, withmtextwritten as OMML normal text (m:nor) and everymathvariantmapped onto them:scrscript +m:stystyle pair — a mapping with no residue, since OMML's two axes span MathML's fourteen values exactly. The honest limits: a stretchy fence is written as an ordinary operator run rather than as an auto-growingm:ddelimiter — which now genuinely DIVERGES from the PDF path, where a fence does stretch to its content (see the stretchy-fence gotcha above): Word will render the docx fence at its base size where the PDF renders it assembled and full height. A tracked, bounded gap, not a silent one; closing it means emitting a realm:dwith the fence characters as itsm:begChr/m:endChr, which is a different write shape from the run-per-token one the rest of this translator uses.munderoverbecomes a nestedm:limUpp/m:limLowpair rather than anm:nary, becausem:nary's ownm:eslot is the operand being summed and MathML records no operand insidemunderoverat all (it sits outside as a following sibling, with nothing marking where it ends — choosing one would be guessing at operand scope), andmspacebecomes a single literal space with anapproximated-elementdiagnostic, since OMML has no width-parameterised spacer anywhere in its vocabulary.mathvariantis carried as markup only: the characters themselves stay in their base form rather than being rewritten into the Mathematical Alphanumeric Symbols block the wayapplyMathVariantdoes for glyph rendering, which would double-apply the style in Word. Thexmlns:mdeclaration goes on the fragment's own root rather than onw:document, so an equation appended throughDocxParagraph.appendOfficeMathstays valid inside a docx this package did not scaffold. -
sourcePathtraces aLayoutItemback to theContentDocumentnode it came from, but only within one read+layout pass.ooxml.js'sreadDocx/readPptxstamp everyContentRun/ContentImageBlock/ContentTable/ContentShapewith a positional path (sections[0].blocks[2].runs[1],slides[1].shapes[3].blocks[0]);convertWordprocessingToLayout/convertPresentationToLayoutcopy that same string onto whicheverLayoutText/LayoutImage/LayoutLink/LayoutRectitem(s) it produces, so a positioned PDF-side item can be traced back to its semantic origin. When line-wrapping splits one run's word across a run boundary, every resulting fragment gets its own run's path (not a shared or merged one); when a single run is emergency-split across several lines or pages, every resulting fragment keeps that same one run's path unchanged. A table cell's backgroundLayoutRectis attributed to its containing table's ownsourcePath, sinceContentTableCellcarries none of its own. This is not an edit-tracking or incremental-relayout mechanism — the path is only valid against the exactContentDocument/Packageit was assigned from in that one read; editing the document, re-reading it, or reordering its blocks invalidates every previously-captured path, and nothing here recomputes or diffs paths across two versions of a document. -
readMarkdownContentpasses markdown-codec'sreadMarkdownreturn value straight through, unlikereadDocxContent/readOdtContent/etc., which build a freshContentDocumentenvelope from a narrower, format-specific shape.markdown-codec's ownreadMarkdownalready produces a fulldocument-schema.jsContentDocumentdirectly (kind/formatVersion/metadata/sections) — the identicalContentDocumenttypedocuments.jsitself imports and re-exports fromdocument-schema.js, with no local schema of its own to reconcile against — so, after narrowing to thewordprocessingvariant, there is nothing left to rebuild. -
Every construct-mapping gap either
readMarkdownContent(read) orbuildMarkdownText(write) cannot represent losslessly is markdown-codec's own documented, reachableMarkdownDiagnosticCodesentry, surfaced through whateversinka caller passes toreadMarkdownContent/buildMarkdownTextdirectly (theDocumentToPdfOptions/DocumentBridgeOptionsshapesmarkdownToPdf/markdownToDocx/markdownToOdtaccept have no room for one — see those types' own doc comments) — not a silent approximation:md/invented-page-geometry— markdown has no page concept of its own; every lowered document gets oneContentSectionwith A4 + 1in default page geometry (overridable viareadMarkdownContent's ownpageSize/marginsoptions). Fires unconditionally, once per lowered document.md/nested-emphasis-flattened— emphasis nested inside the identical kind (emphasis-in-emphasis, strong-in-strong) flattens to one run rather than preserving the nesting.md/link-title-dropped— a link or image's own title attribute ([text](url "title")) has noContentRun/ContentImageBlockfield to survive on.md/code-block-info-string-dropped— a fenced code block's own info string (the language tag after the opening fence) has noContentParagraphfield to survive on.md/blockquote-nested-depth— a blockquote nested beyond one level is recorded only as an indent depth (indentLeftPt), never a genuine container boundary; two independent blockquotes back to back at the same depth are indistinguishable from one that spans both.md/list-item-block-unlisted— a table or a resolved image directly inside a list item has no way to carryContentListMembership, which lives only onContentParagraph.md/list-item-multi-block-flattened— a list item containing more than one non-nested-list block loses its own item-boundary identity once lowered.md/image-unresolved— an image with noMarkdownImageResolversupplied (or one that returnsundefined, or resolved bytes that are neither a readable PNG nor JPEG) degrades to a hyperlinked text run of its own alt text, never an invalidContentImageBlock.md/raw-html-preserved-as-text/md/raw-html-dropped— raw HTML is preserved as literal text by default (styleIdHTMLPreformattedfor block-level HTML) or dropped entirely (rawHtml: 'drop'); markdown-codec's own read side never sanitises or interprets it.md/front-matter-key-unmapped— a leading YAML front matter block is not parsed by a real YAML/TOML engine; onlykey: valuelines (plus one array special case forkeywords) mapping onto five knownLayoutMetadatafields are recognised, everything else is reported and dropped.md/heading-level-clamped— aContentDocumentheading styleId beyondHeading6(reachable from another format'sContentDocumentviadocxToMarkdown/odtToMarkdown) clamps to level 6, since neither ATX nor setext syntax spells a deeper level.md/adjacent-links-mergedandmd/code-span-as-monospace-run— a run of adjacent hyperlinks sharing one destination merges into a single markdown link; a monospace-font run without a genuine code-span origin still emits as a code span, sinceContentDocumenthas no separate "this was actually a code span" marker.md/paragraph-indent-dropped— a paragraph carryingindentLeftPtwith none of the five styleIds markdown-codec's own blockquote/code-block/rule/HTML-preformatted convention recognises (reachable viadocxToMarkdown/odtToMarkdown) is a genuine cross-format ambiguity this package cannot resolve; the indent is dropped, the paragraph still renders.md/list-numid-fallback— a docx/odt-sourcednumId(viadocxToMarkdown/odtToMarkdown) that markdown-codec never minted itself falls back to a plain, tight, non-task bullet list.md/table-cell-formatting-droppedandmd/table-cell-multi-paragraph-joined— a GFM table cell's own run-level formatting beyond plain text, and a cell containing more than one paragraph (both reachable viadocxToMarkdown/odtToMarkdown, since docx/odt table cells support both), are both lossy: GFM's own table-cell grammar has no multi-paragraph or rich-formatting representation to write back to.
-
buildMarkdownTextthrowsMarkdownUnsupportedDocumentKindErrorfor a non-'wordprocessing'ContentDocument, matchingbuildDocxPackage/buildOdtPackage's own "throw outright for the wrong document kind" convention — markdown has no presentation/spreadsheet/drawing equivalent to render, sodocxToMarkdown/odtToMarkdownnever need a redundant guard of their own before calling it (seesrc/markdown/write.ts's own module comment). -
decodeMarkdownText(src/markdown/text.ts) throwsMarkdownInvalidUtf8Errorfor malformed UTF-8 input, rather than silently producing U+FFFD replacement characters. This is the third place this exact invariant is enforced independently:MarkdownBytesSchema(bothdocuments.js's own local copy insrc/model/bytes.tsand markdown-codec's own in that package'ssrc/codec.ts) catches it at the schema-validation boundary (z.decode(markdownPdfCodec, ...)/z.decode(markdownDocxCodec, ...)/etc.), anddecodeMarkdownTextcatches it again formarkdownToPdf/markdownToDocx/markdownToOdt, which callreadMarkdownContentdirectly on already-decoded bytes rather than through a schema. -
markdown was wired into the capability/path-resolver model (
src/convert/capability.ts) as a genuine thirdwordprocessing-variant node, but the four markdown cross-format bridge functions are hand-written, not generically composed.resolveConversionPathcan, in principle, find a one-hop composed path for any pair sharing an intermediate node — the identical mechanism that already lets it independently rediscover the hand-composedxlsxToPdf/pdfToXlsxroute (xlsx → ods → pdf) — butcreateLocalDocumentConverter(src/convert/local.ts) only ever executes a'direct'strategy, never a'composed'one. Wiringmarkdown ⇄ docx/markdown ⇄ odtinto theDocumentConverterport therefore still required four real, callable, registered bridge functions (markdownToDocx/docxToMarkdown/markdownToOdt/odtToMarkdown,src/convert/convert.ts) added toDIRECT_EDGES, exactly asxlsxToPdf/pdfToXlsxneeded hand composition despite the resolver's own theoretical reach — the resolver's composition ability describes what a caller could build by hand, not something the port executes automatically on their behalf.
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 — how close depends on which typeface the document asks for and whether it embedded one, see the font-resolution gotcha above.
odf → PDF (odfToPdf), and a formula embedded inside odt/odp/ods, 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. A vertical fence around a tall construct genuinely stretches too, assembled from the font's own MathVariants pieces and sized to what it wraps, rather than drawn at a fixed base size — and an over/under-brace spanning its own munder/mover/munderover base stretches horizontally on the identical basis, sized to that base's own width. The honest limits: msqrt/mroot still draw a hand-built radical sign rather than the font's own stretched one (a structural reason, not because the font data is unavailable — see the Gotchas entries above); 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. For a formula anchored to a spreadsheet cell, position is genuinely resolved against that sheet's real column/row geometry (verified end to end against a real LibreOffice-authored .ods), but the rendered size comes from the same frame-height heuristic every engine uses, which over-estimates for a stacked formula — see the Gotchas entry on formulaSizePtFromFrame. 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. Two further kinds of content are recovered on top of that text, each on its own explicit terms: a real ContentTable, but only where a genuine drawn gridline lattice is detected, never from text alignment (which would be inventing structure, not recovering it); and a page's vector primitives, into a nested drawing document that currently reaches the ContentDocument pivot but not the output bytes. Both are covered in full by their own Gotchas entries.
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 handles that in two layers. The printed string is always preserved exactly, in the required displayText field, whatever else happens. On top of that, a heuristic, explicitly probabilistic re-typing step (src/layout/cell-typing.ts) sets a typed value — number, percentage, currency, date, or boolean — wherever the rendered string has exactly one defensible reading, and deliberately leaves it a string wherever it does not, reporting both outcomes through ReconstructOptions.onCellTypeInference. This is inference, not recovery: a re-typed value is a best-effort guess about what the source cell held, and a numeric-looking string may genuinely have been a string. The confidence bar is stated in full in the pdfToOds re-typing gotcha above (lossless representation, unambiguous separators, no leading zeros, role-unambiguous dates) — "42.5" and "2024-01-15" are re-typed, "1,234", "007", "01/02/2024" and "Yes" are all declined and reported. A formula is still never claimed, since nothing about a rendered value implies one was computed. See the same gotcha for the detection algorithm that finds the cells in the first place (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 carrying its own printed text verbatim.
markdownToPdf/pdfToMarkdown is the single lossiest round trip in the whole package, and deliberately so. markdownToPdf itself is a genuine, faithful layout render — readMarkdownContent produces the identical WordprocessingContentDocument shape readDocxContent/readOdtContent do, so it feeds convertWordprocessingToLayout completely unmodified, the same engine every other wordprocessing conversion in this package shares — but pdfToMarkdown stacks TWO independent layers of lossiness on the way back, not one. The first layer is the same one every PDF → docx/pptx/odt/odp direction already carries: reconstructWordprocessing recovers paragraph and text-block structure from nothing but positioned glyphs, a best-effort geometric approximation, never an exact recovery (see above). The second layer is new, and unique to markdown: buildMarkdownText then has to fit whatever reconstructWordprocessing recovered into CommonMark/GFM's own, much coarser vocabulary — no colour, no font family, no font size, no explicit paragraph alignment, no page geometry at all. A round-tripped bold run survives as real **bold** markdown syntax; a round-tripped coloured, specifically-sized run does not survive as anything — there is no markdown construct for either to become. This is a categorically worse case than pdfToOds's own "recovers what was printed, not what was entered" limit: pdfToOds at least recovers a bare string faithfully into a real spreadsheet cell; pdfToMarkdown recovers a bare string too, but into a format that then discards most of whatever formatting the reconstruction step itself already approximated.
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/xlsxPdfCodec/markdownPdfCodec/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 first three cross-format bridge pairs (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 (merged cells included), a rotated shape, and (for odp ⇄ pptx) speaker notes and a table shape (a slide shape whose own content IS a table, not a text box) 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). ods ⇄ xlsx preserves cell values, semantic kinds (percentage/currency/date/boolean, since ooxml.js 2.6.1's number-format engine), formulas (verbatim), merged ranges, and column widths (both hops, within tolerance) completely — but still carries a small number of real, honestly-documented format-boundary limits of its own (a time cell has no xlsx serial to write and downgrades to a plain string, and a formula written in one dialect can show 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 or value kind with no counterpart on the other side), not an approximation introduced by the bridge itself. An embedded formula survives odtToDocx as real, editable OOXML math rather than as plain-text: buildDocxPackage translates the block's own MathML into genuine OMML (src/omml/write.ts), covering the identical construct set the PDF path typesets — only a construct OMML itself has no counterpart for degrades, individually and with a diagnostic. The reverse hop, docxToOdt, reads that equation back as real MathML (src/omml/read.ts) and writes it into the odt as a genuine embedded formula sub-document (src/odf-package/formula.ts), so odt → docx → odt keeps a formula as a formula — proven by src/convert/formula.test.ts's own chain test, which compares the MathML recovered at the far end against the MathML the source carried and repeats the whole cycle three times to confirm nothing accumulates or erodes. What the two hops do NOT guarantee is that the same OMML construct comes back out, since the reader covers more of Word's own vocabulary than the writer can express — see that gotcha for exactly which constructs change shape.
The two markdown cross-format bridge pairs (markdownToDocx/docxToMarkdown, markdownToOdt/odtToMarkdown) bypass the PDF pivot entirely too, exactly like the three pairs above — but "no PDF-pivot lossiness" is not the same claim as "no lossiness at all", and conflating the two here would misdescribe what these specifically preserve. There is genuinely no layout engine and no geometry-based reconstruction anywhere in either bridge's own call path (proven the same way the three pairs above are, by src/convert/bridges.test.ts's own spy-based "the layout engine was never called" assertions) — markdownToDocx/markdownToOdt carry a heading's Heading1-style styleId, a bold/italic run, list membership and nesting level, and GFM table structure through to a real docx/odt ContentDocument with zero approximation, and docxToMarkdown/odtToMarkdown carry the reverse just as faithfully for whatever markdown itself can represent. The asymmetry is upstream of the bridge mechanism, in what CommonMark/GFM's own grammar has room for at all: a docx/odt run's colour, explicit font family/size, and paragraph alignment have no markdown source construct to survive as, so docxToMarkdown/odtToMarkdown drop them — not because the bridge approximates anything, but because there is nothing to carry them in. Going the other way, markdownToDocx/markdownToOdt never invent formatting markdown never expressed, so nothing is lost on that hop that wasn't already absent from the source. This is real, permanent, format-boundary lossiness, on exactly one side of the pair — a different shape from ods ⇄ xlsx's own several small, independent format-boundary gaps (percentage/currency, time/date, formula dialect), but a real loss all the same, not the "categorically different, no round-trip-lossless caveat at all" case the three original bridge pairs are.
.odb table extraction (readOdbTables, all four 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 4 (HSQLDB whole-script BINARY/COMPRESSED) is Tier 1's own equal in fidelity, not a degraded variant of it: the DDL it recovers is the identical statement text a TEXT-format script would have carried, and the row values it decodes come from the same per-column binary encoding Tier 2 reads, verified against the engine's own JDBC read-back of both real fixtures. 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. BLOB column content is genuinely recovered too, byte-for-byte — see the dedicated Gotchas entry above for the record shape and the base64 data: URI a binary blob arrives as, which is a ContentCellValue schema gap rather than a decoding one. Tier 3 retains two real, bounded, honestly-scoped gaps, both documented in code comments at the exact spot each applies: no FB4+-only types (INT128/DECFLOAT, i.e. a NUMERIC/DECIMAL column wider than 18 digits of precision), which is a hard environmental limit rather than a decoding shortcut — see the Gotchas entry above for the empirical confirmation that LibreOffice's own bundled engine cannot declare such a column at all, so no .odb exists to verify a decoder against; 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 any real fixture this reader was verified against, but a real gap on a .odb whose tables carry comments or computed columns.
Running a .odb's own saved query (parseSelect/evaluateSelect) is exact within its grammar, and a hard failure outside it — never an approximation. Unlike every conversion above, there is no fidelity spectrum here: a statement either falls inside src/odb/sql/parser.ts's closed grammar, in which case the rows it returns are the rows SQL defines for it (three-valued NULL logic, NULL-aware aggregates, stable multi-column ordering — see the Gotchas entries above for each decision spelled out), or it falls outside, in which case it throws with the construct named. Nothing in between: the engine never drops a clause it cannot handle and returns the rest. What it is not is a database — there is no query planner, no index, no transaction, no cursor, and every row of the table is materialised in memory by readOdbTables before a single predicate runs. Verified end to end against a real saved query in a real LibreOffice-generated .odb: src/odb/sql/query.test.ts reads form-and-report.odb's own HighValueSales command out of the package via readOdbInventory (rather than restating it), runs it against the same package's real six-row SALES table decoded by the Tier 3 Firebird reader, and asserts the exact four surviving rows in the exact order its three-term mixed-direction ORDER BY demands.
Evaluating a .odb Report's own rpt formulas (runRptReport) is exact within its function set, on the same terms. There is no fidelity spectrum here either: a formula either falls inside src/odb/formula/parser.ts's closed set, in which case its value is the value that function defines, or it falls outside, in which case it throws naming the function. The group scoping is likewise defined rather than approximated — instance boundaries follow one stated recurrence, and each aggregate covers exactly its own instance's row range (see the Gotchas entry above). What this engine on its own is not is a renderer: it produces evaluated band instances, not content, which is src/odb/report/'s job below. Verified end to end against a real report in the same real LibreOffice-generated .odb: src/odb/formula/report.test.ts reads form-and-report.odb's own SalesByRegion report structure out of the package via readOdbReport (rather than restating any of it), resolves its rpt:command to that package's own HighValueSales saved query, runs the query through src/odb/sql/, and then asserts the whole band stream — the exact print order, the exact row each band was emitted against, the two-character prefixes the report's own LEFT_QUARTER function computes, and the exact AMOUNT total in each of the three real rpt:SUM([AMOUNT]) scopes. The same real report definition is also run over all six SALES rows rather than the four the query keeps, which exercises the enclosing-break cascade a second time at a transition (South/Q2 → West/Q2) where the inner group's own expression is false; the per-region totals it reaches that way are cross-checked against the ones src/odb/sql/'s GROUP BY REGION reaches by a completely different route over the same data.
Rendering a .odb Report (readOdbReportContent) is structurally faithful, not pixel-faithful, and the line between those is exactly where odf.js's own report reader stops. What is exact: which bands print, in what order, against which rows, with which group instances open, and what every formula in them evaluates to — all of that is the two engines above, which are exact within their own closed sets. What is structural: each printed band becomes one single-row ContentTable, one cell per control in document order, which is the shape the band genuinely has in the report file (every band there is a table:table whose cells hold its controls) rather than a guess at one. The alternative shape — a paragraph per field — was rejected, not merely not chosen: it would stack a detail row's Customer and Amount vertically, destroying the one relationship a banded report's layout grid exists to express.
What is not reproduced is presentation, because it is not read in the first place: a control's own font, colour, alignment, number format, and grid position live in its style, which odf.js's report reader deliberately does not resolve (that reader's own finding 3 states it — a control's grid position is presentation, not structure). So a numeric value renders as its own plain display text (1200.5, not the 1,200.50 the report's own format might produce), no band carries a font or a border, and column widths divide the section's content width equally between a band's cells, which is a stated fallback rather than a recovered measurement. Pagination is not reproduced either: this renderer declares one logical page rather than guessing where breaks fall (see the Gotchas entry for what that means for the two page bands). The bands' own identity does survive, as each cell's paragraph styleId (Group Footer 1, Detail, …), so a consumer can restyle by band without having to infer which band a block came from.
Verified end to end against the real report in the real LibreOffice-generated .odb: src/odb/report/content.test.ts renders form-and-report.odb's own SalesByRegion — its binding resolved from rpt:command-type="query" to that package's own HighValueSales command, its rows decoded by the Tier 3 Firebird reader, its formulas evaluated by src/odb/formula/ — and asserts the entire block sequence exactly: every band in print order, both REGION groups each containing its own QUARTER sub-groups, every detail row in the query's own order, and the SUM(AMOUNT) total in all three scopes, each computed by hand from the real six-row SALES data and asserted as both its rendered text and its exact number (1540.50/2750.25/1810.00 per quarter, 4290.75/1810.00 per region, 6100.75 overall; and over all six rows rather than the four the query keeps, 1540.50/2750.25/95.75/1810.00/60.00, 4290.75/1905.75/60.00, and 6256.50). The rendered document is also parsed against ContentDocumentSchema and pushed through convertWordprocessingToLayout/writePdf, so the claim that it needs no odbToPdf of its own is proven rather than asserted.
Optional real-world corpus. The gitignored, manual real-world PDF conformance harness this README used to describe here now lives in pdf-codec's own repository, since it exercises the PDF codec directly rather than anything this package adds on top.
.github/workflows/ci.yml runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to main where those all pass, release.config.ts drives semantic-release: commit history since the last tag decides the version bump, CHANGELOG.md and package.json are committed back to main, a GitHub Release is cut, and the package publishes to npmjs.org — via npm's OIDC trusted publishing, so no NPM_TOKEN exists anywhere in the pipeline.
Whether that release actually published a new version is detected by diffing package.json's version before and after the release step, not by trusting a third-party action's own detection. Two further jobs gate on that: one republishes the same build under the scoped @exadev/documents.js alias to GitHub Packages (which has no OIDC exchange of its own, so it authenticates with GITHUB_TOKEN instead), and one packs the release into its own directory, generates an SPDX SBOM (pnpm sbom), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.
Commits follow Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint (commitlint.config.ts) via a husky commit-msg hook and a CI commitlint job — semantic-release's version bump depends on these being well-formed, not just style. A husky pre-commit hook runs lint-staged (eslint --fix on staged *.ts files) and pre-push runs the test suite. There is a single main branch and no open pull request workflow established so far.
- ooxml.js — the sibling package this depends on for all docx/pptx/xlsx ⇄ JSON handling and cascade-resolved typed reading, including its own
readXlsxContent/buildXlsxPackage(aContentDocument-shaped xlsx reader/writer pair), consumed directly bysrc/convert/convert.ts'sodsToXlsx/xlsxToOdsbridge but not re-exported from this package's own public surface. - document-schema.js — the sibling package that owns
ContentDocument/LayoutDocumentthemselves;ooxml.js,odf.js,pdf-codec,markdown-codec, anddocuments.jsall import from it rather than each maintaining an independent copy. - markdown-codec — the sibling package this depends on for CommonMark+GFM ⇄
ContentDocumenthandling (readMarkdown/writeMarkdown), also built ondocument-schema.js. A dependency ofdocuments.jsfor: this package'sMarkdownBytesSchema(src/model/bytes.ts), which checks well-formed UTF-8 the same way that package's ownMarkdownBytesSchemadoes;src/markdown/read.ts'sreadMarkdownContent, a thin adapter overmarkdown-codec's ownreadMarkdown, feedingmarkdownToPdf/pdfToMarkdownand themarkdownToDocx/markdownToOdtbridges (src/convert/convert.ts);src/markdown/write.ts'sbuildMarkdownText, the same adapter overmarkdown-codec's ownwriteMarkdown, feedingpdfToMarkdownand thedocxToMarkdown/odtToMarkdownbridges. markdown is the third format (after docx and odt) proven to share thewordprocessingContentDocumentvariant and its layout engine. - pdf-codec — the sibling package this depends on for the hand-written PDF codec itself (
readPdf/writePdf/pdfCodec), extracted from this repository: parsing arbitrary real-world PDFs and generating new ones, the embedded STIX Two Math font, and the text-measurement/font-resolution/byte/image primitivessrc/layout/builds on. See Architecture above for exactly where the boundary between the two packages sits, and pdf-codec's own README for its internals. - odf.js — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on
document-schema.js. A dependency ofdocuments.jsfor: this package'sOdt/Ods/Odp/OdgBytesSchema(src/model/bytes.ts), which validate against itsODF_MEDIA_TYPEStable;src/interop.test.ts, a type-level guard thatooxml.js's andodf.js's rawXmlElement/XmlNode/Attribute/Packagecontainer types stay structurally compatible;src/odf/odt/read.ts'sreadOdtContent, a thin adapter overodf.js's ownreadOdt, feedingodtToPdf/pdfToOdt(src/convert/convert.ts);src/odf/odp/read.ts'sreadOdpContent, the same adapter overodf.js'sreadOdp, feedingodpToPdf/pdfToOdp;src/odf/ods/read.ts'sreadOdsContent, the same adapter overodf.js'sreadOds, feedingodsToPdf/pdfToOds, and reused directly bysrc/edit/ods/print-settings.ts's ownreadSheetPrintSettings(findStyleElement/resolvePageLayoutProperties/parsePageSize/parseMargins, the same style-chain-resolution primitivesreadOds's ownreadPrintSettingsis built on);src/odf/odg/read.ts'sreadOdgContent, the same adapter overodf.js'sreadOdg— including its owntyped/shared/path.ts, the real-LibreOffice-output-verifiedsvg:d/draw:pointsparser this package'swritePathcontent is ultimately sourced from, and whichsrc/edit/odg/svg-path.ts'sbuildSvgPathData(the write-side inverse) also cross-checks its own output against directly — feedingodgToPdf/pdfToOdg(the latter re-reading a rebuilt package's own real geometry through this samereadOdg, not just writing one);src/odf/formula/read.ts'sreadOdfFormulaContent/readOdfEmbeddedFormula, thin adapters overodf.js's ownreadOdfFormula, feedingodfToPdfand the odt/odp embedded-formula paths respectively;src/edit/odt/*'sStyleRegistry/resolveStyle(style interning),src/edit/odp/shape.ts'sapplyOdfTransform/resolveOdfShapeGeometry(rotation), andsrc/edit/odt/automatic-styles.ts'sensureAutomaticStyles/nextStyleName(reused bysrc/edit/odg/style.ts's own graphic-family style writer andsrc/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 →ContentDocumentreading and PDF conversion are now all integrated both ways. - STIX Two Math — the embedded math font
odfToPdf(and the odt/odp embedded-formula paths) render through. Vendored, parsed, and embedded entirely withinpdf-codecnow (this repository no longer carries the font asset directly) — see that package's own README for the exact source commit/version and licensing (OFL-1.1) provenance. - 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(therec_type/att_typeenumerations and their own per-block numbering, and the backup-format version history),src/burp/backup.epp/restore.epp(the write/read reference implementationsrc/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 thexdr_hyperhigh-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), andsrc/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 timesrc/firebird/was built.
This package also publishes under the following alternate npm names — the identical build, same version, republished by CI alongside the primary documents.js package:
MIT