js.documents 0.0.0 → 1.54.0
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/LICENSE +21 -0
- package/README.md +428 -0
- package/dist/index.cjs +11385 -0
- package/dist/index.d.cts +1407 -0
- package/dist/index.d.ts +1407 -0
- package/dist/index.js +10730 -0
- package/package.json +88 -3
package/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# documents.js
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ExaDev/documents.js) [](https://www.npmjs.com/package/documents.js) [](https://github.com/ExaDev/documents.js/releases/latest) [](https://github.com/ExaDev/documents.js/actions)
|
|
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, `.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
|
+
|
|
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 provided by [`pdf-codec`](https://github.com/ExaDev/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](#architecture).
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
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](#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-content-model`, `pdf-codec`, and `fflate`.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
## Getting started
|
|
16
|
+
|
|
17
|
+
Requires Node.js `>=20` and pnpm `11.6.0` (pinned via `packageManager` in `package.json`).
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Install as a dependency in another project:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pnpm add documents.js
|
|
27
|
+
# or
|
|
28
|
+
npm install documents.js
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
The twelve round-trip ergonomic conversions (docx/pptx/odt/odp/ods/odg ⇄ PDF, all now round-trip both ways):
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { docxToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToDocx, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pptxToPdf, pdfToPptx } from 'documents.js';
|
|
37
|
+
|
|
38
|
+
const pdfBytes = docxToPdf(docxBytes);
|
|
39
|
+
const docxBytes2 = pdfToDocx(pdfBytes);
|
|
40
|
+
|
|
41
|
+
const pdfFromSlides = pptxToPdf(pptxBytes);
|
|
42
|
+
const pptxBytes2 = pdfToPptx(pdfFromSlides);
|
|
43
|
+
|
|
44
|
+
const pdfFromOdt = odtToPdf(odtBytes);
|
|
45
|
+
const odtBytes2 = pdfToOdt(pdfFromOdt);
|
|
46
|
+
|
|
47
|
+
const pdfFromOdp = odpToPdf(odpBytes);
|
|
48
|
+
const odpBytes2 = pdfToOdp(pdfFromOdp);
|
|
49
|
+
|
|
50
|
+
const pdfFromOdg = odgToPdf(odgBytes);
|
|
51
|
+
const odgBytes2 = pdfToOdg(pdfFromOdg);
|
|
52
|
+
|
|
53
|
+
const pdfFromOds = odsToPdf(odsBytes);
|
|
54
|
+
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed, not what was entered -- see Fidelity
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Each accepts an optional `signal` (`AbortSignal`) and either a `onSubstitution` callback (docx/pptx/odt/odp/ods/odg → PDF, called once per character not representable in a standard-14 font) or a `sink` (PDF → docx/pptx/odt/odp/ods/odg, called once per recoverable parse diagnostic).
|
|
58
|
+
|
|
59
|
+
Six further conversions bypass PDF entirely: `odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, and `odsToXlsx`/`xlsxToOds` 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](#fidelity) for what that means in practice.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { odtToDocx, docxToOdt } from 'documents.js';
|
|
63
|
+
|
|
64
|
+
const docxBytes = odtToDocx(odtBytes);
|
|
65
|
+
const odtBytes2 = docxToOdt(docxBytes);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each takes an optional `{ signal }` — there is no `onSubstitution`/`sink` option here, since there is no font substitution or PDF-parse degradation to report; a wrong-kind `ContentDocument` throws outright rather than becoming a diagnostic.
|
|
69
|
+
|
|
70
|
+
The same conversions behind a swappable port, for a caller that wants to inject a different implementation later without changing call sites:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createLocalDocumentConverter } from 'documents.js';
|
|
74
|
+
|
|
75
|
+
const converter = createLocalDocumentConverter();
|
|
76
|
+
const { document, diagnostics } = await converter.convert(
|
|
77
|
+
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
|
|
78
|
+
{ signal: new AbortController().signal },
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`DocumentFormat` includes `xlsx` alongside `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`pdf` — not because xlsx has a PDF conversion of its own, but because `createLocalDocumentConverter`'s `{ source, targetFormat }` contract already generalises past "targetFormat always means pdf": `odt`→`docx`, `docx`→`odt`, `odp`→`pptx`, `pptx`→`odp`, `ods`→`xlsx`, and `xlsx`→`ods` are six further entries in the same `conversions` list, routed to the six bridge functions above with an empty `diagnostics` array.
|
|
83
|
+
|
|
84
|
+
Getting back the intermediate `DocumentPackage` (content + layout, from `document-content-model`) a conversion built internally, instead of only the target bytes — every ergonomic conversion function above accepts an `onDocument` callback for this, and the port surfaces the same value as `package` on its `ConversionResult`:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { docxToPdf } from 'documents.js';
|
|
88
|
+
|
|
89
|
+
const pdfBytes = docxToPdf(docxBytes, {
|
|
90
|
+
onDocument: (pkg) => {
|
|
91
|
+
console.log(pkg.content.kind); // 'wordprocessing'
|
|
92
|
+
console.log(pkg.layout?.pages.length); // populated for every X-to-PDF/PDF-to-X conversion
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// or via the port:
|
|
97
|
+
const { document, package: pkg } = await converter.convert(
|
|
98
|
+
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
|
|
99
|
+
{ signal: new AbortController().signal },
|
|
100
|
+
);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For the six PDF-bypassing bridges, `pkg.layout` is always `undefined` — a bridge never runs a layout engine, so there is nothing to populate it with; running one purely to fill this field would be wasted work no caller asked for.
|
|
104
|
+
|
|
105
|
+
Reading and editing docx/pptx content directly, without going through PDF at all:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { openDocx, createDocx } from 'documents.js';
|
|
109
|
+
|
|
110
|
+
const editor = openDocx(existingDocxBytes);
|
|
111
|
+
const paragraph = editor.body.appendParagraph({ alignment: 'center' });
|
|
112
|
+
const run = paragraph.appendRun({ text: 'Hello' });
|
|
113
|
+
run.bold = true;
|
|
114
|
+
run.color = { r: 1, g: 0, b: 0 };
|
|
115
|
+
const bytes = editor.toBytes();
|
|
116
|
+
|
|
117
|
+
// or start from nothing:
|
|
118
|
+
const fresh = createDocx();
|
|
119
|
+
fresh.body.appendParagraph().appendRun({ text: 'New document' });
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`openPptx`/`createPptx` and `PptxSlide`/`PptxShape` are the pptx equivalent (`slide.addTextBox`, `slide.addImage`, `shape.setParagraphs` for multi-paragraph styled text).
|
|
123
|
+
|
|
124
|
+
`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](#conventions) below). `openOdp`/`createOdp` and `OdpSlide`/`OdpShape` are the odp equivalent of `PptxSlide`/`PptxShape` (`slide.addTextBox`, `slide.addImage`, `slide.notes`), and reuse `OdtParagraph`/`OdtRun`/`OdtList` directly for a shape's own text content — a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` model `office:text` does, interned into the same `content.xml` style registry:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { createOdp } from 'documents.js';
|
|
128
|
+
|
|
129
|
+
const editor = createOdp();
|
|
130
|
+
const slide = editor.addSlide();
|
|
131
|
+
const title = slide.addTextBox({ frame: { xPt: 40, yPt: 30, widthPt: 640, heightPt: 80 }, text: 'Title' });
|
|
132
|
+
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter, unlike PptxShape's own documented gap below
|
|
133
|
+
const bullets = slide.addTextBox({ frame: { xPt: 40, yPt: 130, widthPt: 300, heightPt: 200 }, text: '' });
|
|
134
|
+
bullets.paragraphs()[0].remove();
|
|
135
|
+
bullets.addList().addItem().appendParagraph({ text: 'A real bulleted text:list' });
|
|
136
|
+
slide.notes = 'Speaker notes for this slide';
|
|
137
|
+
const bytes = editor.toBytes();
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`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.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { createOds } from 'documents.js';
|
|
144
|
+
|
|
145
|
+
const editor = createOds();
|
|
146
|
+
const sheet = editor.addSheet('Sheet1');
|
|
147
|
+
sheet.printSettings = { pageSize: { widthPt: 595, heightPt: 842 }, margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, gridlines: true, headers: true, pageOrder: 'downThenOver' };
|
|
148
|
+
sheet.cell(0, 0).value = { kind: 'string', value: 'Total' }; // 0-based (row, column) -- there is no A1-string overload
|
|
149
|
+
sheet.cell(0, 1).value = { kind: 'currency', value: 42.5, currency: 'USD' };
|
|
150
|
+
sheet.cell(500, 50).value = { kind: 'boolean', value: true }; // does not materialise 500x50 empty cells
|
|
151
|
+
const bytes = editor.toBytes();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`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](#architecture)); `addRect`/`addEllipse`/`addLine`/`addPath` return `OdgBoxVector`/`OdgLineVector`/`OdgPathVector`, writing real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path` elements. A vector's own paint order is purely document order — the same convention real LibreOffice output already uses, so an earlier `add*` call paints behind a later one, with no `draw:z-index` attribute ever written.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { createOdg } from 'documents.js';
|
|
158
|
+
|
|
159
|
+
const editor = createOdg();
|
|
160
|
+
const page = editor.addPage();
|
|
161
|
+
page.addRect({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 60 }, fill: { r: 1, g: 0.5, b: 0 } });
|
|
162
|
+
page.addEllipse({ frame: { xPt: 140, yPt: 20, widthPt: 100, heightPt: 60 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 } });
|
|
163
|
+
page.addPath({
|
|
164
|
+
frame: { xPt: 20, yPt: 100, widthPt: 80, heightPt: 80 },
|
|
165
|
+
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 } }] }],
|
|
166
|
+
fill: { r: 1, g: 1, b: 0 },
|
|
167
|
+
}); // a genuine Bezier curve -- writes a real svg:d/svg:viewBox pair, not a polygon approximation
|
|
168
|
+
page.addTextBox({ frame: { xPt: 20, yPt: 200, widthPt: 300, heightPt: 30 }, text: 'A label on top' });
|
|
169
|
+
const bytes = editor.toBytes();
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`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.
|
|
173
|
+
|
|
174
|
+
Reading and writing PDF bytes directly, without going through docx/pptx:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { readPdf, writePdf } from 'documents.js';
|
|
178
|
+
|
|
179
|
+
const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/link items
|
|
180
|
+
const bytes = writePdf(layout);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The same seven round trips (PDF ⇄ `LayoutDocument`, docx ⇄ PDF, pptx ⇄ PDF, odt ⇄ PDF, odp ⇄ PDF, ods ⇄ PDF, odg ⇄ PDF) are each also available as a schema-validated [`z.codec()`](https://zod.dev) 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.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { z } from 'zod';
|
|
187
|
+
import { docxPdfCodec, pdfCodec, pptxPdfCodec } from 'documents.js';
|
|
188
|
+
|
|
189
|
+
const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes has no %PDF- header
|
|
190
|
+
const pdfBytes2 = z.encode(pdfCodec, layout);
|
|
191
|
+
|
|
192
|
+
const pdfFromDocx = z.decode(docxPdfCodec, docxBytes);
|
|
193
|
+
const docxBack = z.encode(docxPdfCodec, pdfFromDocx);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The six PDF-bypassing bridges above get the same treatment: `odtDocxCodec`, `odpPptxCodec`, and `odsXlsxCodec` (odt bytes ⇄ docx bytes, odp bytes ⇄ pptx bytes, ods bytes ⇄ xlsx bytes) — the no-options form again, `odtToDocx`/`docxToOdt`/etc. remain the entry points for `signal`.
|
|
197
|
+
|
|
198
|
+
`readDocxContent`/`readPptxContent`/`readOdtContent`/`readOdpContent`/`readOdsContent`/`readOdgContent` (docx/pptx/odt/odp/ods/odg → `ContentDocument`), `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; `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 comes back a bare string, never re-parsed into a number/date/boolean or claimed as a formula (see [Fidelity](#fidelity)).
|
|
199
|
+
|
|
200
|
+
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:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { readFileSync } from 'node:fs';
|
|
204
|
+
import { odmToPdf, OdmUnresolvedSectionError } from 'documents.js';
|
|
205
|
+
|
|
206
|
+
const chapterBytes = new Map([
|
|
207
|
+
['../chapter1.odt', new Uint8Array(readFileSync('chapter1.odt'))],
|
|
208
|
+
['../chapter2.odt', new Uint8Array(readFileSync('chapter2.odt'))],
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const pdfBytes = odmToPdf(odmBytes, {
|
|
213
|
+
resolveSubDocument: (href) => chapterBytes.get(href),
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof OdmUnresolvedSectionError) {
|
|
217
|
+
console.error('missing chapters:', error.hrefs); // every unresolved href, not just the first
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
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
|
+
|
|
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
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { decodePackage } from 'odf.js';
|
|
228
|
+
import { odbToCsv, odbToXlsx, readOdbTables } from 'documents.js';
|
|
229
|
+
|
|
230
|
+
const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table, a header row of column names then one row per record
|
|
231
|
+
const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // exactly one named table as CSV -- required whenever the .odb has more than one table
|
|
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 -- the identical shape whether the .odb is HSQLDB- or Firebird-backed
|
|
234
|
+
```
|
|
235
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { odfToPdf } from 'documents.js';
|
|
250
|
+
|
|
251
|
+
const pdfBytes = odfToPdf(odfBytes); // a single formula (or small formula document), faithfully typeset -- see Fidelity
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`odfToPdf` is not one of the twelve round-trip conversions above either: there is no `pdfToOdf` (recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one — see [Fidelity](#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.
|
|
255
|
+
|
|
256
|
+
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:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { odtToPdf } from 'documents.js';
|
|
260
|
+
|
|
261
|
+
// odtBytes contains an ordinary paragraph followed by an embedded formula object (LibreOffice: Insert > Object > Formula) --
|
|
262
|
+
// the formula renders as real typeset MathML in the output PDF, at the position and approximate size of its own source frame.
|
|
263
|
+
const pdfBytes = odtToPdf(odtBytes);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
This works by threading the formula's own raw MathML alongside the `ContentDocument` `readOdtContent`/`readOdpContent` already produce (see `OdtContentResult`/`OdpContentResult`'s own `formulas` field) rather than through `ContentDocument` itself, since document-content-model's own `ContentEmbeddedObject.document` field has no MathML-shaped variant to carry it in:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { convertWordprocessingToLayout, readOdtContent } from 'documents.js';
|
|
270
|
+
|
|
271
|
+
const { document, formulas } = readOdtContent(pkg); // formulas: ReadonlyMap<sourcePath, EmbeddedFormula>
|
|
272
|
+
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer, formulas });
|
|
273
|
+
const pdfBytes = writePdf(layout, { formulas: positioned }); // writePdf's own formula-aware option -- see Architecture
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`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:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { layoutFormula, loadMathFont } from 'documents.js';
|
|
280
|
+
|
|
281
|
+
const { metricsAt } = loadMathFont();
|
|
282
|
+
const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), sizePt: 12, color: { r: 0, g: 0, b: 0 } });
|
|
283
|
+
// box: a MathBox -- positioned glyph runs, fraction/radical rules, and radical-hook strokes, ready for pdf-codec's own math-content-write.ts
|
|
284
|
+
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see Fidelity
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Architecture
|
|
288
|
+
|
|
289
|
+
The package is layered from generic primitives outward to the two conversion directions:
|
|
290
|
+
|
|
291
|
+
- **`src/model/`** — thin, documents.js-specific additions on top of the sibling [`document-content-model`](https://github.com/ExaDev/document-schema.js) package, which now owns the two pivot models themselves: `LayoutDocument` (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — `LayoutPath` is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind `writePath`, pdf-codec's own content-write.ts, turns into PDF `m`/`l`/`c`/`h` content-stream operators) and `ContentDocument` (the semantic pivot: a discriminated union of `wordprocessing`, `presentation`, `spreadsheet`, 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`, `pdf-codec`, and `documents.js` share one schema instead of each maintaining an independent, drift-prone copy. What remains local: `bytes.ts` (magic-byte-validated `Uint8Array` schemas for docx/pptx/PDF, plus `Odt`/`Ods`/`Odp`/`OdgBytesSchema`, which check the package's actual declared media type against `odf.js`'s `ODF_MEDIA_TYPES` table rather than only the generic ZIP signature the OOXML schemas are limited to), `units.ts` (OOXML EMU/twip/point/half-point conversions), and `geometry.ts`/`color.ts`/`style.ts`, each now mostly a thin re-export of `document-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 (pdf-codec's own `fonts.ts`/`font-read.ts`) as PDF-specific, now external to this package entirely. `content.ts` holds the one genuinely local piece of the `ContentDocument` envelope (`CONTENT_FORMAT_VERSION`), and `formula.ts` defines `EmbeddedFormula` — the side-channel formula shape threaded alongside a `ContentDocument` rather than inside it (see the Usage section above), with a type-only dependency on `MathMlNode` from `mathml` (see the dependency-direction note below). `PositionedFormula` (the equivalent side-channel shape for a `LayoutDocument`) now lives in `pdf-codec` itself, which redeclares its own structurally-identical copy of it and of `MathBox` — see [Architecture](#architecture) below and pdf-codec's own README for why a real `MathBox` this package's `layoutFormula` produces crosses that package boundary with zero cast or wrapper.
|
|
292
|
+
- **The hand-written PDF codec, and the generic byte/image primitives it depends on, are now the external [`pdf-codec`](https://github.com/ExaDev/pdf-codec) dependency** rather than local `src/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 own `src/layout/` to build on).
|
|
293
|
+
- **`src/ports/`** — the 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 row loop boundaries in `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; `pdf-codec` needed the identical helper for its own page loops and now carries its own independently-duplicated copy rather than depending on this package for it) 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 pdf-codec'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.
|
|
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).
|
|
297
|
+
- **`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 from `model`, `pdf-codec`, 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 — pdf-codec's own `math-font.ts` is the real implementation, consumed only through this structural port, never imported directly. `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), passed with zero cast into pdf-codec's `writePdf({ 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.
|
|
298
|
+
- **`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.
|
|
299
|
+
- **`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).
|
|
300
|
+
- **`src/layout/`** — the pure conversion algorithms, importing `model`, (for formula placement) `mathml`, and — for line-wrapping/pagination itself — several primitives sourced from the external `pdf-codec` dependency: the injected `TextMeasurer` port and `wrapRunsToWidth` (pdf-codec's own `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` (pdf-codec's own `math-font.ts`, for formula placement), pdf-codec's `matrix.ts`'s `rotatePointAboutCenter` (`slides.ts`'s own shape-rotation placement), and pdf-codec's `afm-widths.ts`/`fonts.ts`'s `STANDARD_METRICS`/`resolveStandardFont` (`reconstruct.ts`'s own font-matching when reconstructing from a `LayoutDocument`) — 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` (`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).
|
|
301
|
+
- **`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 pdf-codec'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.
|
|
302
|
+
- **`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.
|
|
303
|
+
- **`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.
|
|
304
|
+
- **`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.
|
|
305
|
+
|
|
306
|
+
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-content-model`, 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 the value level, but `formula.ts` carries one type-only import of `MathMlNode` from `mathml` (erased entirely at runtime, and not a cycle — `mathml` itself imports nothing from `model`); `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 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`); `odb` imports `hsqldb`+`firebird`+`model`+`odf-package`+odf.js only; `convert` composes everything else, including `pdf-codec` directly for `readPdf`/`writePdf`/`loadMathFont`. Beyond this package's own local modules, four external dependencies each own a distinct concern with no overlap: `ooxml.js` (docx/pptx/xlsx ⇄ JSON), `odf.js` (odt/ods/odp/odg ⇄ JSON), `document-content-model` (the shared `ContentDocument`/`LayoutDocument` schemas), and `pdf-codec` (the PDF codec itself, plus the text-layout/font-resolution/byte/image primitives built on it). 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.
|
|
307
|
+
|
|
308
|
+
## Build, test, and lint
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts)
|
|
312
|
+
pnpm typecheck # tsc --noEmit
|
|
313
|
+
pnpm lint # eslint . --max-warnings 0
|
|
314
|
+
pnpm test # vitest run --project unit
|
|
315
|
+
pnpm test:watch # vitest --project unit
|
|
316
|
+
pnpm test:smoke # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise, a real createOdg/odgToPdf/pdfToOdg round trip (a curved path, a filled rect, and text, built entirely through the odg live-view editor, converted to PDF and reconstructed back to odg via reconstructDrawing), and a real odfToPdf conversion (a fraction, rendered via the embedded STIX Two Math font -- checked by confirming the built PDF contains a real /Type0/Identity-H/CIDFontType0C font resource, proving the base64-embedded font asset itself survived the tsdown build), from the built CJS bundle
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The 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.
|
|
320
|
+
|
|
321
|
+
To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
322
|
+
|
|
323
|
+
## Conventions
|
|
324
|
+
|
|
325
|
+
- **Zod-first schema/type/guard**, matching `ooxml.js`: every model type is inferred from its Zod schema, never hand-written. `ContentBlock` (recursive, mirroring `ooxml.js`'s own `XmlNode` treatment) uses a hand-written structural guard + `z.custom`, not `z.lazy`, which collapses to `unknown` for recursive element-children in the pinned Zod version.
|
|
326
|
+
- **`z.codec()` for every schema-to-schema round trip**, matching `ooxml.js`'s `packageCodec`/`xmlCodec`: `pdfCodec` (PDF bytes ⇄ `LayoutDocument`), `docxPdfCodec`/`pptxPdfCodec`/`odtPdfCodec`/`odpPdfCodec`/`odsPdfCodec`/`odgPdfCodec` (docx/pptx/odt/odp/ods/odg bytes ⇄ PDF bytes), and `odtDocxCodec`/`odpPptxCodec`/`odsXlsxCodec` (odt/odp/ods bytes ⇄ docx/pptx/xlsx bytes, the PDF-bypassing bridges) each wrap an already-independently-tested function pair, adding automatic two-way schema validation. These are deliberately the no-options form — `readPdf`/`writePdf`/`docxToPdf`/`pdfToDocx`/`pptxToPdf`/`pdfToPptx`/`odtToPdf`/`pdfToOdt`/`odpToPdf`/`pdfToOdp`/`odsToPdf`/`pdfToOds`/`odgToPdf`/`pdfToOdg`/`odtToDocx`/`docxToOdt`/`odpToPptx`/`pptxToOdp`/`odsToXlsx`/`xlsxToOds` remain the primary entry points wherever a caller needs an `AbortSignal`, a `PdfDiagnosticSink`, or an `onSubstitution` callback, since `z.codec()`'s fixed `decode(input)`/`encode(output)` signature has no room for side-channel options.
|
|
327
|
+
- **`PdfObject` has no Zod schema at all**, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser — validating it would just be validating our own output. It narrows natively on its own `kind` discriminant instead, the same reasoning `ooxml.js` applies when it picks a hand-written `isXmlNode` guard over `z.lazy`.
|
|
328
|
+
- **No type assertions anywhere.** Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
|
|
329
|
+
- **Live views, not flatten-and-regenerate.** `src/edit/*`'s editor classes hold a reference directly into the real `Package`/`XmlElement` objects; saving is `encodePackage(pkg)`, nothing more. This is what makes "everything you didn't touch stays byte-faithful" a structural guarantee rather than a best effort.
|
|
330
|
+
- **A three-tier PDF-read failure policy** governs everything `readPdf` reports back through its own `PdfDiagnosticSink` — 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.
|
|
331
|
+
- **Conventional commits**, enforced via commitlint + husky, matching `ooxml.js`.
|
|
332
|
+
|
|
333
|
+
## Gotchas and quirks
|
|
334
|
+
|
|
335
|
+
- **`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.
|
|
336
|
+
- **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.
|
|
337
|
+
- **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.
|
|
338
|
+
- **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.
|
|
339
|
+
- **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.
|
|
340
|
+
- **A table shape inside an odp slide does not survive `odpToPptx`.** `buildPptxPackage`'s `appendShape` (`src/edit/pptx/content.ts`) silently drops any non-paragraph block found inside a shape's own text-box loop — a scope choice whose own comment ("PDF-reconstructed shapes never mix kinds") assumed its only caller was the PDF-reconstruction path, where that is true. `odpToPptx` is a second, non-PDF-reconstructed caller for which it is not: a real odp `draw:frame` containing a `table:table` directly (not inside a text box) reads as a `ContentShape` with a `'table'` block, and that block is silently dropped, leaving an empty pptx text box where the table was. Everything else on the same slide — a rotated shape, grouped shapes, an image, speaker notes — survives correctly (see `src/convert/bridges.test.ts`'s own dedicated fidelity-gap test, which proves both halves against the existing `minimalOdpBytes()` fixture). A real, tracked, bounded gap, not a silent one: closing it means teaching `buildPptxPackage`/`buildOdpPackage` to write a real table into a slide shape, a materially larger feature than this bridge's own scope.
|
|
341
|
+
- **The `ods ⇄ xlsx` bridge inherits several real, format-boundary fidelity limits from `ooxml.js`'s brand-new `readXlsxContent`/`buildXlsxPackage`, on top of its own pivot-copy design.** xlsx has no `percentage`/`currency` cell type of its own (both are a plain numeric cell plus a number-format style neither this reader nor this writer interprets) — an ods `percentage`/`currency` cell survives the `odsToXlsx` hop with its numeric *value* intact but downgrades to a plain `number` *kind*, permanently (currency's own currency code is dropped outright). xlsx also has only one rare `t="d"` cell type covering BOTH date and time — an ods `time` cell survives as a `date`-kind cell carrying its original value string verbatim, but mislabelled; an ods `date` cell is unaffected (it was already the kind xlsx's own `t="d"` maps onto). A formula (`table:formula`/`<f>`) is carried completely verbatim in both directions — never parsed, translated, or evaluated by either this package's own reader or writer — but a REAL spreadsheet application does evaluate a workbook's own `<f>`/`table:formula` on open: confirmed against genuine LibreOffice 26.2, an ods formula authored in OpenFormula syntax (`of:=[.B2]*2`) becomes a formula ERROR (`Err:510`) when the bridged xlsx is opened in real Calc, even though the formula's own cached value is still present and correctly readable via `readXlsxContent` — going the other way is less fragile in practice only because a genuine xlsx formula (bare Excel A1 syntax, e.g. `B2*2`) happens to still parse under LibreOffice's own more lenient, backward-compatible ODF formula grammar, not because of anything this bridge does differently in either direction. Column widths survive the `odsToXlsx` hop within roughly a pixel of rounding tolerance (see `src/convert/bridges.test.ts`'s own `COLUMN_WIDTH_TOLERANCE_PT`) but are then dropped entirely on the return `xlsxToOds` hop — not a character-width-unit rounding loss, but `buildOdsPackage` not writing `ContentSheetColumn.widthPt` at all, a pre-existing, already-documented gap in that file's own module comment, unrelated to and unfixed by this bridge. A boolean cell written by `buildXlsxPackage` renders as a raw `1`/`0` rather than `TRUE`/`FALSE` when opened in real Excel/Calc, since that writer's own genuinely-minimal `xl/styles.xml` (one default cell format, no boolean-specific number format) has nothing else to apply — the underlying `{ kind: 'boolean', value: true }` is still read back correctly by `readXlsxContent` regardless; this is a real-application *display* gap, not a data-fidelity one. `readXlsxContent`'s own cell.value.kind never produces `'error'` from an odf.js-sourced document at all, for a structural reason rather than a bug: ODF's `office:value-type` enumeration has no `error` member, so `OdsCell.value`'s own write-side choice for a `kind: 'error'` cell is to write it as a genuine, non-empty `office:string-value` carrying the error's own text — an `xlsxToOds` → `odsToXlsx` round trip of a genuine xlsx `t="e"` error cell therefore turns it into a plain `string` cell carrying the identical text; the message survives, the `error` semantic does not.
|
|
342
|
+
- **`odpToPdf`/`pdfToOdp` needed zero new layout code.** `readOdpContent` (`src/odf/odp/read.ts`) produces the identical `presentation` `ContentDocument` shape `readPptxContent` does, so it feeds `convertPresentationToLayout` unmodified — including the existing hidden-annotation speaker-notes mechanism below, which carries odp's `presentation:notes` through to the PDF with no new notes-handling code at all; `pdfToOdp` reuses `reconstructPresentation` unmodified too, the same architectural bet `pdfToOdt` already proved for `reconstructWordprocessing`. The genuinely new work for the reverse direction was the live-view editor itself (`src/edit/odp/*`) — see Architecture above.
|
|
343
|
+
- **`OdpShape.rotationDeg` writes a real `draw:transform`, built on `odf.js`'s own transform machinery.** It is the write-side inverse of `odf.js`'s `resolveOdfShapeGeometry` (`typed/shared/transform.ts`), built on that module's own exported `applyOdfTransform` rather than a hand-rolled rotation matrix, so it inherits that module's own empirically-verified rotate/translate composition order and sign convention by construction. Unlike `PptxShape` (see the `colSpan`/`rowSpan` gotcha below, which pptx still has and odp does not), `buildOdpPackage` writes a rotated shape's rotation back correctly — verified both by this package's own tests and by opening a fresh, editor-built `.odp` in actual LibreOffice.
|
|
344
|
+
- **`readPdf` tracks general vector paths, not just axis-aligned rectangles — pdf-codec's own capability, with a direct consequence for this package's `pdfToOds`/`reconstructDrawing`.** A stroked-and-filled rect, any ellipse, and any plain line each come back from a real PDF round trip as a generic `LayoutPath` rather than their original kind (see pdf-codec's own `interpret.ts` gotcha for the exact fast-path boundary and the ISO 32000-1 operators involved). This is the shared infrastructure both `pdfToOds` and `reconstructDrawing` need; both now use it. A direct, practical consequence for `pdfToOds`: `readPdf` never reconstructs a `'line'` kind item at all, so a gridline written by `sheets.ts`'s own `renderGridlines` always comes back from a real PDF round trip as a generic, single-subpath, single-line-segment, stroke-only `LayoutPath` — `reconstructSpreadsheet`'s own gridline-lattice detection accepts both shapes (a genuine `LayoutLine` item and this stroked-single-segment `LayoutPath` shape) for exactly this reason.
|
|
345
|
+
- **`pdfToOds` recovers what was printed, not what was entered.** `reconstructSpreadsheet` (`src/layout/reconstruct.ts`) tries a real gridline lattice first: it scans the page's `LayoutLine`/stroked-single-segment-`LayoutPath` items (see the `interpret.ts` gotcha above) for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid (`MIN_GRIDLINE_COUNT_PER_AXIS = 3` per axis, i.e. at least a 2×2 grid, and a span-consistency check that rejects a scatter of unrelated short strokes — a page border or a couple of decorative rules — as not a genuine lattice), and uses those line positions DIRECTLY as cell boundaries when found. Absent a lattice, it clusters text into a grid from geometry alone instead: rows reuse `clusterIntoLines` verbatim (a spreadsheet cell's own text is never wrapped across lines, so a text line already IS a row), and columns generalise `clusterIntoParagraphs`'s own single `dominantLeftX` to several recurring x-position anchors, first merging directly-adjacent same-line fragments (`splitLineByLargeGaps`, the same >2em-gap signal `reconstructPresentation`'s own block clustering uses) so a cell whose text arrived as several run-level-split `LayoutText` items isn't scattered across spurious columns. Column widths and row heights are genuinely measured from whichever geometry was used (drawn gridline gaps, or measured text/anchor extents), never invented. Every recovered cell is a bare string carrying only its own extracted `displayText` — **never** re-parsed into a number/date/boolean, and never claimed as a formula, even when the text looks numeric or date-shaped; see [Fidelity](#fidelity) for the full framing. `buildOdsPackage` (`src/edit/ods/content.ts`) is `pdfToOds`'s own package-building half, mirroring `buildOdtPackage`/`buildOdpPackage`/`buildOdgPackage`'s role for `pdfToOdt`/`pdfToOdp`/`pdfToOdg`.
|
|
346
|
+
- **`buildOdsPackage` now writes `printSettings` for real, via a new `OdsSheet.printSettings` getter/setter (`src/edit/ods/print-settings.ts`) — discovered as a genuine blocker while building `pdfToOds`'s own round-trip verification, not a pre-planned feature.** `OdsEditor`/`OdsSheet` previously had no width/height/print-settings API at all, so `buildOdsPackage` silently dropped `ContentSheetPrintSettings` entirely; that made a reconstructed sheet's own recovered `gridlines`/`headers`/`pageSize` unverifiable by any real write-then-reread round trip, which is exactly what `pdfToOds`'s own test needed to prove. The setter mints a fresh `style:page-layout` (`styles.xml`/`office:automatic-styles`) + `style:master-page` (`styles.xml`/`office:master-styles`) + `style:style[family="table"]` (`content.xml`/`office:automatic-styles`) triple and repoints the sheet's own `table:style-name` to it on every call, rather than mutating whatever it was pointing at before — the same append-only style-editing convention `src/edit/odg/style.ts` already documents. Scoped to the five fields `ContentSheetPrintSettingsSchema` always carries (`pageSize`/`margins`/`gridlines`/`headers`/`pageOrder`); `printRange`/`scale`/`fitToPages`/`repeatRows`/`repeatColumns`/`manualBreaks` (all optional, and never set by `reconstructSpreadsheet`) are still not read or written — resolving them needs the same table-wide repeated-column/row cursor tracking `odf.js`'s own `readTable` does before ever calling its own `readPrintSettings`, a genuinely separate, larger undertaking than this getter/setter's own scope.
|
|
347
|
+
- **`OdsSheet`/`OdsEditor` still have no column-width or row-height setter at all — discovered the same way as the `printSettings` gap above, while building `pdfToOds`'s own smoke-test coverage.** `OdsSheet.cell()`'s own column/row-materialisation (`address.ts`) creates a real, explicit `table:table-column`/`table:table-row` element for any position a caller ever addresses, but never gives it a width/height style. This is a genuinely different failure shape from a column/row with NO element at all: `sheets.ts`'s own `resolveAxis` only falls back to `DEFAULT_COLUMN_WIDTH_PT`/`DEFAULT_ROW_HEIGHT_PT` for an index with no `ContentSheetColumn`/`ContentSheetRow` entry whatsoever — an explicit-but-unstyled element reads back at `widthPt`/`heightPt` 0 (`odf.js`'s own `resolveColumnWidthPt`/`readRowLayout`), and that explicit zero wins over the fallback. A sheet built purely through `createOds()`/`OdsSheet.cell()` therefore renders with a zero-size grid — real content needs an explicit column-width/row-height style, exactly as a real LibreOffice-authored file always has one (see `src/test-support/ods.ts`'s own fixtures, which set one deliberately). `buildOdsPackage` (`src/edit/ods/content.ts`) documents this as a tracked, bounded gap alongside `ContentSheetImage`/`embeddedObjects`, mirroring `buildOdtPackage`'s own identical image/colSpan-write gaps.
|
|
348
|
+
- **`reconstructDrawing` maps recovered geometry back onto ODF shapes near-1:1, with no clustering — but PDF's own content-stream operators still force several `ContentVector` kinds to collapse to a generic `path` on the way through.** Every painted `LayoutItem` maps onto a `ContentVector`/`ContentShape` directly, in the exact z-order it was recovered — `LayoutRect` → `rect`, `LayoutEllipse` → `ellipse`, `LayoutLine` → `line`, `LayoutPath` → `path`, `LayoutText`/`LayoutImage` → `ContentShape` — a fundamentally more tractable problem than `reconstructWordprocessing`/`reconstructPresentation`'s own paragraph/shape geometry clustering, since a drawing has no semantic structure to infer at all. The catch is upstream of `reconstructDrawing` itself, in what `readPdf` can even hand it: pdf-codec's own `LayoutRect` fast path only fires for a fill-only rectangle under a non-rotated CTM (see the general-vector-path-tracking gotcha above), `writeEllipse` always emits an ellipse as four cubic Beziers with no PDF-level marker that it started life as an ellipse, and `readPdf` never reconstructs a `'line'` kind item at all — so a stroked-and-filled rect, any ellipse, and any line each come back from a PDF as a generic `LayoutPath`, and `reconstructDrawing` correctly maps that to a `ContentVector` `'path'`, not the shape's original kind. Position, size, and fill/stroke colour still survive (within ordinary floating-point/string-formatting tolerance); only the vector's own discriminant kind narrows to whatever PDF's content-stream operators actually distinguish. A `path` vector's own reconstructed `frame` is a further, separate approximation: it is the *tight* bounding box of every recovered point, cubic control points included (a cubic curve is guaranteed to lie within their convex hull, so this never clips the curve) — which can legitimately be *larger* than whatever frame the original path's own author declared, if that frame didn't tightly bound its own control points to begin with (a real, valid ODF/SVG authoring pattern: a `viewBox`/frame is a declared coordinate window, not a guaranteed tight bounding box). A single original drawing text box that PDF's own greedy line-wrapper split across several lines does **not** reconstruct as one multi-line shape: `reconstructDrawing` maps each recovered `LayoutText` item to its own separate `ContentShape` (the same one-`LayoutItem`-to-one-shape rule every other kind follows), so a wrapped multi-line text box comes back as several small, independently-positioned text boxes, one per original line — confirmed visually against real LibreOffice (see the real-file verification note below); the full text content still survives, just redistributed. `buildOdgPackage` (`src/edit/odg/content.ts`) is `pdfToOdg`'s own package-building half, mirroring `buildOdtPackage`/`buildOdpPackage`'s role for `pdfToOdt`/`pdfToOdp`.
|
|
349
|
+
- **Two real, confirmed-against-actual-LibreOffice-rendering fill bugs were fixed as part of building `reconstructDrawing`/`pdfToOdg`, not by it.** Both are pre-existing gaps in code that `reconstructDrawing`'s own real-file verification exposed, not something the reconstruction algorithm itself introduced, and both apply to every `.odg` this package writes, not only a reconstructed one: (1) `src/edit/odg/style.ts`'s `graphicPropertyAttrs` wrote `draw:fill-color` alone, with no accompanying `draw:fill="solid"` — real LibreOffice 26.2 fills a `draw:rect`/`draw:ellipse` that way fine, but silently renders a `draw:path` with the identical omission as unfilled, even with a fill colour declared. `draw:fill="solid"` is now written explicitly whenever a fill is set, for every vector kind. (2) `writeEllipse` (pdf-codec's own `content-write.ts`) never emitted a PDF closepath (`h`) operator, even though its four Bezier arcs already return exactly to their own starting point — PDF fill operators close every subpath implicitly regardless (ISO 32000-1 8.5.3.1), but `readPdf`'s own general path tracking only marks a subpath `closed: true` when it actually sees an explicit `h`, so a PDF-round-tripped ellipse came back with `closed: false`, which correctly-behaving ODF/SVG consumers then refuse to fill even with `draw:fill="solid"` set. `writeEllipse` now emits `h` before its paint operator, drawing no additional ink (the path was already geometrically closed) but recording that closure explicitly.
|
|
350
|
+
- **A vector primitive's own fill/stroke needed a self-contained graphic-family style writer, not `odf.js`'s own `StyleRegistry`.** `'graphic'` is a recognised `StyleFamily` member (`odf.js`'s `src/styles/registry.ts`), but `StylePropertiesSchema`/`buildStylePropertyElements` (`properties.ts`/`serialize.ts`) only ever model text/paragraph formatting and never emit a `style:graphic-properties` element for any family — extending that shared package for one narrow, documents.js-local need (`draw:fill(-color)`/`draw:stroke` + `svg:stroke-color`/`svg:stroke-width`) would be scope creep into a foreign package for a two-attribute-group writer this package can express directly. `src/edit/odg/style.ts` is that writer: it still reuses `odf.js`'s general append-only style-editing invariant (a setter always mints a fresh `style:style` and repoints `draw:style-name`, never mutates an existing entry — verified by the same `assertAutomaticStylesOnlyAppended` helper `OdpEditor`'s own live-view fidelity test uses) and `src/edit/odt/automatic-styles.ts`'s `ensureAutomaticStyles`/`nextStyleName` (the "find-or-create `office:automatic-styles`, mint the next unused name" logic every other hand-rolled style helper in this package already shares), rather than a third reimplementation of either.
|
|
351
|
+
- **A path vector's own `svg:d` is cross-checked against `odf.js`'s real parser, not merely asserted to "look plausible".** `src/edit/odg/svg-path.ts`'s `buildSvgPathData` is the write-side inverse of `odf.js`'s `parseOdfPathData`; `OdgPathVector.subpaths` re-derives its value by reparsing the actual written `svg:viewBox`/`svg:d` through that exact function (plus `parseOdfViewBox`/`buildOdfSubpaths`) on every read, rather than echoing back whatever `ContentSubpath[]` the caller originally passed to `addPath` — so every read is itself a live round-trip proof, and this module's own test suite additionally feeds `buildSvgPathData`'s output straight into `parseOdfPathData` to confirm point-for-point recovery.
|
|
352
|
+
- **A newly added vector/shape's paint order is expressed purely as document order, with no `draw:z-index` ever written.** This matches `odf.js`'s own reader-side convention exactly (`typed/draw/shapes.ts`'s `paintOrderKey`: honour an explicit `draw:z-index` when present, otherwise fall back to document order — and real LibreOffice output never emits one, it reorders elements instead), so `OdgPage.addRect`/`addEllipse`/`addLine`/`addPath`/`addTextBox`/`addImage` simply append to `draw:page`'s own children in call order and nothing more is needed for a later `add*` call to paint in front of an earlier one.
|
|
353
|
+
- **`LayoutPathSchema` (`document-content-model`) has no quadratic or elliptical-arc segment kind, deliberately — not a scope gap that happens to be unfilled.** `writePath` (pdf-codec's own `content-write.ts`) therefore has no quadratic-to-cubic elevation and no SVG-arc-to-cubic endpoint-to-centre parameterization anywhere in it: `odf.js`'s own real-LibreOffice-output-verified `svg:d` parser (`typed/shared/path.ts`) recognises `S`/`s`/`Q`/`q`/`T`/`t`/`A`/`a` as command letters (so its own token stream stays in sync) but produces no segment for any of them — real LibreOffice output for rectangles, ellipses, freeform curves, and basic custom-shape presets never emits a quadratic or an arc in the first place, only `M`/`L`/`H`/`V`/`C`/`Z`. Building unused quadratic/arc conversion code against a segment kind that can never occur would be speculative, not root-cause work.
|
|
354
|
+
- **A drawing page's `shapes` and `vectors` paint in two independently-ordered arrays, with no field recording their relative order.** `ContentDrawPageSchema` (`document-content-model`) keeps text/image/table content (`shapes`) and vector primitives (`vectors`) as two separate arrays, each correctly paint-ordered on its own by `odf.js`'s own reader (honouring a real `draw:z-index` when present, falling back to document order otherwise) — but there is no shared ordering field between the two arrays at all, a real, tracked gap in the shared schema, not something `convertDrawingToLayout` can reconstruct after the fact. `convertDrawingToLayout` resolves it with one fixed, documented choice: every vector paints before every shape (vectors are the common "diagram" content in a real `.odg`; shapes are far more often text labels layered on top of them than the reverse). A page that genuinely interleaves the two mid-stack will not paint in true document z-order until the schema itself grows a shared field. `reconstructDrawing` resolves the identical gap in reverse the same way: it buckets each recovered `LayoutItem` into `vectors` or `shapes` by kind while walking the page once in overall paint order, so each array keeps its own items' relative order — which reproduces a `convertDrawingToLayout`-produced page's original paint order exactly (vectors-then-shapes, by construction), and is still the best either array's own shape is able to express for a `LayoutDocument` from any other producer.
|
|
355
|
+
- **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.
|
|
356
|
+
- **`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.
|
|
357
|
+
- **`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.
|
|
358
|
+
- **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, and pdf-codec's own README, for the exact scope of that embedding (the whole `CFF ` table, not glyph-subsetted).
|
|
359
|
+
- **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.
|
|
360
|
+
- **Reading arbitrary real-world PDFs, encrypted-PDF support, and unsupported image filters (`CCITTFaxDecode`/`JBIG2Decode`/`JPXDecode`) are all pdf-codec's own scope boundaries now, not this package's.** In short: the parser targets cleanly-generated output from mainstream producers rather than adversarial-input robustness; `/Encrypt` in the trailer throws rather than attempting decryption, even for the common empty-user-password case; scanned-fax and JPEG2000 images are skipped with a diagnostic while the rest of the page still reads (JPEG and PNG both pass through losslessly). See pdf-codec's own README for the full statement of each.
|
|
361
|
+
- **PDF → docx/pptx reconstruction has no table or vector-shape recovery.** A PDF has no semantic table structure to recover — a wide horizontal gap on a line becomes a tab character, not a reconstructed grid. General vector paths, curves, gradients, and shadings are not recovered either. This is a genuinely different scope boundary from `pdfToOds`'s own grid recovery (see the `pdfToOds` gotcha above): there, the grid itself IS the deliverable, so a real gridline lattice or text-position clustering builds one deliberately, within its own honestly-scoped limits (a bare string per cell, never a typed value).
|
|
362
|
+
- **Table cell `colSpan`/`rowSpan` and pptx shape rotation are read from a `ContentDocument` but not yet written back** by `buildDocxPackage`/`buildOdtPackage`/`buildPptxPackage` — a merged cell round-trips as an ordinary unmerged one, and a rotated *pptx* shape round-trips unrotated (`buildOdpPackage` does not share the rotation half of this gap — see the `OdpShape.rotationDeg` gotcha above). Both are bounded, tracked gaps (the cell's own text content and the shape's own position are still correct), not silent ones.
|
|
363
|
+
- **docx headers/footers, live `PAGE`/`NUMPAGES` field substitution, and inline images are not read** by `readDocxContent` — a deliberate, tracked scope narrowing from the original design, not an oversight.
|
|
364
|
+
- **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.
|
|
365
|
+
- **`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.
|
|
366
|
+
- **`.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).
|
|
367
|
+
- **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.
|
|
368
|
+
- **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.
|
|
369
|
+
- **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.
|
|
370
|
+
- **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.
|
|
371
|
+
- **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.
|
|
372
|
+
- **`.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.
|
|
373
|
+
- **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.
|
|
374
|
+
- **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.
|
|
375
|
+
- **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.
|
|
376
|
+
- **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.
|
|
377
|
+
- **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.
|
|
378
|
+
- **STIX Two Math is embedded as a whole, unmodified `CFF ` table rather than glyph-subsetted, and its `MathVariants` (stretchy glyph assembly) subtable is deliberately not parsed at all — both pdf-codec's own font-embedding scope decisions, not this package's.** The practical consequence for this package's own MathML rendering: 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. See pdf-codec's own README for the full CFF-embedding and `MathVariants` scope statement.
|
|
379
|
+
- **A token element's (`mi`/`mn`/`mo`/`mtext`) own box height comes from the font's nominal design ascent/descent, not a tight per-glyph ink bounding box.** `src/mathml/` never parses glyph outlines itself (no `glyf`/CFF charstring geometry extraction anywhere in this package — pdf-codec's own font parsing doesn't expose per-glyph ink bounds either, see its README), 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.
|
|
380
|
+
- **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.
|
|
381
|
+
- **`mover`/`munder`/`munderover` centre an over/under-script geometrically over the wider of the two boxes, not at the base glyph's own font-declared accent-attachment point (`MathTopAccentAttachment`, which the embedded font's `MathGlyphInfo` subtable DOES carry and this package DOES parse — see the CFF-embedding gotcha above — just not consumed here).** Visually correct for the common case of a single-character base (geometric centre ≈ optical centre for a roughly symmetric glyph); measurably different only for a multi-character or asymmetric base under a genuine `accent="true"` mark. A real, bounded simplification, not a data gap — the metric this would need is already being parsed for a different purpose.
|
|
382
|
+
- **Greek `mathvariant` mapping covers the plain alphabet plus nabla (∇) and partial differential (∂), not the OpenType/Unicode Greek "symbol variant" set** (epsilon/theta/kappa/phi/rho/pi symbol glyphs — `ϵ`/`ϑ`/`ϰ`/`ϕ`/`ϱ`/`ϖ` styled to e.g. bold). Latin letters, digits, and the two named symbols above are fully covered, generated directly from Unicode's own `UnicodeData.txt` (see `src/mathml/variant.ts`'s own generation note) rather than transcribed by hand.
|
|
383
|
+
- **Embedded-formula detection inside odt/odp is genuinely new work with no `odf.js`-side equivalent (`readDrawFrameContent` doesn't recognise a `draw:object`-bearing `draw:frame` at all yet — see the `src/odf/` architecture entry above), and each format's own detection carries its own real, bounded scope narrowing.** For **odt** (`src/odf/odt/read.ts`): only a `draw:frame` that is a *direct child of `office:text`* is detected — a formula anchored inline inside a paragraph's own run content, or nested inside a `draw:g` group, is not. Detected formulas are appended to the **end** of the section's own `blocks` array, in the order their frames appear in the document, not interleaved at their true original position among the paragraphs/tables `odf.js`'s own reader already produced — true positional interleaving would need per-element block-count bookkeeping this adapter doesn't have (a `text:list`, for instance, unwraps into many `ContentParagraph` blocks from one raw XML element, so "one raw child = one block" doesn't hold in general). For **odp** (`src/odf/odp/read.ts`): only a top-level `draw:frame` on a `draw:page` with no `draw:g` sibling at all is detected (a slide containing any group is skipped entirely for formula detection, to avoid mismatching a formula onto the wrong shape) — but where it IS detected, position is exact, not appended: `odf.js`'s own `walkDrawShapes` already produces exactly one `ContentShape` per top-level `draw:frame` in document order, so the Nth frame maps precisely onto `shapes[N]`. **ods embedded-formula detection is not implemented at all** — `odf.js`'s `readOds` has no existing floating-drawing/anchor-resolution mechanism (`ContentSheetImage`/`ContentSheet.embeddedObjects` are both already-known, pre-existing unpopulated gaps this task does not newly create — see the `ContentSheetCellSchema` gotcha above for the sibling gap on the write side), so there is no `readDrawFrame`-equivalent entry point to hook a formula-frame scan onto the way odt/odp have; `src/layout/sheets.ts` accordingly has no formula-handling branch at all, with a comment marking why.
|
|
384
|
+
- **A formula that isn't rendered as real MathML (an odm chapter's own embedded formula, or any formula crossing the `odtToDocx`/`docxToOdt`/`odpToPptx`/`pptxToOdp` bridges) survives only as its own plain-text placeholder** — the formula's StarMath annotation if it had one, or the literal `[formula]` otherwise (see `src/odf/formula/placeholder.ts`). `odmToPdf`'s own per-chapter `readOdtContent` call discards that chapter's own `formulas` map entirely (re-keying every formula's `sourcePath` against the final combined document's own renumbered block indices is a materially larger undertaking than this task's own scope, and `.odm` has no confirmed real-world test fixture to validate it against regardless — see the `odmToPdf` gotcha below). `buildDocxPackage` has no MathML-writing path of its own (OOXML's own math markup, OMML, is a different vocabulary this package does not write), so the six cross-format bridges never consult a formula's real MathML either, even when bridging between two formats that both, individually, support real formula rendering elsewhere in this package.
|
|
385
|
+
- **`sourcePath` traces a `LayoutItem` back to the `ContentDocument` node it came from, but only within one read+layout pass.** `ooxml.js`'s `readDocx`/`readPptx` stamp every `ContentRun`/`ContentImageBlock`/`ContentTable`/`ContentShape` with a positional path (`sections[0].blocks[2].runs[1]`, `slides[1].shapes[3].blocks[0]`); `convertWordprocessingToLayout`/`convertPresentationToLayout` copy that same string onto whichever `LayoutText`/`LayoutImage`/`LayoutLink`/`LayoutRect` item(s) it produces, so a positioned PDF-side item can be traced back to its semantic origin. When line-wrapping splits one run's word across a run boundary, every resulting fragment gets its own run's path (not a shared or merged one); when a single run is emergency-split across several lines or pages, every resulting fragment keeps that same one run's path unchanged. A table cell's background `LayoutRect` is attributed to its containing table's own `sourcePath`, since `ContentTableCell` carries none of its own. This is **not** an edit-tracking or incremental-relayout mechanism — the path is only valid against the exact `ContentDocument`/`Package` it was assigned from in that one read; editing the document, re-reading it, or reordering its blocks invalidates every previously-captured path, and nothing here recomputes or diffs paths across two versions of a document.
|
|
386
|
+
|
|
387
|
+
## Fidelity
|
|
388
|
+
|
|
389
|
+
**docx/pptx/odt/odp/ods/odg → PDF** is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, `basedOn` chains, placeholder inheritance for docx/pptx; `style:default-style`/`style:parent-style-name` chains for odt/odp). `odg` renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF `m`/`l`/`c`/`h` content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful **visual approximation**, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — see the standard-14 font substitution gotcha above.
|
|
390
|
+
|
|
391
|
+
**odf → PDF (`odfToPdf`), and a formula embedded inside odt/odp,** render **faithful mathematical typesetting**, not a static image or a plain-text placeholder: real box-model layout (script/limit positioning, fraction/radical geometry with correct rule thickness, table column alignment, `mathvariant` → Mathematical Alphanumeric Symbols mapping) through the embedded STIX Two Math font, with genuine per-glyph metrics (advance width, italic correction, top-accent attachment) and font-wide layout constants (axis height, fraction/radical rule thickness and gaps, script shift amounts) parsed directly from that font's own `MATH` table — not approximated or hand-tuned. The honest limits: stretchy delimiters render at a fixed size rather than dynamically assembling to their content's own height (the `MathVariants` subtable isn't parsed), a token's own box height comes from the font's nominal ascent/descent rather than a tight per-glyph ink bound, `mover`/`munder` centre geometrically rather than at the font's own declared accent-attachment point, and the operator dictionary and Greek `mathvariant` mapping each cover a deliberately bounded, common-case set rather than the full specification — see the Gotchas entries above for the exact boundary of each. **`pdfToOdf` (PDF → structured MathML) is not attempted, on either direction** — recovering a semantic operator tree (is this pair of glyphs a fraction, or a coincidentally stacked pair of ordinary characters? is a raised glyph a superscript, or just a smaller font size used for emphasis?) from nothing but positioned glyphs and paths is a categorically different, OCR-adjacent problem, with no geometry-reconstruction analogue anywhere else in this package: `reconstructWordprocessing`/`reconstructPresentation` recover paragraph/shape *structure* from geometry, never semantic *meaning* the way recognising a fraction would require.
|
|
392
|
+
|
|
393
|
+
**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.
|
|
394
|
+
|
|
395
|
+
**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.
|
|
396
|
+
|
|
397
|
+
**PDF → ods (`pdfToOds`, `reconstructSpreadsheet`) recovers what was printed, not what was entered.** This is a harder, categorically different limit than any other reconstruction direction above, not merely a looser version of the same one: docx/pptx/odp/odg reconstruction can at least recover real text, formatting, and (for odg) exact vector geometry from a PDF's own positioned glyphs and paths. A spreadsheet cell's own *value* — a formula, a real typed number, a date serial, a currency code — never exists anywhere in a rendered PDF at all; a PDF only ever carries the **rendered string** Calc or Excel chose to print for that cell. `reconstructSpreadsheet` is honest about this rather than pretending otherwise: every recovered cell is a bare `{ kind: 'string', value: displayText }`, never re-parsed back into a number/date/boolean, and never claimed as a formula, even when the recovered text looks numeric or date-shaped (`"42.5"`, `"2024-01-15"`) — see the `pdfToOds` gotcha above for the full detection algorithm (a real gridline lattice used directly as cell boundaries when a printed sheet had gridlines enabled, text-position clustering otherwise). Column widths, row heights, and page size are genuinely measured from whichever geometry was used; no print range, scale, fit-to-page, repeat-rows/columns, or manual breaks are ever inferred, since a rendered page carries no trace of print *intent*, only what was visually printed. Verified against real LibreOffice 26.2, not merely against this package's own reader: a genuine, gridline-and-headers-enabled four-column, four-row employee-record `.ods` (mixed string/date/boolean-looking cell content) round-tripped through `odsToPdf` then `pdfToOds` opens as a valid spreadsheet with every cell's text recovered in its correct row/column position, via the gridline-lattice path specifically (confirmed by inspecting the recovered `printSettings.gridlines`), and every recovered cell an honest string.
|
|
398
|
+
|
|
399
|
+
Neither direction is round-trip-lossless, and no conversion is the exact inverse of its own reverse direction — `pdfToDocx(docxToPdf(x))` will not reproduce `x` exactly, and neither will `pdfToOdg(odgToPdf(x))` or `pdfToOds(odsToPdf(x))`; neither is intended to. This is a deliberate, permanent contrast with `ooxml.js`'s own `packageCodec`, which genuinely is a lossless round trip. `docxPdfCodec`/`pptxPdfCodec`/`odtPdfCodec`/`odpPdfCodec`/`odsPdfCodec`/`odgPdfCodec`/`pdfCodec` share `packageCodec`'s *mechanism* (`z.codec()`, schema-validated both ways) but not its *guarantee* — wrapping a lossy conversion in `z.codec()` validates the shape of what comes out, not its fidelity to what went in.
|
|
400
|
+
|
|
401
|
+
**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.
|
|
402
|
+
|
|
403
|
+
**`.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.
|
|
404
|
+
|
|
405
|
+
**Optional real-world corpus.** The gitignored, manual real-world PDF conformance harness this README used to describe here now lives in [pdf-codec](https://github.com/ExaDev/pdf-codec)'s own repository, since it exercises the PDF codec directly rather than anything this package adds on top.
|
|
406
|
+
|
|
407
|
+
## Release and publishing
|
|
408
|
+
|
|
409
|
+
`.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](https://semantic-release.gitbook.io/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](https://www.npmjs.com/package/documents.js) — via npm's OIDC trusted publishing, so no `NPM_TOKEN` exists anywhere in the pipeline.
|
|
410
|
+
|
|
411
|
+
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.
|
|
412
|
+
|
|
413
|
+
## Contributing
|
|
414
|
+
|
|
415
|
+
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.
|
|
416
|
+
|
|
417
|
+
## References
|
|
418
|
+
|
|
419
|
+
- [ooxml.js](https://github.com/ExaDev/ooxml.js) — the sibling package this depends on for all docx/pptx/xlsx ⇄ JSON handling and cascade-resolved typed reading, including its own `readXlsxContent`/`buildXlsxPackage` (a `ContentDocument`-shaped xlsx reader/writer pair), consumed directly by `src/convert/convert.ts`'s `odsToXlsx`/`xlsxToOds` bridge but not re-exported from this package's own public surface.
|
|
420
|
+
- [document-content-model](https://github.com/ExaDev/document-schema.js) — the sibling package that owns `ContentDocument`/`LayoutDocument` themselves; `ooxml.js`, `odf.js`, `pdf-codec`, and `documents.js` all import from it rather than each maintaining an independent copy.
|
|
421
|
+
- [pdf-codec](https://github.com/ExaDev/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 primitives `src/layout/` builds on. See [Architecture](#architecture) above for exactly where the boundary between the two packages sits, and pdf-codec's own README for its internals.
|
|
422
|
+
- [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.
|
|
423
|
+
- [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font `odfToPdf` (and the odt/odp embedded-formula paths) render through. Vendored, parsed, and embedded entirely within `pdf-codec` now (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.
|
|
424
|
+
- [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.
|
|
425
|
+
|
|
426
|
+
## License
|
|
427
|
+
|
|
428
|
+
MIT
|