documents.js 1.52.0 → 1.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/documents.js) [![Release](https://img.shields.io/github/v/release/ExaDev/documents.js)](https://github.com/ExaDev/documents.js/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)
4
4
 
5
- > Bidirectional docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, six further cross-format bridges (odt⇄docx, odp⇄pptx, ods⇄xlsx) that bypass PDF entirely, Tier 1 `.odb` (ODF database front-end) table extraction to xlsx/CSV, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, a hand-written MathML presentation-layer typesetting engine with embedded-font PDF rendering (odf → PDF, plus formulas embedded inside odt/odp), and a fully hand-written PDF codec, built on [ooxml.js](https://github.com/ExaDev/ooxml.js) and [odf.js](https://github.com/ExaDev/odf.js).
5
+ > Bidirectional docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, six further cross-format bridges (odt⇄docx, odp⇄pptx, ods⇄xlsx) that bypass PDF entirely, `.odb` (ODF database front-end) table extraction to xlsx/CSV from an embedded HSQLDB TEXT script (Tier 1), HSQLDB's own binary CACHED-table row-store format (Tier 2), and an embedded Firebird database's own gbak logical-backup format (Tier 3), a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, a hand-written MathML presentation-layer typesetting engine with embedded-font PDF rendering (odf → PDF, plus formulas embedded inside odt/odp), and a fully hand-written PDF codec, built on [ooxml.js](https://github.com/ExaDev/ooxml.js) and [odf.js](https://github.com/ExaDev/odf.js).
6
6
 
7
7
  `documents.js` depends on `ooxml.js` for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions `ooxml.js` deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-**and-write** manipulation API for docx/pptx content — `ooxml.js`'s own typed readers (`readDocx`/`readPptx`) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are entirely hand-written: no external PDF library (`pdf-lib`, `pdfjs-dist`, `mupdf`, or any other) is a dependency. The one exception is [`fflate`](https://github.com/101arrowz/fflate) for raw DEFLATE/zlib compression underneath PDF's `FlateDecode` filter and PNG's `IDAT` chunks — the same dependency `ooxml.js` itself already relies on for ZIP handling. `src/mathml/` (the MathML typesetting engine) and the OpenType/CFF font parsing this package's own PDF writer uses to embed a real math font (`src/pdf/sfnt.ts`/`math-*.ts`) are both hand-written too, for the same "no supply-chain surface beyond what's already declared" reason — the one bundled binary asset is the vendored STIX Two Math font itself (OFL-1.1, see [Fidelity](#fidelity) and `assets/fonts/NOTICE.md`), not a library.
8
8
 
@@ -221,7 +221,7 @@ try {
221
221
 
222
222
  `odmToPdf` is not one of the twelve round-trip conversions or the six bridges above, has no `z.codec()` pair, and is not wired into the `DocumentConverter` port below — see Gotchas for why.
223
223
 
224
- Tier 1 `.odb` (ODF database front-end) support: `readOdbTables` extracts every table an embedded HSQLDB database declares in its own TEXT-format `database/script` part, and `odbToXlsx`/`odbToCsv` turn that straight into xlsx or CSV bytes a `.odb` never has any table data of its own inside the ODF package for any other embedded storage shape (see Gotchas/Fidelity for what that means for binary HSQLDB and Firebird):
224
+ `.odb` (ODF database front-end) support: `readOdbTables` extracts every table an embedded database declares, and `odbToXlsx`/`odbToCsv` turn that straight into xlsx or CSV bytes. Three embedded storage shapes are supported, dispatched automatically from the package's own connection URL and, for HSQLDB, its own per-table storage shape: a MEMORY/TEXT table's rows inline in `database/script` as ordinary TEXT-format SQL (Tier 1, `src/hsqldb/script.ts`), a CACHED table's rows in a separate binary page-cache file, `database/data` (Tier 2, `src/hsqldb/cache.ts`/`rowformat.ts` — LibreOffice's own embedded-HSQLDB default, see Architecture/Gotchas for the exact scope and version pinning), and a Firebird database's own `database/firebird.fbk` part — LibreOffice's modern default embedded engine since 4.1, a genuine gbak logical-backup stream rather than a raw on-disk database file (Tier 3; see the Gotchas entry below for the empirical finding this rests on). A caller never needs to know which shape or engine a given `.odb` used. HSQLDB's own whole-script BINARY/COMPRESSED serialisation is the one embedded shape still out of scope (see Gotchas/Fidelity):
225
225
 
226
226
  ```ts
227
227
  import { decodePackage } from 'odf.js';
@@ -230,11 +230,19 @@ import { odbToCsv, odbToXlsx, readOdbTables } from 'documents.js';
230
230
  const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table, a header row of column names then one row per record
231
231
  const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // exactly one named table as CSV -- required whenever the .odb has more than one table
232
232
 
233
- const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[], for a caller that wants the raw table/column/row data without going through xlsx or CSV
233
+ const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[], for a caller that wants the raw table/column/row data without going through xlsx or CSV -- the identical shape whether the .odb is HSQLDB- or Firebird-backed
234
234
  ```
235
235
 
236
236
  `readOdbTables` takes a decoded `Package` (matching `readOdtContent`/`readOdsContent`/etc.'s own convention), while `odbToXlsx`/`odbToCsv` take raw bytes and decode them internally, matching every other ergonomic conversion in this package. `.odb` has no PDF conversion, no reverse (xlsx/CSV → `.odb`) direction, and — like `odmToPdf` — is not wired into the `DocumentConverter` port below, since Reports require live SQL execution to render (categorically out of scope) and the write direction would need a real embedded SQL engine this package deliberately does not implement.
237
237
 
238
+ `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:
239
+
240
+ ```ts
241
+ import { readFirebirdBackup } from 'documents.js';
242
+
243
+ const { summary, tables } = readFirebirdBackup(firebirdBackupBytes); // summary: backupFormatVersion/transportable/compressed/pageSizeBytes; tables: the same HsqldbTable[] shape
244
+ ```
245
+
238
246
  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:
239
247
 
240
248
  ```ts
@@ -280,9 +288,11 @@ const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), siz
280
288
 
281
289
  The package is layered from generic primitives outward to the two conversion directions:
282
290
 
283
- - **`src/model/`** — thin, documents.js-specific additions on top of the sibling [`document-content-model`](https://github.com/ExaDev/document-content-model) package, which now owns the two pivot models themselves: `LayoutDocument` (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — `LayoutPath` is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind `writePath`, src/pdf/content-write.ts, turns into PDF `m`/`l`/`c`/`h` content-stream operators) and `ContentDocument` (the semantic pivot: a discriminated union of `wordprocessing`, `presentation`, `spreadsheet`, and `drawing` variants sharing paragraph/run/table/image building blocks, `drawing`'s own `ContentVector` vocabulary — rect/ellipse/line/path — being the vector-primitive counterpart to the shared `ContentShape`) are both imported, not defined here — `document-content-model` exists specifically so `ooxml.js`, `odf.js`, and `documents.js` share one schema instead of each maintaining an independent, drift-prone copy. What remains local: `bytes.ts` (magic-byte-validated `Uint8Array` schemas for docx/pptx/PDF, plus `Odt`/`Ods`/`Odp`/`OdgBytesSchema`, which check the package's actual declared media type against `odf.js`'s `ODF_MEDIA_TYPES` table rather than only the generic ZIP signature the OOXML schemas are limited to), `units.ts` (OOXML EMU/twip/point/half-point conversions), and `geometry.ts`/`color.ts`/`style.ts`, each now mostly a thin re-export of `document-content-model`'s `Box`/`Margins`/`PageSize`/`Color`/`Alignment`/`LayoutFont` — the one genuinely PDF-specific piece each still adds locally is `geometry.ts`'s `flipY` (the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates); `LayoutFont`/`DEFAULT_LAYOUT_FONT` moved to `document-content-model` too (since `LayoutText`, part of the pivot, needs the field), leaving only the standard-14 font *resolution* logic that consumes it (`src/pdf/fonts.ts`/`font-read.ts`) as PDF-specific and local.
291
+ - **`src/model/`** — thin, documents.js-specific additions on top of the sibling [`document-content-model`](https://github.com/ExaDev/document-content-model) package, which now owns the two pivot models themselves: `LayoutDocument` (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — `LayoutPath` is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind `writePath`, src/pdf/content-write.ts, turns into PDF `m`/`l`/`c`/`h` content-stream operators) and `ContentDocument` (the semantic pivot: a discriminated union of `wordprocessing`, `presentation`, `spreadsheet`, and `drawing` variants sharing paragraph/run/table/image building blocks, `drawing`'s own `ContentVector` vocabulary — rect/ellipse/line/path — being the vector-primitive counterpart to the shared `ContentShape`) are both imported, not defined here — `document-content-model` exists specifically so `ooxml.js`, `odf.js`, and `documents.js` share one schema instead of each maintaining an independent, drift-prone copy. What remains local: `bytes.ts` (magic-byte-validated `Uint8Array` schemas for docx/pptx/PDF, plus `Odt`/`Ods`/`Odp`/`OdgBytesSchema`, which check the package's actual declared media type against `odf.js`'s `ODF_MEDIA_TYPES` table rather than only the generic ZIP signature the OOXML schemas are limited to), `units.ts` (OOXML EMU/twip/point/half-point conversions), and `geometry.ts`/`color.ts`/`style.ts`, each now mostly a thin re-export of `document-content-model`'s `Box`/`Margins`/`PageSize`/`Color`/`Alignment`/`LayoutFont` — the one genuinely PDF-specific piece each still adds locally is `geometry.ts`'s `flipY` (the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates); `LayoutFont`/`DEFAULT_LAYOUT_FONT` moved to `document-content-model` too (since `LayoutText`, part of the pivot, needs the field), leaving only the standard-14 font *resolution* logic that consumes it (`src/pdf/fonts.ts`/`font-read.ts`) as PDF-specific and local. `content.ts` holds the one genuinely local piece of the `ContentDocument` envelope (`CONTENT_FORMAT_VERSION`), and `formula.ts` defines `EmbeddedFormula`/`PositionedFormula` — the side-channel formula shapes threaded alongside a `ContentDocument`/`LayoutDocument` rather than inside them (see the Usage section above) — the one file in `src/model/` with a local dependency of its own, a type-only import of `MathBox`/`MathMlNode` from `mathml` (see the dependency-direction note below).
284
292
  - **`src/bytes/`** and **`src/image/`** — generic byte and image-container primitives with zero PDF or OOXML knowledge: a chunked byte writer, a backtracking byte reader, CRC32, and a hand-written PNG decoder/encoder (palette/gray/RGB/alpha, multi-`IDAT` files, all five scanline filters) plus JPEG marker scanning for dimensions only — JPEG's compressed bytes pass through completely unchanged in both directions. `src/bytes/flate.ts` is the only file that imports `fflate`, mirroring how `ooxml.js`'s own `src/zip.ts` wraps it for ZIP handling.
285
- - **`src/xml/`** and **`src/opc/`** parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built over `ooxml.js`'s `Package`/`XmlNode`, needed because `ooxml.js`'s own XML nodes have no parent pointers and `ooxml.js` never writes new parts into an existing package.
293
+ - **`src/ports/`** — the two injectable ports this package's own "identity, clock, and observability are first-class ports" convention calls for: `abort.ts`'s `throwIfAborted` (a signal-check helper called at page/slide/row loop boundaries throughout `src/pdf/write.ts`/`read.ts` and `src/layout/sheets.ts`/`reconstruct.ts` the codebase has no `await` point for cancellation to hook into implicitly, since the local pipeline is synchronous end to end, so every long-running loop checks explicitly instead) and `clock.ts`'s `ClockPort`/`systemClock`/`fixedClock` (an injectable "now", for deterministic PDF output in tests). `ClockPort` is exported and tested in isolation but not yet consumed by any conversion path — `writePdf`'s own `/CreationDate`/`/ModDate` come directly from `LayoutDocument.metadata.createdIso`/`modifiedIso` when present, with nothing in this package's own write path calling `new Date()` to fill in a missing one, so there is currently no real call site for `ClockPort` to inject into. A real, tracked gap in wiring, not a documentation gap: a future default-timestamp write path should consume it rather than reaching for `new Date()` directly.
294
+ - **`src/xml/`** and **`src/opc/`** — parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built over `ooxml.js`'s `Package`/`XmlNode`, needed because `ooxml.js`'s own XML nodes have no parent pointers and `ooxml.js` never writes new parts into an existing package. `src/xml/odf-text.ts` is the one ODF-specific module in this directory: `encodeOdfText`/`decodeOdfText` convert between a plain string and ODF's own whitespace-run element sequence (`text:s` for a run of two or more literal spaces, `text:tab`, `text:line-break` — all three occupy real character positions in an ODF paragraph but are ELEMENTS, not text-node characters, unlike docx's flat `w:t` run text) — see the Gotchas entry below on why every ODF text getter in this codebase must call `decodeOdfText`, never `ooxml.js`'s own plain-text-node `textContent()`.
295
+ - **`src/odf-package/`** — the ODF-side counterpart to `src/opc/`: `manifest.ts` is a pure re-export of `odf.js`'s own manifest read/build/write/sync/validate functions (`odf.js` already owns `META-INF/manifest.xml` end to end — reading, deriving, writing, syncing, and validating it — unlike `ooxml.js`'s read-only OPC relationship handling), and `media.ts`'s `addImageMedia` inserts a binary image part under `Pictures/` (the real-world LibreOffice/OASIS convention, confirmed against `odf.js`'s own round-trip/manifest fixtures) and re-syncs the manifest via that same `syncManifest` re-export — one step simpler than OOXML's own `addImageMedia` (`src/opc/media.ts`) since ODF references a media part directly by its package path (`xlink:href`) rather than through a relationship-ID indirection. `OdpSlide.addImage`/`OdpShape` (`src/edit/odp/image.ts`) is `addImageMedia`'s real caller — and, through `src/edit/odg/*`'s wholesale reuse of `OdpShape` (see the `src/edit/` entry below), `OdgPage.addImage` too; `src/odb/read.ts` also reuses `manifest.ts`'s `readManifest` directly, to check `database/script`'s own manifest-declared media type before treating it as an HSQLDB script part.
286
296
  - **`src/edit/`** — the read-and-write editable model: live-view classes (`DocxEditor`/`DocxParagraph`/`DocxRun`/`DocxTable`, `PptxEditor`/`PptxSlide`/`PptxShape`, `OdtEditor`/`OdtParagraph`/`OdtRun`/`OdtTable`/`OdtList`, `OdpEditor`/`OdpSlide`/`OdpShape`, `OdsEditor`/`OdsSheet`/`OdsCell`, `OdgEditor`/`OdgPage`/`OdgBoxVector`/`OdgLineVector`/`OdgPathVector`) wrapping the actual `XmlElement` objects inside a decoded `Package`, plus `buildDocxPackage`/`buildPptxPackage`/`buildOdtPackage`/`buildOdpPackage`/`buildOdsPackage`/`buildOdgPackage` bridging a `ContentDocument` to a fresh package built entirely through those same primitives — `pdfToOdt`/`pdfToOdp`/`pdfToOds`/`pdfToOdg` each call the matching one. `src/edit/odp/*` reuses `src/edit/odt/*`'s own paragraph/run/list/style-interning classes WHOLESALE rather than reimplementing them for presentations: a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` content model `office:text` does, interned into the identical `content.xml` `office:automatic-styles` registry (`src/edit/odt/props.ts`'s `applyStyleChange`) — `OdpShape.appendParagraph`/`.paragraphs()`/`.addList()` return real `OdtParagraph`/`OdtList` instances, not odp-specific lookalikes. The genuinely new odp-specific work is `draw:page`/`draw:frame` mechanics (a slide is a `draw:page`, a shape's geometry is explicit `svg:x`/`svg:y`/`svg:width`/`svg:height` rather than pptx's placeholder-inheritance-heavy model) and rotation: `OdpShape.rotationDeg` is a genuine `draw:transform` setter built on `odf.js`'s own `applyOdfTransform`/`resolveOdfShapeGeometry` (`typed/shared/transform.ts`) — the write-side inverse of the exact function odf.js's own reader uses — unlike `PptxShape`, which has no rotation setter yet (see Gotchas below). `src/edit/ods/*` has no docx/pptx/odt/odp analogue to reuse for its core concern (cell addressing) but still reuses `src/edit/odt/*`'s style interning and `src/edit/odt/content.ts`'s `populateParagraph` for cell text content — `src/edit/ods/address.ts` is the write-side counterpart to `odf.js`'s own read-side `table:number-*-repeated`-aware cursor: setting a distant cell's value splits the covering repeated run in place at that one position rather than materialising every cell in between, exactly mirroring the read-side hazard `odf.js`'s own `typed/shared/a1.ts` already solved. `src/edit/ods/print-settings.ts` is the newest addition: `OdsSheet.printSettings`'s own getter/setter, mining `styles.xml`'s `office:automatic-styles`/`office:master-styles` directly (a part no other `src/edit/ods/*` module needed to touch before) rather than `content.xml` alone, reusing `odf.js`'s own exported `findStyleElement`/`resolvePageLayoutProperties`/`parsePageSize`/`parseMargins` for the read half and `src/edit/odt/automatic-styles.ts`'s `nextStyleName` (already generic over which `office:automatic-styles` element it scans) for the write half's own fresh-name minting. `src/edit/odg/*` reuses `OdpShape`/`buildTextBoxFrame`/`insertImageFrameMedia` WHOLESALE for `draw:frame` text/image content (a drawing page's `draw:frame` content model and geometry resolution — rotation included — are byte-for-byte identical to a presentation's, both resolved through `odf.js`'s own shared `readDrawFrame`), so there is no separate `OdgShape` class at all; the genuinely new work is the vector-primitive classes (no rotation, a per-kind attribute vocabulary: `svg:x`/`y`/`width`/`height` for rect/ellipse/path, `svg:x1`/`y1`/`x2`/`y2` for a line) and their own fill/stroke, which needed a small, self-contained graphic-family style writer (`src/edit/odg/style.ts`) since `odf.js`'s own `StyleRegistry` recognises `'graphic'` as a style family but its `StylePropertiesSchema` only ever models text/paragraph formatting — it has no fill/stroke fields and never emits a `style:graphic-properties` element. A path vector's own `svg:d` is generated by `src/edit/odg/svg-path.ts`, the write-side inverse of `odf.js`'s own `typed/shared/path.ts` parser — always absolute, always space-separated commands, anchoring `svg:viewBox` at `"0 0 {widthPt} {heightPt}"` so the written numbers are the exact source `ContentPathPoint` values with no rescaling arithmetic either way (see Gotchas below for the cross-check against that exact parser).
287
297
  - **`src/mathml/`** — a MathML presentation-layer typesetting engine, comparable in scope to the standard-14 text-layout half of `src/pdf/` — genuinely self-contained: no import from `model`, `pdf`, or `odf.js` at all (not even `document-content-model`), matching `src/layout/`'s own "pure conversion algorithm" isolation one tier further down. `nodes.ts` defines `MathMlNode`/`MathMlElement` as a local, structurally-compatible mirror of `odf.js`'s own `XmlNode` (the same "mirror the shape, don't import the package" trick `src/interop.test.ts` already proves holds between `ooxml.js` and `odf.js`), so `odf.js`'s `readOdfFormula`'s real return value type-checks against it with zero cast. `variant.ts` maps `mathvariant` to the Unicode Mathematical Alphanumeric Symbols block (Latin/Greek/digits, including the block's own well-known Letterlike-Symbols hole-fillers — italic small h, eleven Script/Fraktur/Double-struck capitals — generated directly from Unicode's own `UnicodeData.txt`, not transcribed by hand). `operators.ts` is a deliberately bounded operator dictionary (lspace/rspace/stretchy/largeop/movablelimits per operator), not the MathML3 spec's own multi-thousand-entry table. `layout.ts` is the recursive box-model engine itself (`mrow`/`mi`/`mn`/`mo`/`mtext`/`mspace`/`msub`/`msup`/`msubsup`/`munder`/`mover`/`munderover`/`mfrac`/`msqrt`/`mroot`/`mtable`/`mtr`/`mtd`/`mstyle`/`semantics`, plus a text-content fallback with a diagnostic for anything else), driven entirely by the injected `MathFontMetrics` port (`metrics.ts`) rather than any font-parsing code of its own — `src/pdf/math-font.ts` is the real implementation. `compose.ts`/`radical.ts`/`length.ts` are its own small geometry helpers (baseline-offset box placement, a hand-drawn hooked radical sign built from line segments rather than a bare glyph substitute, MathML length-unit parsing). Output is a flat `MathBox` (positioned glyph runs, rules, and strokes, box-local top-left/y-down coordinates) — `src/pdf/math-content-write.ts` is the one consumer that turns it into PDF content-stream bytes.
288
298
  - **`src/pdf/`** — the hand-written PDF codec, importing `model`/`bytes`/`image`/`mathml` (no OOXML knowledge at all):
@@ -292,12 +302,13 @@ The package is layered from generic primitives outward to the two conversion dir
292
302
  - `codec.ts` — `pdfCodec`, a `z.codec()` pair over `readPdf`/`writePdf` (PDF bytes ⇄ `LayoutDocument`).
293
303
  - **`src/ooxml/`** — resolves a `Package` into a `ContentDocument`: `docx/read.ts` and `pptx/read.ts` are now thin adapters over `ooxml.js`'s own `readDocx`/`readPptx`, wrapping their `{ metadata, sections }`/`{ metadata, slides }` result into `ContentDocument`'s `wordprocessing`/`presentation` shape. The docx style cascade (`docDefaults` → named-style `basedOn` chains → paragraph-mark run properties → character styles → direct formatting), the pptx placeholder → layout → master → theme inheritance cascade, and DrawingML geometry/colour resolution all now live upstream in `ooxml.js` itself, not in this package.
294
304
  - **`src/odf/`** — the ODF-side counterpart to `src/ooxml/`, resolving an `odf.js` `Package` into a `ContentDocument`: `odt/read.ts`'s `readOdtContent` is a thin adapter over `odf.js`'s own `readOdt`, wrapping its `{ metadata, sections }` result into the identical `wordprocessing` shape `readDocxContent` produces — the concrete proof that odt and docx genuinely share one pivot and one layout engine. `odp/read.ts`'s `readOdpContent` is the same adapter over `odf.js`'s own `readOdp`, wrapping `{ metadata, slides }` into the identical `presentation` shape `readPptxContent` produces. `ods/read.ts`'s `readOdsContent` wraps `odf.js`'s `readOds`'s `{ metadata, sheets }` into the `spreadsheet` `ContentDocument` variant, and `odg/read.ts`'s `readOdgContent` wraps `odf.js`'s `readOdg`'s `{ metadata, pages }` into the `drawing` variant — `odg` still has no OOXML-side sibling adapter at all (no drawing-equivalent OOXML format this package reads); `ods` now does, `ooxml.js`'s own `readXlsxContent`/`buildXlsxPackage`, consumed directly by `src/convert/convert.ts`'s `odsToXlsx`/`xlsxToOds` bridge (see below) but deliberately not re-exported from this package's own public surface, mirroring the `readDocx`/`readPptx` non-re-export choice above. `buildOdtPackage`/`buildOdpPackage`/`buildOdsPackage`/`buildOdgPackage` (`src/edit/{odt,odp,ods,odg}/content.ts`) each bridge a `ContentDocument` back to a fresh package built on that format's own live-view editor, closing the PDF → odt/odp/ods/odg direction (`pdfToOdt`/`pdfToOdp`/`pdfToOds`/`pdfToOdg` each call the matching one) — see the `pdfToOds` gotcha below for `buildOdsPackage`'s own printSettings-writing addition. `formula/read.ts`'s `readOdfFormulaContent`/`readOdfEmbeddedFormula` are the same thin-adapter pattern over `odf.js`'s own `readOdfFormula`, for a standalone `.odf` and an embedded sub-object respectively (the latter reading the sub-object's own `content.xml`/`meta.xml` directly out of the outer package's flat `Package.parts` record, no separate unzip step needed); `formula/detect.ts`'s `detectEmbeddedFormulaFrames` is genuinely new work with no `odf.js`-side equivalent at all — `odf.js`'s own `readDrawFrameContent` doesn't recognise a `draw:object`-bearing `draw:frame` yet, so `odt/read.ts` and `odp/read.ts` each run this as a second pass over the same package's raw `content.xml` to find and inject a formula's own placeholder block (see the Gotchas entry below for the exact scope and positioning caveats this second pass carries).
295
- - **`src/layout/`** — the pure conversion algorithms, importing `model` and (for formula placement) `mathml`: `engine.ts` (`ContentDocument` wordprocessing → `LayoutDocument`: flow, line-breaking, pagination — fed identically by docx- and odt-sourced content; also returns `WordprocessingLayoutResult.formulas`, every embedded formula block it laid out via `src/mathml`'s `layoutFormula`, positioned in PDF page space — see the Gotchas entry below on why a formula can't become an ordinary `LayoutItem`), `slides.ts` (`ContentDocument` presentation → `LayoutDocument`: direct EMU-to-point placement, no pagination needed — fed identically by pptx- and odp-sourced content; also exports `convertShape`, the single-`ContentShape`-to-`LayoutItem[]` conversion `drawing.ts` below reuses verbatim, now optionally formula-aware via its own trailing `formulaContext` parameter so `drawing.ts`'s existing call site keeps compiling unchanged), `sheets.ts` (`ContentDocument` spreadsheet → `LayoutDocument`: resolve the print range, build cumulative column/row offsets skipping hidden ones, reserve header/repeat-row-column space, resolve an explicit or non-iterative fit-to-page scale, partition into column/row bands honouring manual breaks with the same "an oversized item gets its own band and overflows rather than looping" guarantee `engine.ts`'s `ensureRoom` documents, emit pages in `downThenOver`/`overThenDown` order, then per page paint backgrounds/gridlines/headers/cell text with default alignment by value kind and `###`/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts an `AbortSignal`, since a 50k-cell sheet needs cancellation where a docx/pptx page count never did), `drawing.ts` (`ContentDocument` drawing → `LayoutDocument`: one `ContentDrawPage` per PDF page, direct placement like `slides.ts`, with one new emission path — a `ContentVector` `rect`/`ellipse`/`line` maps onto the pre-existing `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds, and a `path` vector's local, viewBox-relative subpath points are resolved through the vector's own frame offset then a single page-space flip into a `LayoutPath` value; vectors paint before shapes, a documented, bounded choice — see this module's own top-of-file note — since `ContentDrawPageSchema` keeps `shapes` and `vectors` as two independently paint-ordered arrays with no field recording their relative order when the two genuinely overlap), `reconstruct.ts` (`LayoutDocument` → `ContentDocument`: `reconstructWordprocessing`/`reconstructPresentation` do baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs; `reconstructDrawing` does no clustering at all, since a drawing has no such structure to infer in the first place — every `LayoutItem` maps close to 1:1 back onto a `ContentVector` `rect`/`ellipse`/`line`/`path` or a `ContentShape`, in the exact z-order it was painted, bucketed into `ContentDrawPageSchema`'s own two independently-ordered `shapes`/`vectors` arrays the same way `drawing.ts` produced them; `reconstructSpreadsheet` tries a real gridline lattice first — scanning the page's `LayoutLine`/stroked-single-segment-`LayoutPath` items for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid, using those line positions directly as cell boundaries when found — and falls back to text-position clustering otherwise, reusing this same module's `clusterIntoLines` for rows and a parallel recurring-x-position generalisation of `clusterIntoParagraphs`'s own `dominantLeftX` for columns; every recovered cell is a bare string, column widths/row heights are genuinely measured from whichever geometry was used, and no print range/scale/repeat-rows/repeat-columns/manual-breaks are ever inferred).
296
- - **`src/hsqldb/`** — `script.ts`, the Tier 1 `.odb` decoder: a small, bounded HSQLDB TEXT-script-format (`hsqldb.script_format=0`) DDL/DML text parser, not a database engine — `parseHsqldbScript(bytes)` extracts `CREATE TABLE`'s own column names/types and `INSERT INTO`'s own row values into `HsqldbTable[]`, tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwing `HsqldbScriptParseError` for anything matching neither list. Mirrors `src/pdf/`'s own isolation discipline: it imports only `document-content-model`'s `ContentCellValue` type, no odf.js `Package`/`XmlElement` knowledge at all — the caller is responsible for handing it raw bytes already extracted from a real `.odb` package.
297
- - **`src/odb/`** — the decoder-selection and pivot-mapping layer sitting between odf.js's `.odb` support and `src/hsqldb/`: `read.ts`'s `readOdbTables(pkg)` calls odf.js's own `readOdbInventory` to classify the package's connection (throwing `OdbNoEmbeddedDataSourceError` for an external-only datasource) and its embedded engine (throwing `OdbUnsupportedFormatError`, naming Firebird or HSQLDB's own binary/compressed script formats explicitly, for anything Tier 1 doesn't cover), then routes a genuine HSQLDB TEXT script to `parseHsqldbScript`. `spreadsheet.ts`'s `odbTablesToSpreadsheetDocument` maps `HsqldbTable[]` onto the same `ContentSheet`-based `ContentDocument` spreadsheet variant `readOdsContent`/`buildOdsPackage` already produce and consume, feeding `odbToXlsx`'s call into `buildXlsxPackage` directly. `csv.ts`'s `buildOdbTableCsv` writes exactly one named table as CSV bytes, with no `ContentSheet`/xlsx machinery involved at all, throwing `OdbTableNotSpecifiedError`/`OdbTableNotFoundError` (naming every available table) when the caller's own `table` option doesn't resolve to exactly one table.
305
+ - **`src/layout/`** — the pure conversion algorithms, importing `model`, (for formula placement) `mathml`, and — for line-wrapping/pagination itself — several genuinely PDF-sourced primitives: the injected `TextMeasurer` port and `wrapRunsToWidth` (`src/pdf/measure.ts`/`text-layout.ts`, since deciding where a line breaks needs to know how wide text renders in a PDF standard-14 font, regardless of which format the content came from), `loadMathFont` (`src/pdf/math-font.ts`, for formula placement), `src/pdf/matrix.ts`'s `rotatePointAboutCenter` (`slides.ts`'s own shape-rotation placement), and `src/pdf/afm-widths.ts`/`fonts.ts`'s `STANDARD_METRICS`/`resolveStandardFont` (`reconstruct.ts`'s own font-matching when reconstructing from a `LayoutDocument`) — the one dependency arrow in this package that runs opposite the general "generic primitives outward" ordering below, since text layout is inherently coupled to the one font model (`pdf`'s own standard-14 resolution) every conversion direction ultimately renders through: `engine.ts` (`ContentDocument` wordprocessing → `LayoutDocument`: flow, line-breaking, pagination — fed identically by docx- and odt-sourced content; also returns `WordprocessingLayoutResult.formulas`, every embedded formula block it laid out via `src/mathml`'s `layoutFormula`, positioned in PDF page space — see the Gotchas entry below on why a formula can't become an ordinary `LayoutItem`), `slides.ts` (`ContentDocument` presentation → `LayoutDocument`: direct EMU-to-point placement, no pagination needed — fed identically by pptx- and odp-sourced content; also exports `convertShape`, the single-`ContentShape`-to-`LayoutItem[]` conversion `drawing.ts` below reuses verbatim, now optionally formula-aware via its own trailing `formulaContext` parameter so `drawing.ts`'s existing call site keeps compiling unchanged), `sheets.ts` (`ContentDocument` spreadsheet → `LayoutDocument`: resolve the print range, build cumulative column/row offsets skipping hidden ones, reserve header/repeat-row-column space, resolve an explicit or non-iterative fit-to-page scale, partition into column/row bands honouring manual breaks with the same "an oversized item gets its own band and overflows rather than looping" guarantee `engine.ts`'s `ensureRoom` documents, emit pages in `downThenOver`/`overThenDown` order, then per page paint backgrounds/gridlines/headers/cell text with default alignment by value kind and `###`/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts an `AbortSignal`, since a 50k-cell sheet needs cancellation where a docx/pptx page count never did), `drawing.ts` (`ContentDocument` drawing → `LayoutDocument`: one `ContentDrawPage` per PDF page, direct placement like `slides.ts`, with one new emission path — a `ContentVector` `rect`/`ellipse`/`line` maps onto the pre-existing `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds, and a `path` vector's local, viewBox-relative subpath points are resolved through the vector's own frame offset then a single page-space flip into a `LayoutPath` value; vectors paint before shapes, a documented, bounded choice — see this module's own top-of-file note — since `ContentDrawPageSchema` keeps `shapes` and `vectors` as two independently paint-ordered arrays with no field recording their relative order when the two genuinely overlap), `reconstruct.ts` (`LayoutDocument` → `ContentDocument`: `reconstructWordprocessing`/`reconstructPresentation` do baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs; `reconstructDrawing` does no clustering at all, since a drawing has no such structure to infer in the first place — every `LayoutItem` maps close to 1:1 back onto a `ContentVector` `rect`/`ellipse`/`line`/`path` or a `ContentShape`, in the exact z-order it was painted, bucketed into `ContentDrawPageSchema`'s own two independently-ordered `shapes`/`vectors` arrays the same way `drawing.ts` produced them; `reconstructSpreadsheet` tries a real gridline lattice first — scanning the page's `LayoutLine`/stroked-single-segment-`LayoutPath` items for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid, using those line positions directly as cell boundaries when found — and falls back to text-position clustering otherwise, reusing this same module's `clusterIntoLines` for rows and a parallel recurring-x-position generalisation of `clusterIntoParagraphs`'s own `dominantLeftX` for columns; every recovered cell is a bare string, column widths/row heights are genuinely measured from whichever geometry was used, and no print range/scale/repeat-rows/repeat-columns/manual-breaks are ever inferred).
306
+ - **`src/hsqldb/`** — the `.odb` decoders, in two tiers over two genuinely different on-disk storage shapes a HSQLDB table can use. `script.ts` (Tier 1): a small, bounded HSQLDB TEXT-script-format (`hsqldb.script_format=0`) DDL/DML text parser, not a database engine — `parseHsqldbScript(bytes)` extracts `CREATE TABLE`'s own column names/types and `INSERT INTO`'s own row values into `HsqldbTable[]`, tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwing `HsqldbScriptParseError` for anything matching neither list. `rowformat.ts`/`cache.ts` (Tier 2): a CACHED table's own binary row-store format — LibreOffice's embedded-HSQLDB default (`database.isStoredFileAccess()` switches `hsqldb.default_table_type` to `cached` specifically for storage-backed access, confirmed against the decompiled engine source) — a CACHED table's DDL still lives in `database/script` as ordinary TEXT (Tier 1 parses it unmodified) but its row *data* lives in a separate binary page-cache file, `database/data`. `rowformat.ts` decodes one column's own binary field at a time (`HsqldbDataCursor`, a big-endian `DataView` cursor; `readHsqldbColumnValue`, one branch per SQL type code); `cache.ts` walks a table's own AVL row-position tree (`readHsqldbCachedTableRows`, following each row's persisted `iLeft`/`iRight` child positions recursively, needing no key-comparison or free-list logic at all — a deleted row is already unlinked from the tree before its space can be reused, so a traversal rooted at the tree's current root only ever reaches live rows), rooted at the position `parseHsqldbIndexRoots` recovers from each table's own `SET TABLE ... INDEX'...'` script line, using `parseHsqldbProperties`'s reading of `database/properties` (cache-file scale, engine version) to resolve byte offsets; `decodeHsqldbCachedTables` is the orchestration `src/odb/read.ts` calls, splicing real rows into every table with an index-root line and leaving every other table (MEMORY/TEXT, or a genuinely empty CACHED table — HSQLDB never writes an index-root line for one) exactly as Tier 1 already produced it. Both tiers mirror `src/pdf/`'s own isolation discipline: `script.ts` imports only `document-content-model`'s `ContentCellValue` type; `rowformat.ts` imports the same plus nothing else; `cache.ts` imports only those two and `script.ts`'s own types — no odf.js `Package`/`XmlElement` knowledge anywhere in `src/hsqldb/` — the caller is responsible for handing every function its raw bytes/text already extracted from a real `.odb` package. `HsqldbTable`/`HsqldbColumn` are also the shared pivot shape `src/firebird/`'s own Tier 3 decoder below produces. See Gotchas for Tier 2's own version scope and verification account.
307
+ - **`src/firebird/`** — the Tier 3 `.odb` decoder: a reader for Firebird's own gbak logical-backup format (`database/firebird.fbk`), the artifact a real Firebird-embedded `.odb` actually contains — see the README's own Gotchas entry below for the empirical finding that this is NOT a raw on-disk ODS page dump, the single largest correction this subsystem's own design went through. `reader.ts` holds the two distinct byte-level primitives the format mixes (`FirebirdBackupReader`, the generic little-endian tag+length+value attribute framing every `rec_*`/`att_*` record uses, plus its own RLE/"PackBits"-style decompression for `att_data_data` when the backup is compressed; `XdrReader`, the big-endian, 4-byte-aligned RFC 1832 XDR decoding a row's own field values use once compression is peeled off). `blr-types.ts` maps a field's own BLR type opcode (`att_field_type`) onto its physical storage representation, sourced directly from Firebird's own `blr.h`/`align.h`. `date.ts` restates Firebird's own MJD-epoch DATE and 1/10000-second-tick TIME encoding, taken from `NoThrowTimeStamp.cpp`. `schema.ts` walks `rec_relation`/`rec_field` (column definitions gbak has ALREADY resolved from the live engine's system tables at backup time — see the Gotchas entry). `data.ts` walks `rec_relation_data`/`rec_data` (a relation's own rows, addressed by name), decoding each row's XDR-and-possibly-RLE-compressed field-value sequence into `ContentCellValue[]`. `backup.ts`'s `readFirebirdBackup` is the top-level entry point, producing the identical `HsqldbTable[]` shape `parseHsqldbScript` does.
308
+ - **`src/odb/`** — the decoder-selection and pivot-mapping layer sitting between odf.js's `.odb` support and `src/hsqldb/`/`src/firebird/`: `read.ts`'s `readOdbTables(pkg)` calls odf.js's own `readOdbInventory` to classify the package's connection (throwing `OdbNoEmbeddedDataSourceError` for an external-only datasource) and its embedded engine, then routes a genuine HSQLDB TEXT script to `parseHsqldbScript`, then — whenever a `database/data` part is present — hands Tier 1's own result to `src/hsqldb/cache.ts`'s `decodeHsqldbCachedTables` to splice in every CACHED table's real rows (a `.odb` with no CACHED table at all, the common case, never even looks for `database/data`, leaving Tier 1's own result untouched), or routes a Firebird `database/firebird.fbk` part to `readFirebirdBackup` — throwing `OdbUnsupportedFormatError` (naming HSQLDB's own whole-script binary/compressed serialisation explicitly, the one embedded shape still unimplemented) for anything none of these cover. `spreadsheet.ts`'s `odbTablesToSpreadsheetDocument` maps `HsqldbTable[]` onto the same `ContentSheet`-based `ContentDocument` spreadsheet variant `readOdsContent`/`buildOdsPackage` already produce and consume, feeding `odbToXlsx`'s call into `buildXlsxPackage` directly. `csv.ts`'s `buildOdbTableCsv` writes exactly one named table as CSV bytes, with no `ContentSheet`/xlsx machinery involved at all, throwing `OdbTableNotSpecifiedError`/`OdbTableNotFoundError` (naming every available table) when the caller's own `table` option doesn't resolve to exactly one table.
298
309
  - **`src/convert/`** — `convert.ts` (the twelve PDF-pivot round-trip ergonomic wrappers, a dedicated "Six cross-format bridges" section: `odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, each a direct `readXContent` → `buildYPackage` composition bypassing PDF entirely — see [Fidelity](#fidelity) — `odmToPdf`, the one further conversion shaped around a caller-supplied `resolveSubDocument` callback rather than being purely bytes-in/bytes-out, since a `.odm` master document's own chapters are external references odf.js's `readOdm` never inlines — see Gotchas — `odbToXlsx`/`odbToCsv`, thin compositions over `readOdbTables` and `src/odb/`'s own pivot/CSV mapping, and `odfToPdf`, a standalone `.odf` formula document → PDF via `readOdfFormulaContent` → `src/mathml`'s `layoutFormula` → `writePdf`'s own formula-aware option, with no reverse `pdfToOdf` at all), `codec.ts` (`docxPdfCodec`/`pptxPdfCodec`/`odtPdfCodec`/`odpPdfCodec`/`odsPdfCodec`/`odgPdfCodec` plus `odtDocxCodec`/`odpPptxCodec`/`odsXlsxCodec`, a `z.codec()` pair over each — `odmToPdf`/`odbToXlsx`/`odbToCsv`/`odfToPdf` have no codec of their own, for the same fixed-signature/one-directional reasons each has no port entry, or a one-way port entry, below), `port.ts`/`local.ts` (the swappable `DocumentConverter` contract and its synchronous local implementation, covering `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`odf` → `pdf`, `pdf` → `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`, and the six bridge pairs — `DocumentFormat` includes `xlsx` for exactly this reason, even though xlsx has no PDF conversion of its own; `odm` and `odb` are deliberately not `DocumentFormat` members, since neither `odmToPdf` nor `odbToXlsx`/`odbToCsv` is wired into this port at all; `odf` IS a member, but with only the one `odf → pdf` entry — no `pdf → odf`). Every conversion function that builds a `ContentDocument`/`LayoutDocument` internally (the twelve PDF-pivot conversions and the six bridges; `odfToPdf` accepts but never invokes it) also accepts an `onDocument` callback, and `ConversionResult` carries the same value through the port as an optional `package` field — the full `DocumentPackage` (content + layout, from `document-content-model`) that conversion built, not just its target bytes.
299
310
 
300
- Dependency direction is strictly downward and checkable: `model`/`bytes`/`mathml` import nothing local (`mathml` is fully self-contained — no dependency on `model`, `document-content-model`, or any ODF/PDF package, since it consumes only its own locally-mirrored `MathMlNode` input and its own injected `MathFontMetrics` port); `image` imports `bytes` only; `pdf` imports `model`+`bytes`+`image`+`mathml`; `ooxml/*` imports `xml`/`model` only (no PDF knowledge); `odf/*` imports `model` only (no PDF knowledge, no `xml/*` — `odf.js` already owns its own XML query helpers); `hsqldb` imports `document-content-model` only (no odf.js knowledge); `layout` imports `model`+`mathml`; `odb` imports `hsqldb`+`model`+odf.js only; `convert` composes everything else. No `PdfObject`/`PdfDict`/`PdfStream` type appears outside `src/pdf/`.
311
+ Dependency direction is downward and checkable, with one deliberate exception (`layout`, noted below): `bytes`/`mathml`/`ports` import nothing local (`mathml` is fully self-contained — no dependency on `model`, `document-content-model`, or any ODF/PDF package, since it consumes only its own locally-mirrored `MathMlNode` input and its own injected `MathFontMetrics` port); `model` imports nothing local at the value level, but `formula.ts` carries one type-only import of `MathBox`/`MathMlNode` from `mathml` (erased entirely at runtime, and not a cycle — `mathml` itself imports nothing from `model`); `image` imports `bytes` only; `pdf` imports `model`+`bytes`+`image`+`mathml`+`ports`; `ooxml/*` imports `model` only (now a thin adapter over `ooxml.js`'s own `readDocx`/`readPptx` — see the `src/ooxml/` entry above — with no `xml/*` dependency of its own left; no PDF knowledge either); `odf/*` imports `model` only (no PDF knowledge, no `xml/*` — `odf.js` already owns its own XML query helpers); `hsqldb` imports `document-content-model` only (no odf.js knowledge); `firebird` imports `document-content-model` (its own row/schema decoding, `ContentCellValue` only) and `hsqldb` (`HsqldbTable`/`HsqldbColumn`, a type-only import for its own output shape — the deliberate pivot-sharing point between Tier 1 and Tier 3) but no odf.js knowledge at all; `layout` imports `model`+`mathml`+`ports`, plus, genuinely upward, several text-measurement/font-metric/matrix primitives from `pdf` itself (`measure.ts`/`text-layout.ts`/`math-font.ts`/`matrix.ts`/`afm-widths.ts`/`fonts.ts` — see the `src/layout/` entry above for exactly which); `odf-package` imports odf.js only (no local dependency, mirroring `opc`'s relationship to `ooxml.js`); `odb` imports `hsqldb`+`firebird`+`model`+`odf-package`+odf.js only; `convert` composes everything else. No `PdfObject`/`PdfDict`/`PdfStream` type appears outside `src/pdf/`.
301
312
 
302
313
  ## Build, test, and lint
303
314
 
@@ -326,6 +337,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
326
337
  ## Gotchas and quirks
327
338
 
328
339
  - **`ooxml.js`'s typed readers (`readDocx`/`readPptx`) are now the actual basis for conversion** — `readDocxContent`/`readPptxContent` are thin wrappers around them, not an independent walk of `word/document.xml`/`ppt/slides/slideN.xml`. They are still deliberately not re-exported from this package's own public surface: `readDocx`/`readPptx` also carry `comments`/`footnotes`/`headers`/`footers` (docx) that `ContentDocument` doesn't model, so exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models.
340
+ - **ODF paragraph/heading text content is not a plain string the way a docx run's `w:t` is, and reading it wrong fails silently.** Real whitespace collapses HTML-style when an ODF consumer renders XML text-node content, so the format represents a run of two or more literal spaces as `<text:s text:c="N"/>` (an ELEMENT, not a text node), a tab as `<text:tab/>`, and a hard line break as `<text:line-break/>` — all three occupy real character positions in a paragraph's flat content model but carry no text-node value at all. Every ODF text getter in this codebase's editor layer (`src/edit/odt/*`, `src/edit/odp/*`, `src/edit/ods/*`) MUST call `decodeOdfText` (`src/xml/odf-text.ts`) — never `ooxml.js`'s own `textContent()`, a plain text-node concatenation with no idea `text:s`/`text:tab`/`text:line-break` exist. `textContent()` would silently DROP every one of them: the file still parses as valid XML, so this produces no error and no warning, just silently shorter text. `decodeOdfText` delegates the real work entirely to `odf.js`'s own `decodeOdfText` (wrapped in a synthetic container element, since `odf.js`'s version operates on a whole `XmlElement`'s children rather than a bare node array); the encode direction, `encodeOdfText` (plain string → the same element sequence, coalescing adjacent literal characters into as few text nodes as practical), is local to this package, since `odf.js` is a read-and-manifest package with no write-side text builder of its own.
329
341
  - **The docx⇄PDF and pptx⇄PDF conversions are explicitly not round-trip-lossless** — in deliberate contrast to `ooxml.js`'s own `packageCodec`, which is byte/part-faithful by design. See [Fidelity](#fidelity). The six cross-format bridges below (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`) are a genuinely different case — see the [Fidelity](#fidelity) section's own paragraph on them.
330
342
  - **A `DocumentPackage` returned via `onDocument`/`ConversionResult.package` is a snapshot from that one conversion pass, not a live view** — its `layout` correlates with its `content` only as of the exact read+layout that produced it (`document-content-model`'s own `DocumentPackageSchema` doc comment), so if a caller mutates the returned `content` afterwards, the `layout` sitting alongside it silently goes stale; nothing in this package (or `document-content-model`) detects or rejects that.
331
343
  - **Building the six cross-format bridges surfaced two real, previously-undiscovered gaps in existing `populateParagraph` write paths, both now fixed.** `buildDocxPackage`'s `populateParagraph` (`src/edit/docx/content.ts`) never wrote a paragraph's own `list` membership back (`ContentParagraph.list`, docx's flat `numId`/`level` model) — only read, never written, since no existing caller had ever round-tripped a list-bearing paragraph through it. `buildOdtPackage`'s `populateParagraph` (`src/edit/odt/content.ts`) never wrote a paragraph's own `styleId` back at all (`readOdtContent`/`readOdfParagraph` in `odf.js` reads it unconditionally from `text:style-name`, but nothing on the write side ever set that attribute). Both are now fixed: `DocxParagraph.list` is set unconditionally alongside `styleId`/`alignment`, matching that function's own existing pattern; `OdtParagraph.styleId` is set conditionally alongside `alignment`, matching odt's own local convention. `buildOdtPackage` additionally gained `appendBlocks`/`appendListRun` (`src/edit/odt/content.ts`) — ODF has no flat per-paragraph list property to set the way docx does, so a run of consecutive `ContentParagraph`s sharing `list.numId` is grouped and written as a real, potentially multi-level `text:list`/`text:list-item` tree via `OdtList`/`OdtListItem`, the structural inverse of `odf.js`'s own list-reading (a fresh `text:list` per `numId` change, one level of nesting per `list.level` step, descending only one level at a time since ODF can only open a nested list from inside an existing item). Both gaps were invisible before this task specifically because nothing had previously round-tripped a list-bearing paragraph or a styled paragraph through `docx ⇄ odt` at all — the PDF-pivot conversions never exercised `buildDocxPackage`/`buildOdtPackage` on content read back from the OTHER format.
@@ -347,7 +359,8 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
347
359
  - **A vector primitive's own rotation is never read at all.** None of `ContentVectorSchema`'s variants carry a rotation field, unlike `ContentShapeSchema` — `readOdgContent`'s underlying `odf.js` reader deliberately discards a `draw:rect`/`draw:ellipse`/`draw:custom-shape`'s own rotation, so it reads (and `convertDrawingToLayout` places) at its unrotated bounding frame. A real, tracked model limitation inherited from `odf.js`, not something this package's own layout code introduces.
348
360
  - **`ContentVector`'s `path` variant's `fillRule` is never populated by the reader — always `undefined`, which `writePath` treats as nonzero.** `odf.js`'s `readDrawPathVector` does not currently resolve an evenodd fill rule from real ODF output, so every path this pipeline reads paints with PDF's default nonzero winding rule. `LayoutPathSchema`/`writePath` fully support `fillRule: 'evenodd'` regardless — a caller constructing a `LayoutPath` (or a future `ContentVector` producer) directly can still set it; it just never arrives via `odgToPdf` today.
349
361
  - **`ContentSheetCellSchema` (`document-content-model`) models no per-cell border or background, and no per-cell alignment override** — unlike `ContentTableCellSchema.background`. `sheets.ts`'s cell-background and cell-border z-order steps are consequently skipped entirely (no dead placeholder code), and cell text alignment always falls back to the value-kind default (numeric right, boolean/error centre, string left) since there is nothing to override it with. A tracked, documented gap, not a silent one.
350
- - **PDF output uses the standard 14 fonts only — no font embedding.** Helvetica/Times-Roman are genuinely metric-compatible substitutes for Arial/Times New Roman, but Word's actual current defaults (Calibri, Aptos) are not, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation, not a line-identical reproduction.
362
+ - **Ordinary text in PDF output uses the standard 14 fonts only — no font embedding.** Helvetica/Times-Roman are genuinely metric-compatible substitutes for Arial/Times New Roman, but Word's actual current defaults (Calibri, Aptos) are not, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation, not a line-identical reproduction. The one exception is MathML formula rendering (`odfToPdf`, and formulas embedded inside odt/odp): those genuinely embed the real STIX Two Math font — see the CFF-embedding gotcha below for the exact scope of that embedding (the whole `CFF ` table, not glyph-subsetted).
363
+ - **Justified paragraphs render left-aligned, not justified.** `Alignment` (`document-content-model`) has a real `'justify'` member, and it survives reading a docx/odt paragraph's own alignment correctly, but `src/layout/shared.ts`'s `alignmentOffsetPt` — the one function every layout engine (`engine.ts`/`slides.ts`/`sheets.ts`) consults to position a line — has no branch for it at all, falling through to the same `0` offset `'left'` gets; there is no inter-word spacing stretch anywhere in this package's line-wrapping code either. A documented narrowing, not a silent approximation: `alignmentOffsetPt`'s own comment states "not implemented for v1" explicitly. Every other alignment value (`center`/`right`) works correctly.
351
364
  - **Reading arbitrary real-world PDFs is the single largest risk surface in this package**, and the parser is honest about its design target: cleanly-generated output from mainstream producers (Word, PowerPoint, Chrome, LibreOffice, Acrobat), recovering from the malformations those producers and their downstream tooling actually create, and failing loudly and specifically on anything else — not matching a mature library's robustness against adversarial input.
352
365
  - **Encrypted PDFs are unsupported.** `/Encrypt` present in the trailer throws `PdfEncryptedError`, even for the common empty-user-password case.
353
366
  - **`CCITTFaxDecode`/`JBIG2Decode`/`JPXDecode` PDF images are unsupported** (scanned-fax and JPEG2000 formats) — the image is skipped with a diagnostic, the rest of the page still reads. JPEG images (`DCTDecode`) pass through completely losslessly in both directions; PNG-sourced images go through a real, narrowly-scoped hand-written codec.
@@ -357,7 +370,17 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
357
370
  - **pptx speaker notes survive `pptxToPdf`/`pdfToPptx`, but not through any real PDF feature.** PDF has no native concept of hidden presenter notes, so `convertPresentationToLayout` carries `ContentSlide.notes` as a hidden `/Subtype /Text` annotation on the page (the same construct Acrobat's own sticky-note tool uses, marked with the `Hidden` annotation flag so it never renders or prints), and `reconstructPresentation` reads it back via a `/T` marker that distinguishes this package's own notes annotation from a genuine third-party sticky note. This is a round-trip mechanism specific to this package's own writer/reader pair — a PDF produced by anything else will never carry it, and a PDF consumer other than this package's own `readPdf` will never see it as anything but an invisible, empty sticky note.
358
371
  - **`odmToPdf` is the one conversion in this package that is not purely bytes-in/bytes-out.** A `.odm` (ODF master document) never carries its own chapters' content — each `text:section` is a bare external reference (`text:section-source`'s `xlink:href` + `text:filter-name`) to a standalone `.odt` file, confirmed against real, unmodified LibreOffice 26.2 output while building `odf.js`'s own `readOdm`: a self-closing `text:section-source` with no `xlink:show`/`xlink:type`, no manifest entry for the linked part, and no chapter text anywhere in the master document's own `content.xml`. There is consequently no way for `odmToPdf` to read a chapter's content from the `.odm` bytes alone — it takes an `options.resolveSubDocument` callback, called once per section with that section's own `href`, to hand back the chapter's own `.odt` bytes. Every section left unresolved (no callback given, or the callback returns `undefined` for that `href`) is collected across the *whole* document before anything throws, and reported together in one `OdmUnresolvedSectionError` naming every unresolved `href` — not just whichever section the read loop happened to reach first. `odmToPdf` is consequently not one of the twelve round-trip conversions or six bridges above, and is deliberately not wired into the `DocumentConverter` port either: that port's `convert(request, options)` contract is a fixed single-bytes-in/bytes-out shape, and widening it with a resolver parameter for this one format would leak an odm-specific concern into every other conversion's own request shape — a caller wanting `odmToPdf` behind the port can wrap it in their own adapter. `OdmSection.inlineContent` (declared by `odf.js`'s own `readOdm` for schema-completeness, covering a producer that caches a chapter's content inline rather than only linking it) is handled too, via the same `readOdfParagraph`/`readOdfTable` primitives `odf.js`'s own `readOdt` calls internally — but the installed `odf.js` 1.10.0 never actually populates it for any real document `readOdm` was tested against, so this branch is exercised only by a directly-constructed `OdmSection` in this package's own test suite, not by any `.odm` fixture.
359
372
  - **`.odb` never gets PDF conversion, and never will.** A `.odb`'s own Reports are live SQL-backed layouts — rendering one faithfully means actually executing its query against a real database engine, categorically out of scope for a hand-written codec that never runs SQL. `readOdbTables`/`odbToXlsx`/`odbToCsv` extract table *data*, not the database's own forms/reports/queries (whose *names* `odf.js`'s `readOdbInventory` surfaces, but never their content — see `odf.js`'s own implementation notes).
360
- - **Only HSQLDB's TEXT script format (`hsqldb.script_format=0`) is implemented.** `readOdbTables` detects and *names* two further shapes it does not implement, rather than silently returning no tables: HSQLDB's own BINARY (`hsqldb.script_format=1`) and COMPRESSED (`hsqldb.script_format=3`, gzip) script formats, and a Firebird-backed embedded `.odb` (`database/firebird.fbk`-style opaque storage, LibreOffice's other bundled embedded engine) — each throws `OdbUnsupportedFormatError` with a `format` field naming exactly which. An external-only connection (no embedded engine at all — MySQL/PostgreSQL/JDBC/ODBC) is a third, *permanent* scope boundary, not a missing tier: `readOdbTables` throws `OdbNoEmbeddedDataSourceError` rather than attempting anything network-facing.
373
+ - **All three `.odb` decoder tiers are implemented: HSQLDB TEXT-script rows (MEMORY/TEXT tables, Tier 1), HSQLDB's own binary CACHED-table row-store rows (Tier 2), and a Firebird-backed embedded database's own gbak logical-backup format (Tier 3, see the dedicated Tier 3 entries below).** `readOdbTables` still detects and *names* one further shape it does not implement, rather than silently returning wrong or empty tables: HSQLDB's own whole-script BINARY (`hsqldb.script_format=1`) and COMPRESSED (`hsqldb.script_format=3`) serialisation each throws `OdbUnsupportedFormatError` with a `format` field naming exactly which. BINARY and COMPRESSED are a single, materially different, larger undertaking from CACHED-table row-store decoding, not a close sibling of it: both are the *entire script's own DDL/DML statements* re-encoded as a length-prefixed, `COMMAND`-tagged binary stream (confirmed by generating a real `hsqldb.script_format=1` file and reading its own `COMMAND`/`SYSTEM_SCRIPT` tokens directly), and `hsqldb.script_format=3` is that identical binary stream wrapped in ordinary zlib `DEFLATE` (RFC 1950, confirmed against a real generated file's own `0x78 0x9c` header) — genuinely not a compressed *TEXT* script, despite an earlier, unverified assumption to the contrary; `readOdbTables`' own `classifyScriptBytes` detects the real zlib header rather than gzip's, which a real HSQLDB-produced COMPRESSED file never carries. An external-only connection (no embedded engine at all — MySQL/PostgreSQL/JDBC/ODBC) is a second, *permanent* scope boundary, not a missing tier: `readOdbTables` throws `OdbNoEmbeddedDataSourceError` rather than attempting anything network-facing.
374
+ - **The CACHED-table row-store decoder (Tier 2, `src/hsqldb/cache.ts`/`rowformat.ts`) is scoped to the specific HSQLDB 1.8.x-branch on-disk layout LibreOffice's embedded driver actually ships, not "any HSQLDB version ever" — the same bounding principle the PDF codec applies to "mainstream producer output" rather than every PDF ever created.** There is no ISO/ratified specification for this binary format at all (unlike ODF or OOXML); ground truth is the actual HSQLDB 1.8.0.10 engine source, decompiled from the real `hsqldb.jar` LibreOffice 26.2 bundles (`Specification-Version: 1.8.0.10` in that jar's own `META-INF/MANIFEST.MF` — the exact engine version LibreOffice's embedded HSQLDB JDBC driver loads), cross-checked against a real database that exact jar produced: created, populated, and checkpointed via `java.sql` directly against the bundled jar, then read back — as this decoder's own ground-truth oracle — by a second, independent Java program using the identical jar. Every field of every row of all four CACHED tables in the checked-in fixture (`src/test-support/odb.ts`'s `embeddedHsqldbCachedOdbBytes`) matched that oracle exactly; `parseHsqldbProperties` throws for a `hsqldb.compatible_version` outside the `1.7.x`/`1.8.x` family rather than guessing at an unverified layout. A genuine attempt was also made to cross-check the same fixture against actual LibreOffice itself via a headless UNO Basic macro driving its own SDBC API — this task's own strictest verification bar — but headless `soffice` macro invocation hung indefinitely in this sandbox regardless of profile isolation, macro-security configuration, or a five-minute timeout budget, independently corroborated by a concurrent, unrelated agent's own headless-LibreOffice attempt stalling identically in the same session; the JDBC oracle above is a materially stronger substitute than a fallback of convenience, though, since LibreOffice's own SDBC-to-HSQLDB path is itself a thin wrapper around calling this exact same bundled jar's own JDBC driver methods.
375
+ - **The CACHED-table decoder supports only a single-index table (the primary key, or HSQLDB's own internal row-position index for a table with none declared) — a table also carrying an explicit `CREATE INDEX` or a `UNIQUE` constraint's own auto-generated index throws rather than mis-decoding.** `org.hsqldb.Table.getIndexRoots()`'s own `SET TABLE ... INDEX'...'` script line carries one root row-position token per table index, but a `UNIQUE` constraint's own index has no matching `CREATE INDEX` DDL statement at all (its name is a `Table.getIndexCount()`-only trait), so this decoder's `indexCount` cannot be reliably recovered from the script's own DDL text alone; `parseHsqldbIndexRoots` throws rather than guessing which root token belongs to which index. The row-store's own AVL tree is walked purely by following each row's persisted child *positions*, never by comparing key values, so this decoder never needed HSQLDB's own free-block list at all: a deleted row is unlinked from its table's tree before its space is ever added to that list, so a traversal rooted at the tree's current root only ever reaches genuinely live rows.
376
+ - **DATE/TIME/TIMESTAMP columns decoded from a CACHED table's binary row store are only correct when read in the same timezone the database was written in — a genuine, inherent limitation of HSQLDB 1.8's own on-disk format, not an implementation shortcut.** `org.hsqldb.HsqlDateTime` resolves every date/time value through a `java.util.Calendar` carrying no explicit `TimeZone` (i.e. the writing JVM's own default), and the row store's own encoding is a bare epoch-millisecond `long` with no timezone or offset recorded anywhere alongside it — confirmed empirically: the checked-in fixture's own `DATE` values straddle both GMT and BST, and decoding via UTC (rather than local-timezone) `Date` methods recovers the *wrong calendar day* for every summer date. `src/hsqldb/rowformat.ts` reconstructs every DATE/TIME/TIMESTAMP value using the *reading* process's own local timezone — correct whenever a `.odb` is read on the same machine/region that created it, the overwhelmingly common case, but not guaranteed for one moved to a host in a materially different timezone. `src/hsqldb/cache.test.ts` pins `process.env.TZ` to `'Europe/London'` for exactly this reason, matching the fixture's own real generation environment.
377
+ - **A BIGINT value decoded from a CACHED table loses precision beyond `Number.MAX_SAFE_INTEGER`, the same class of limitation every `'number'`-kind `ContentCellValue` in this package already has (DECIMAL/NUMERIC included).** `ContentCellValue` has no arbitrary-precision integer kind to offer instead; `readHsqldbColumnValue` converts a decoded BIGINT through a `bigint` and only casts to a JS `number` at the very end via `Number()`, matching how `src/hsqldb/script.ts`'s own Tier 1 numeric-literal parsing already stores every SQL numeric value as a plain JS number. A real, documented format-boundary gap, not a silent guess: the checked-in fixture's own BIGINT test values are deliberately kept within the safe range so the test suite demonstrates clean, exact round-tripping rather than exercising this already-understood, orthogonal ceiling.
378
+ - **`.odb` Tier 3 (Firebird) is the single subsystem in this whole package with no ratified spec foundation at all — not ISO 32000-1 (PDF), not the OASIS ODF 1.3 RelaxNG schema, nothing.** Firebird's own on-disk page format (ODS) has no public specification; the only ground truth is Firebird's own open-source engine implementation (the [firebirdsql/firebird](https://github.com/FirebirdSQL/firebird) repository) and real fixtures generated and cross-verified by hand. Building this reader surfaced a genuine, load-bearing correction to the design plan it was built against, discovered only by extracting and hex-inspecting a real LibreOffice-generated fixture: **a Firebird-embedded `.odb`'s own `database/firebird.fbk` part is a gbak logical BACKUP stream, not a raw ODS page dump.** LibreOffice's embedded-Firebird SDBC driver backs up the live database (via the identical mechanism the standalone `gbak` command-line tool uses) into the `.odb` package on save, and restores it into a throwaway temp `.fdb` file only when a document is actually opened for live editing — confirmed directly from the backup stream's own embedded temp-file path attribute (`att_backup_file`), which names a `.../lu*.tmp/firebird.fdb` path under LibreOffice's own temp directory, never the `.odb`'s own location. This means the page-level reader (header page, Page Inventory Page, Pointer Page → Data Page chains, RLE-style record compression, MVCC back-pointer chains) the original design plan called for has **no real file to ever operate on** — no `.odb` this reader was tested against, or could plausibly be tested against, ever contains one. `src/firebird/` is consequently a gbak-backup-format reader instead, built against the exact same "no ratified spec, read the engine's own source, verify against real fixtures" discipline, just aimed at a different (and, as it turns out, more tractable) real artifact: `src/burp/burp.h`/`backup.epp`/`restore.epp`/`canonical.cpp`/`mvol.cpp` and `src/common/xdr.cpp`/`src/common/classes/NoThrowTimeStamp.cpp` in the Firebird engine repository, cross-checked line-for-line against real fixture bytes throughout construction (several real off-by-one attribute-index errors and one real high/low-word ordering bug in the initial pass were caught exactly this way, not by inspection alone). One genuine, welcome simplification falls out of this finding for free: because gbak's own backup process ALREADY resolves table/column definitions from the live engine's `RDB$RELATIONS`/`RDB$RELATION_FIELDS`/`RDB$FIELDS` system tables before writing anything, `src/firebird/schema.ts` never bootstraps those system tables itself — a real `rec_relation`/`rec_field` record pair, already fully resolved, is simply *there* in the stream for every user table.
379
+ - **The exact Firebird gbak backup format version this reader targets, and how that was determined: format version 10, per a real fixture's own `att_backup_format` attribute — burp.h's own version-history comment identifies format 10 as "FB2.5 → FB3.0" output.** `readFirebirdBackup` checks this explicitly and throws `FirebirdBackupFormatError` naming the actual version found for anything else, rather than guessing at a different version's own attribute/record shape. Two real, LibreOffice 26.2-generated fixtures (`src/test-support/firebird.ts`) both report this same format version and both set `att_backup_compress=true` (gbak's own default, not something either fixture-generation session opted into) — a genuine surprise this reader's own construction caught only by testing against real bytes: the naive assumption that a `.odb`'s own embedded backup would be uncompressed was wrong on the very first real file tested, and `src/firebird/reader.ts`'s `readCompressedPayload` (a signed-run-length/"PackBits"-style codec, restated from `backup.epp`'s own `compress`/`restore.epp`'s own `decompress`) exists specifically because of that correction.
380
+ - **Two genuine real fixtures back this reader's own tests, both generated via a headless LibreOffice 26.2 UNO automation session and never hand-edited afterward** (`src/test-support/firebird.ts` documents each in full): a richer one built in this task's own session (two tables, varied column types — `INTEGER`/`VARCHAR`/`DOUBLE PRECISION`/`DATE`/`BOOLEAN`/`DECIMAL`/`NUMERIC` — four and three rows respectively, including deliberate `NULL`s in every nullable column, an apostrophe-escaped string, and a zero value distinct from `NULL`), and the `ExaDev/odf.js` repository's own pre-existing fixture (two empty tables, no row data, a second independently-generated real data point proving the schema-only path). The richer fixture's own construction surfaced a genuine UNO API ordering requirement, not obvious from the API surface alone: `getConnection()` on a freshly `createInstance()`'d `DatabaseContext` entry fails with `SQLException: No storage or URL was given` unless `.DatabaseDocument.storeAsURL()` is called FIRST to give the embedded engine real backing storage to connect to — setting `.URL` alone is not enough.
381
+ - **Cross-verified field-by-field against real LibreOffice itself, not merely against this reader's own output.** A second headless UNO macro (`VerifyFirebirdFixture` — see `src/test-support/firebird.ts`'s own doc comment) reopens the saved fixture completely fresh from disk (a genuine new document load, not the same in-memory session that created it), reconnects via `getConnection`, and runs a real `SELECT * FROM <table> ORDER BY <pk>` through LibreOffice's own SDBC API — every value it returned matched this reader's own decoded output exactly, row for row, field for field, across both tables.
382
+ - **Headless LibreOffice command-line macro dispatch (`soffice {file} {macro:///Library.Module.Name}`) needed two real, non-obvious environment fixes to run at all in this sandbox, beyond the ones already documented for HSQLDB/odm fixture generation.** (1) A prior session's forcefully-killed `soffice` process leaves macOS's own native "reopen windows after a crash" alert showing on every subsequent launch — invisible in headless/`--invisible` mode (no window to click), so `soffice` hangs indefinitely in `-[NSAlert runModal]` waiting for a response that can never arrive; `defaults write org.libreoffice.script ApplePersistenceIgnoreState -bool true` (plus removing `~/Library/Saved Application State/org.libreoffice.script.savedState`) disables it. (2) `soffice "macro:///Library.Module.Name"` with no document argument silently does nothing at all — per `soffice --help`'s own usage text, the `{file}` argument is not optional (`{file} {macro:///Library.Module.MacroName}`); a session invoking a macro with no real work to do on a document still needs a real (even trivial) file argument for the macro to actually dispatch.
383
+ - **A Firebird gbak backup stream's own wire format mixes two genuinely different byte-level encodings, confirmed only by testing against real bytes, not solely from reading the engine's source.** Every `rec_*`/`att_*` tag-and-attribute structure is little-endian ("VAX order", `isc_vax_integer`), one length-prefix byte per value; a row's own field-value sequence (once any RLE compression is peeled off) is standard RFC 1832 XDR — big-endian, every value (even a nominally 16-bit `SSHORT`) widened to a 4-byte-aligned unit, opaque byte runs zero-padded to the next 4-byte boundary. A genuine 64-bit-integer word-order bug (high 32 bits transmitted first, not low-first as `xdr_hyper`'s own in-memory `temp_long` array layout suggests on first reading) was caught exactly this way: a `DECIMAL(10,2)` column decoded to a nonsense value on the first real-fixture test run, not from a source-reading mistake that was obvious in advance.
361
384
  - **STIX Two Math (the embedded formula font) is a CFF-flavoured OpenType font (an `OTTO` sfnt wrapping a `CFF ` table), not TrueType/glyf** — confirmed by inspecting the vendored font's own sfnt table directory while `src/pdf/math-font.ts` was built; the design plan this feature was built against assumed glyf and asked to confirm which and handle accordingly. Genuine Type2-charstring glyph subsetting (re-encoding charstrings, rebuilding the CFF `INDEX` structures with a renumbered, minimal glyph set) is a substantially larger undertaking than TrueType glyf/loca subsetting, and is out of scope for this pass: the **entire** `CFF ` table is embedded verbatim, unmodified, as a single `/FontFile3` `/Subtype /CIDFontType0C` stream — a real, correct, working embedded font, just not glyph-subsetted. Everything else genuinely IS built from a targeted parse of only what's used: `cmap` resolves exactly the Unicode code points a document's formulas actually reference to glyph IDs, and the emitted `/W` widths array and ToUnicode CMap only ever cover those same glyph IDs, not the font's full ~5,500-glyph repertoire. A CID-keyed composite font built this way needs no `/CIDToGIDMap` at all (that key exists only for `/CIDFontType2`): per ISO 32000-1 9.7.4.2, a `/CIDFontType0` whose `/FontFile3` is a "bare" (non-CID-keyed) CFF program is read with CID treated as directly indexing the CFF's own `CharStrings` INDEX by glyph order — i.e. CID == GID — exactly the numbering `cmap`-derived glyph IDs already use, so Identity-H text-showing needs no further remapping anywhere in the write path.
362
385
  - **The OpenType `MATH` table's `MathVariants` subtable (stretchy glyph assembly — building a tall parenthesis or brace from reusable top/middle/bottom/extender pieces) is deliberately not parsed.** A stretchy fence (`(`/`)`/`[`/`]`/`{`/`}`/`|`/`‖`) or a stretchy `<mo>` wrapping a tall construct (a large fraction, a tall matrix) always renders at its own base glyph's fixed size, not dynamically resized to its content's own height — a documented, honest fallback the task's own design plan explicitly allowed for when the full variants subtable proves too large a sub-scope. The `MathConstants` subtable (every fraction/radical/script-positioning constant this package's own layout engine actually uses) and the `MathGlyphInfo` subtable (italics correction, top-accent attachment) ARE both genuinely parsed in full — see `src/pdf/math-table.ts`.
363
386
  - **A token element's (`mi`/`mn`/`mo`/`mtext`) own box height comes from the font's nominal design ascent/descent (`hhea`'s own `ascender`/`descender`), not a tight per-glyph ink bounding box.** `src/mathml/` never parses glyph outlines (no `glyf`/CFF charstring geometry extraction anywhere in this package — see the CFF-embedding gotcha above), so every token run shares one uniform vertical extent regardless of which characters it actually contains. Accurate enough for box-model layout (spacing, baseline alignment, page placement) but not pixel-tight around an unusually tall or shallow glyph.
@@ -384,6 +407,8 @@ Neither direction is round-trip-lossless, and no conversion is the exact inverse
384
407
 
385
408
  **The six cross-format bridges (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`) are a categorically different case from every conversion above: they bypass the PDF pivot entirely, so the "not round-trip-lossless" caveat that applies to every PDF-pivot conversion in this section does not carry over to them.** There is no layout engine (no flow, no line-wrapping, no pagination) and no geometry-based reconstruction (no baseline clustering, no gridline-lattice detection) anywhere in a bridge's own call path — each is nothing more than `buildYPackage(readXContent(decodePackage(bytes)))`, composing the identical reader/builder pair the PDF-pivot conversions on either side of the bridge already use, because both formats in each pair read into and build from the exact same `ContentDocument` variant. Concretely, for `odt ⇄ docx` and `odp ⇄ pptx`: text, run styling (bold/italic/underline/colour/font/size), paragraph `styleId`, list membership and nesting level, table structure and cell content, and (for `odp ⇄ pptx`) speaker notes all survive completely — proven by `src/convert/bridges.test.ts`'s own dedicated round-trip suite, exercised in both directions from both starting formats, and cross-checked by opening genuinely LibreOffice-produced source files and their bridged output in real LibreOffice (see that test file and this repo's own verification notes). The one confirmed gap is a table shape nested inside an odp/pptx slide shape, not the document/presentation structure itself — see the gotcha above. `ods ⇄ xlsx` preserves cell values, formulas (verbatim), and merged ranges completely, and column widths within a roughly one-pixel rounding tolerance on the `odsToXlsx` hop — but, being built on `ooxml.js`'s brand-new `readXlsxContent`/`buildXlsxPackage`, carries several real, honestly-documented format-boundary limits of its own (percentage/currency downgrading to a plain number, time collapsing into date, xlsx column widths not surviving the `xlsxToOds` return hop, and a formula written in one dialect showing as a genuine formula error in a REAL spreadsheet application expecting the other) — see the `ods ⇄ xlsx` gotcha above for the full, specific list. None of this is layout drift or reconstruction guesswork; every gap listed is a genuine format-boundary limit (a cell type, a value kind, or a write-side omission that exists independently of this bridge), not an approximation introduced by the bridge itself.
386
409
 
410
+ **`.odb` table extraction (`readOdbTables`, all three tiers) is a genuine, verified data extraction, not an approximation — but it recovers only what a `.odb`'s own embedded database storage actually carries, which differs by tier.** Tier 1 (HSQLDB TEXT script) parses real DDL/DML text, so a table's own declared column types survive as the literal SQL clause they were declared with, and row values are the literal `INSERT` statement literals. Tier 2 (HSQLDB CACHED-table binary row store) shares Tier 1's own DDL-derived column types — a CACHED table's DDL still lives in `database/script` as ordinary TEXT — but decodes its actual row *values* from a separate binary page-cache file, `database/data`, cross-verified field-by-field against a real HSQLDB JDBC oracle on the identical fixture (see the Gotchas entry above). Tier 3 (Firebird) decodes a real gbak backup stream — every cell value, `NULL`, and column name is genuinely read from the file, cross-verified field-by-field against real LibreOffice's own SDBC query on the identical fixture (see the Gotchas entry above for the full verification transcript) — but a column's own `HsqldbColumn.type` label is *synthesised* from the field's binary metadata (BLR type + length + scale), not lifted from source SQL text the way Tier 1/2's is, since a gbak backup carries no DDL text at all. No tier recovers a database's own forms, reports, or queries (names only, never content — see the gotcha above), and none has a reverse (xlsx/CSV → `.odb`) direction. Tier 3 additionally has three real, bounded, honestly-scoped gaps, all documented in code comments at the exact spot each applies: no BLOB column content (the blob's own reference is consumed for stream alignment, but its column value is always recorded empty); no FB4+-only types (`INT128`/`DECFLOAT`, i.e. a `NUMERIC`/`DECIMAL` column wider than 18 digits of precision — this reader's own real fixtures are Firebird 3.0-era output, which has no such types to begin with); and a blob-VALUED metadata *attribute* (a relation/field/index/trigger's own description, default value, or BLR body) uses a different, compound wire encoding this reader's generic attribute-skip does not yet handle — never encountered by either real fixture this reader was verified against, but a real gap on a `.odb` whose tables carry comments or computed columns.
411
+
387
412
  **Optional real-world corpus.** `test/corpus/` (gitignored, never committed) holds a `pnpm test:corpus` vitest project for manual conformance checking against real PDFs a hand-built fixture can't fully stand in for — a Word "Save as PDF", a PowerPoint "Save as PDF", a Chrome "Print to PDF", a LibreOffice export. It is not part of `pnpm test` and does not gate CI; drop files in locally before a significant parser change.
388
413
 
389
414
  ## Release and publishing
@@ -402,6 +427,7 @@ Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), e
402
427
  - [document-content-model](https://github.com/ExaDev/document-content-model) — the sibling package that owns `ContentDocument`/`LayoutDocument` themselves; both `ooxml.js` and `documents.js` import from it rather than each maintaining an independent copy.
403
428
  - [odf.js](https://github.com/ExaDev/odf.js) — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on `document-content-model`. A dependency of `documents.js` for: this package's `Odt`/`Ods`/`Odp`/`OdgBytesSchema` (`src/model/bytes.ts`), which validate against its `ODF_MEDIA_TYPES` table; `src/interop.test.ts`, a type-level guard that `ooxml.js`'s and `odf.js`'s raw `XmlElement`/`XmlNode`/`Attribute`/`Package` container types stay structurally compatible; `src/odf/odt/read.ts`'s `readOdtContent`, a thin adapter over `odf.js`'s own `readOdt`, feeding `odtToPdf`/`pdfToOdt` (`src/convert/convert.ts`); `src/odf/odp/read.ts`'s `readOdpContent`, the same adapter over `odf.js`'s `readOdp`, feeding `odpToPdf`/`pdfToOdp`; `src/odf/ods/read.ts`'s `readOdsContent`, the same adapter over `odf.js`'s `readOds`, feeding `odsToPdf`/`pdfToOds`, and reused directly by `src/edit/ods/print-settings.ts`'s own `readSheetPrintSettings` (`findStyleElement`/`resolvePageLayoutProperties`/`parsePageSize`/`parseMargins`, the same style-chain-resolution primitives `readOds`'s own `readPrintSettings` is built on); `src/odf/odg/read.ts`'s `readOdgContent`, the same adapter over `odf.js`'s `readOdg` — including its own `typed/shared/path.ts`, the real-LibreOffice-output-verified `svg:d`/`draw:points` parser this package's `writePath` content is ultimately sourced from, and which `src/edit/odg/svg-path.ts`'s `buildSvgPathData` (the write-side inverse) also cross-checks its own output against directly — feeding `odgToPdf`/`pdfToOdg` (the latter re-reading a rebuilt package's own real geometry through this same `readOdg`, not just writing one); `src/odf/formula/read.ts`'s `readOdfFormulaContent`/`readOdfEmbeddedFormula`, thin adapters over `odf.js`'s own `readOdfFormula`, feeding `odfToPdf` and the odt/odp embedded-formula paths respectively; `src/edit/odt/*`'s `StyleRegistry`/`resolveStyle` (style interning), `src/edit/odp/shape.ts`'s `applyOdfTransform`/`resolveOdfShapeGeometry` (rotation), and `src/edit/odt/automatic-styles.ts`'s `ensureAutomaticStyles`/`nextStyleName` (reused by `src/edit/odg/style.ts`'s own graphic-family style writer and `src/edit/ods/print-settings.ts`'s own page-layout/master-page/table-style minting), all consumed directly rather than reimplemented. odt, odp, ods, and odg → `ContentDocument` reading and PDF conversion are now all integrated both ways.
404
429
  - [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font `src/pdf/math-font.ts` parses and `odfToPdf` (and the odt/odp embedded-formula paths) render through, vendored at `assets/fonts/STIXTwoMath-Regular.otf` and embedded into `dist/` as a base64 string (`src/mathml/assets/stix-two-math-font.ts`, generated by `scripts/generate-math-font-asset.mjs`) rather than read from disk at runtime. Copyright 2001-2021 The STIX Fonts Project Authors, licensed [OFL-1.1](assets/fonts/OFL.txt) — see `assets/fonts/NOTICE.md` for the exact source commit and version this was vendored from.
430
+ - [firebirdsql/firebird](https://github.com/FirebirdSQL/firebird) — the ground truth `src/firebird/` is built against, since Firebird's own gbak backup format has no ratified public specification: `src/burp/burp.h` (the `rec_type`/`att_type` enumerations and their own per-block numbering, and the backup-format version history), `src/burp/backup.epp`/`restore.epp` (the write/read reference implementation `src/firebird/reader.ts`'s attribute framing and RLE decompression are restated from), `src/burp/canonical.cpp` (the per-SQL-type XDR shape a row's own field values use), `src/burp/mvol.cpp` (the backup-header attributes and their own presence-means-true encoding), `src/common/xdr.cpp` (the underlying big-endian XDR primitive encodings, including the `xdr_hyper` high-word-first ordering this reader's own construction initially got backwards), `src/jrd/align.h`/`src/include/firebird/impl/blr.h` (the BLR-type-opcode-to-physical-storage-type mapping), and `src/common/classes/NoThrowTimeStamp.cpp` (the DATE/TIME encoding algorithms). Not a dependency of this package at build or runtime — read and cited as source material only, per commit state at the time `src/firebird/` was built.
405
431
 
406
432
  ## License
407
433