js.documents 1.98.2 → 1.99.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -372
- package/dist/convert/composition.cjs +8 -0
- package/dist/convert/composition.js +9 -1
- package/dist/convert/variant-bridges.cjs +28 -0
- package/dist/convert/variant-bridges.d.cts +6 -1
- package/dist/convert/variant-bridges.d.ts +6 -1
- package/dist/convert/variant-bridges.js +27 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
> Converts between any two compatible document formats through a shared content/layout pivot
|
|
5
|
+
> Converts between any two compatible document formats through a shared content/layout pivot. docx, pptx, odt, odp, ods, odg, xlsx, and markdown all read into and build from the same `ContentDocument`/`LayoutDocument` model, with PDF as the one format every variant can reach. A composition engine (`convertDocument`) routes 73 (source, target) pairs across the eight content formats and PDF, including fourteen PDF-pivot round trips, sixteen cross-format bridges (same-variant direct copies, cross-variant semantic transforms, and PDF-composed), plus special-case conversions for `.odm` master documents, `.odb` database front-ends (HSQLDB and Firebird, four storage tiers), standalone `.odf` formula documents, and a bounded SQL/rpt-formula engine for `.odb` reports. Also includes: read-and-write live-view editors for all six editable formats, docx comment/footnote/header-footer exposure via `readDocxExtras`, real font resolution (source-embedded faces ahead of caller-supplied, vendored substitutes, and the standard 14), a hand-written MathML typesetting engine with embedded-font PDF rendering and a matching MathML ⇄ OMML translator, and a fully hand-written PDF codec. Built on [ooxml.js](https://github.com/ExaDev/ooxml.js), [odf.js](https://github.com/ExaDev/odf.js), [pdf-codec](https://github.com/ExaDev/pdf-codec), [markdown-codec](https://github.com/ExaDev/markdown-codec), and [document-schema.js](https://github.com/ExaDev/document-schema.js).
|
|
6
6
|
|
|
7
|
-
`documents.js`
|
|
7
|
+
`documents.js` extends `ooxml.js` in two directions `ooxml.js` deliberately does not cover: full PDF support (parsing and generating, via `pdf-codec`), and a read-**and-write** manipulation API for docx/pptx content — `ooxml.js`'s own typed readers are one-way. The PDF codec is hand-written against ISO 32000-1, with no external PDF library as a dependency — see [Fidelity](#fidelity) and pdf-codec's own README for the honest trade-off (not as robust against adversarial PDFs as a 15+-year-hardened library; fully auditable and dependency-free instead). `src/mathml/` (the MathML typesetting engine) stays in this package and is hand-written too, for the same supply-chain reason.
|
|
8
8
|
|
|
9
9
|
```mermaid
|
|
10
10
|
graph TD
|
|
@@ -50,9 +50,7 @@ graph TD
|
|
|
50
50
|
|
|
51
51
|
## Why
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
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.
|
|
53
|
+
The PDF side hand-writes every layer of the format against ISO 32000-1 rather than wrapping a third-party library. The read-and-write editor exists because `ooxml.js`'s typed readers are a deliberate one-way projection — editors are live views directly over the `XmlElement` objects inside a decoded `Package`, so a mutation edits the tree in place and everything you don't touch round-trips byte-faithful.
|
|
56
54
|
|
|
57
55
|
## Getting started
|
|
58
56
|
|
|
@@ -72,7 +70,26 @@ npm install documents.js
|
|
|
72
70
|
|
|
73
71
|
## Usage
|
|
74
72
|
|
|
75
|
-
The
|
|
73
|
+
### The generic entry point: `convertDocument`
|
|
74
|
+
|
|
75
|
+
A single function, `convertDocument`, sits behind every named conversion and reaches every pair the composition engine can route — all 73 supported (source, target) combinations. The named functions below are thin one-line forwarders to it; they remain the ergonomic layer for a caller who wants a fixed pair and autocomplete discovery, while `convertDocument` is the first-class entry point for a caller working from a runtime format pair (CLI, MCP tool, matrix enumeration).
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { convertDocument } from 'documents.js';
|
|
79
|
+
|
|
80
|
+
// markdown -> pptx has no named function of its own: the composition engine routes it
|
|
81
|
+
// as one cross-variant transform hop (read wordprocessing, wordprocessingToPresentation, build pptx).
|
|
82
|
+
const pptxBytes = convertDocument('markdown', 'pptx', markdownBytes);
|
|
83
|
+
|
|
84
|
+
// Every option a named function accepts is accepted here too, threaded to whichever hop consumes it.
|
|
85
|
+
const odtBytes = convertDocument('docx', 'odt', docxBytes, { onMathDiagnostic: (d) => console.warn(d) });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`convertDocument` throws `UnsupportedConversionError` (a named class, so a caller can branch on it) for any pair the composition engine cannot route — there is no silent fallback. `resolveCompositionPlan(source, target)` is exported too, for surfacing the resolved hop plan without running it.
|
|
89
|
+
|
|
90
|
+
### PDF-pivot conversions
|
|
91
|
+
|
|
92
|
+
The fourteen round-trip ergonomic conversions between the formats with their own layout engine and PDF (docx/pptx/odt/odp/ods/odg/markdown ⇄ PDF, all round-tripping both ways), plus `xlsxToPdf`/`pdfToXlsx` (composing the ods⇄xlsx bridge with the ods⇄pdf layout pair internally):
|
|
76
93
|
|
|
77
94
|
```ts
|
|
78
95
|
import { docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToXlsx, pptxToPdf, xlsxToPdf } from 'documents.js';
|
|
@@ -95,20 +112,18 @@ const odgBytes2 = pdfToOdg(pdfFromOdg);
|
|
|
95
112
|
const pdfFromOds = odsToPdf(odsBytes);
|
|
96
113
|
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed, then heuristically re-types it -- see Fidelity
|
|
97
114
|
|
|
98
|
-
const pdfFromXlsx = xlsxToPdf(xlsxBytes); // composes xlsxToOds -> odsToPdf internally
|
|
115
|
+
const pdfFromXlsx = xlsxToPdf(xlsxBytes); // composes xlsxToOds -> odsToPdf internally
|
|
99
116
|
const xlsxBytes2 = pdfToXlsx(pdfFromXlsx); // composes pdfToOds -> odsToXlsx internally
|
|
100
117
|
|
|
101
118
|
const pdfFromMarkdown = markdownToPdf(markdownBytes);
|
|
102
119
|
const markdownBytes2 = pdfToMarkdown(pdfFromMarkdown); // the lossiest conversion in the whole package -- see Fidelity
|
|
103
120
|
```
|
|
104
121
|
|
|
105
|
-
Each accepts an optional `signal` (`AbortSignal`) and either
|
|
106
|
-
|
|
107
|
-
Every X → PDF conversion additionally accepts `fonts` (extra `ProvidedFont` faces to make available) and `onFontSubstitution` (called once per requested family+weight+style that resolved to something else). Neither is needed for the common case: the conversion already extracts the **source document's own embedded fonts** and renders through them, so a docx or odt saved with font embedding turned on comes out in its real typeface at its real metrics with no caller involvement at all — see [Fonts](#fonts) below for the full resolution order.
|
|
122
|
+
Each accepts an optional `signal` (`AbortSignal`) and either `onSubstitution` (X → PDF, called per character not representable in a standard-14 font) or `sink` (PDF → X, called per recoverable parse diagnostic). Every X → PDF conversion additionally accepts `fonts` (extra `ProvidedFont` faces) and `onFontSubstitution` (per family+weight+style that resolved to something else). Neither is needed for the common case — see [Fonts](#fonts).
|
|
108
123
|
|
|
109
|
-
|
|
124
|
+
### Cross-format bridges
|
|
110
125
|
|
|
111
|
-
|
|
126
|
+
Sixteen bridge functions across eight pairs bypass the PDF pivot entirely. Five same-variant direct-copy pairs (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown`) compose a direct `readXContent` → `buildYPackage` pivot copy. Two cross-variant semantic-transform pairs (`docxToPptx`/`pptxToDocx`, `odtToOdp`/`odpToOdt`) go through `src/convert/variant-bridges.ts`. One PDF-composed pair (`xlsxToMarkdown`/`markdownToXlsx`) routes through PDF internally — the single lossiest conversion in the package.
|
|
112
127
|
|
|
113
128
|
```ts
|
|
114
129
|
import { odtToDocx, docxToOdt, markdownToDocx, docxToMarkdown } from 'documents.js';
|
|
@@ -117,12 +132,14 @@ const docxBytes = odtToDocx(odtBytes);
|
|
|
117
132
|
const odtBytes2 = docxToOdt(docxBytes);
|
|
118
133
|
|
|
119
134
|
const docxFromMarkdown = markdownToDocx(markdownBytes);
|
|
120
|
-
const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct -- dropped on this hop
|
|
135
|
+
const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct -- dropped on this hop
|
|
121
136
|
```
|
|
122
137
|
|
|
123
|
-
Each takes an optional `{ signal }` —
|
|
138
|
+
Each takes an optional `{ signal }` — no `onSubstitution`/`sink`, since there is no font substitution or PDF-parse degradation. `odtToDocx`/`markdownToDocx`/`docxToOdt`/`docxToMarkdown` additionally take `onMathDiagnostic`, called per formula construct that degraded crossing the bridge.
|
|
139
|
+
|
|
140
|
+
### The `DocumentConverter` port
|
|
124
141
|
|
|
125
|
-
The same conversions behind a swappable port, for a caller that wants to inject a different implementation
|
|
142
|
+
The same conversions behind a swappable port, for a caller that wants to inject a different implementation without changing call sites:
|
|
126
143
|
|
|
127
144
|
```ts
|
|
128
145
|
import { createLocalDocumentConverter } from 'documents.js';
|
|
@@ -134,7 +151,7 @@ const { document, diagnostics } = await converter.convert(
|
|
|
134
151
|
);
|
|
135
152
|
```
|
|
136
153
|
|
|
137
|
-
`DocumentFormat` includes `
|
|
154
|
+
`DocumentFormat` includes `docx`/`pptx`/`xlsx`/`odt`/`odp`/`ods`/`odg`/`odf`/`markdown`/`pdf` — ten members. The port's `conversions` list is derived from `resolveCompositionPlan` plus the `odf`→`pdf` special case — 73 pairs total. `DocumentFormat` is inferred from `DocumentFormatSchema` (a real Zod schema); `DOCUMENT_FORMATS` is exported as a plain array derived from the same schema:
|
|
138
155
|
|
|
139
156
|
```ts
|
|
140
157
|
import { DOCUMENT_FORMATS, DocumentFormatSchema } from 'documents.js';
|
|
@@ -143,7 +160,9 @@ console.log(DOCUMENT_FORMATS); // ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods',
|
|
|
143
160
|
DocumentFormatSchema.parse(userSuppliedFormat); // throws a ZodError for anything outside that list
|
|
144
161
|
```
|
|
145
162
|
|
|
146
|
-
|
|
163
|
+
### Intermediate `DocumentPackage`, JSON, and bytes
|
|
164
|
+
|
|
165
|
+
Every conversion function accepts an `onDocument` callback receiving the intermediate `DocumentPackage` (content + layout). The port surfaces the same value as `package` on `ConversionResult`. For PDF-bypassing bridges, `pkg.layout` is always `undefined`.
|
|
147
166
|
|
|
148
167
|
```ts
|
|
149
168
|
import { docxToPdf } from 'documents.js';
|
|
@@ -154,17 +173,9 @@ const pdfBytes = docxToPdf(docxBytes, {
|
|
|
154
173
|
console.log(pkg.layout?.pages.length); // populated for every X-to-PDF/PDF-to-X conversion
|
|
155
174
|
},
|
|
156
175
|
});
|
|
157
|
-
|
|
158
|
-
// or via the port:
|
|
159
|
-
const { document, package: pkg } = await converter.convert(
|
|
160
|
-
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
|
|
161
|
-
{ signal: new AbortController().signal },
|
|
162
|
-
);
|
|
163
176
|
```
|
|
164
177
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
Turning that `DocumentPackage` into self-describing JSON — re-exported from `document-schema.js`, which owns the pivot schemas and the published `.schema.json` files (see that package's own README) — via `documentPackageWithSchema`, which stamps a `$schema` property pointing at the matching schema file for the currently installed `document-schema.js` version, and reading one back via `documentFromJson`, which uses that same `$schema` property to work out which of `DocumentPackage`/`ContentDocument`/`LayoutDocument` a value is before validating it:
|
|
178
|
+
`documentPackageWithSchema`/`documentFromJson` turn a `DocumentPackage` into self-describing JSON and back (re-exported from `document-schema.js`):
|
|
168
179
|
|
|
169
180
|
```ts
|
|
170
181
|
import { documentFromJson, documentPackageWithSchema } from 'documents.js';
|
|
@@ -176,9 +187,7 @@ const { kind, value } = documentFromJson(JSON.parse(readFileSync('converted.doc.
|
|
|
176
187
|
// kind: 'DocumentPackage' (here) | 'ContentDocument' | 'LayoutDocument'
|
|
177
188
|
```
|
|
178
189
|
|
|
179
|
-
`
|
|
180
|
-
|
|
181
|
-
Building any `DocumentFormat`'s own bytes back out of an already-assembled `DocumentPackage`, instead of only ever getting one out of a conversion's own `onDocument` callback — `buildDocumentBytes` is the reverse of that callback: `'pdf'` writes the package's own `LayoutDocument` half directly (throwing if the package carries none — only a `<format>-to-pdf`/`pdf-to-<format>` conversion's own dump has one; a bridge conversion's own dump, e.g. `odtToDocx`, never does), `'odf'` (a standalone formula document) has no builder at all and throws outright, and every other target rebuilds a fresh package from the `ContentDocument` half through the identical `buildXPackage` function the matching `pdfToX`/bridge conversion already uses — xlsx included, via `ooxml.js`'s own `buildXlsxPackage`:
|
|
190
|
+
`buildDocumentBytes` rebuilds any `DocumentFormat`'s bytes from a `DocumentPackage` — `'pdf'` writes the `LayoutDocument` half directly (throwing if the package carries none), `'odf'` has no builder and throws, everything else rebuilds from the `ContentDocument` half:
|
|
182
191
|
|
|
183
192
|
```ts
|
|
184
193
|
import { buildDocumentBytes, docxToPdf } from 'documents.js';
|
|
@@ -186,36 +195,40 @@ import { buildDocumentBytes, docxToPdf } from 'documents.js';
|
|
|
186
195
|
let captured;
|
|
187
196
|
docxToPdf(docxBytes, { onDocument: (pkg) => { captured = pkg; } });
|
|
188
197
|
const pdfBytesAgain = buildDocumentBytes(captured, 'pdf');
|
|
189
|
-
const docxBytesAgain = buildDocumentBytes(captured, 'docx');
|
|
198
|
+
const docxBytesAgain = buildDocumentBytes(captured, 'docx');
|
|
190
199
|
```
|
|
191
200
|
|
|
192
|
-
|
|
201
|
+
### Package decode/encode, metadata, and deep imports
|
|
202
|
+
|
|
203
|
+
`decodeDocumentPackage`/`encodeDocumentPackage` dispatch docx/pptx/xlsx through `ooxml.js`'s OPC codec and odt/odp/ods/odg/odf through `odf.js`'s ODF codec, throwing `UnsupportedPackageFormatError` for `markdown`/`pdf`. `decodeOdbPackage` is the `.odb`-specific sibling (`.odb` is not a `DocumentFormat` member):
|
|
193
204
|
|
|
194
205
|
```ts
|
|
195
206
|
import { decodeDocumentPackage, decodeOdbPackage, encodeDocumentPackage } from 'documents.js';
|
|
196
207
|
|
|
197
|
-
const pkg = decodeDocumentPackage('docx', docxBytes);
|
|
208
|
+
const pkg = decodeDocumentPackage('docx', docxBytes);
|
|
198
209
|
const docxBytesAgain = encodeDocumentPackage('docx', pkg);
|
|
199
|
-
|
|
200
|
-
const odbPkg = decodeOdbPackage(odbBytes); // -> odf.js's own Package -- feed straight into readOdbTables/readOdbInventory/etc. below
|
|
210
|
+
const odbPkg = decodeOdbPackage(odbBytes);
|
|
201
211
|
```
|
|
202
212
|
|
|
203
|
-
|
|
213
|
+
`readDocumentMetadata`/`setDocumentMetadata` read or patch metadata across any `DocumentFormat`. `setDocumentMetadata` patches in place (source/target formats must match); `odf` is rejected in both directions. `readDocumentMetadata('xlsx', ...)` is a named exception: it renders via `xlsxToPdf` and reads the PDF's metadata, because a direct read and the PDF-preview path genuinely disagree on `createdIso`/`modifiedIso`/`producer`.
|
|
204
214
|
|
|
205
215
|
```ts
|
|
206
216
|
import { readDocumentMetadata, setDocumentMetadata } from 'documents.js';
|
|
207
217
|
|
|
208
|
-
const metadata = readDocumentMetadata('docx', docxBytes);
|
|
209
|
-
console.log(metadata.title, metadata.author);
|
|
210
|
-
|
|
218
|
+
const metadata = readDocumentMetadata('docx', docxBytes);
|
|
211
219
|
const patchedBytes = setDocumentMetadata('docx', 'docx', docxBytes, { title: 'New title', keywords: ['a', 'b'] });
|
|
212
220
|
```
|
|
213
221
|
|
|
214
|
-
|
|
222
|
+
Every module under `src/` is deep-importable by package-relative path:
|
|
215
223
|
|
|
216
|
-
|
|
224
|
+
```ts
|
|
225
|
+
import { emuToPt } from 'documents.js/model/units';
|
|
226
|
+
import { buildOdtPackage } from 'documents.js/edit/odt/content';
|
|
227
|
+
```
|
|
217
228
|
|
|
218
|
-
|
|
229
|
+
### Live-view editors
|
|
230
|
+
|
|
231
|
+
Read-and-write editors for docx/pptx/odt/odp/ods/odg content, holding a direct reference into the real `Package`/`XmlElement` objects. Saving is `encodePackage(pkg)` — everything you didn't touch stays byte-faithful.
|
|
219
232
|
|
|
220
233
|
```ts
|
|
221
234
|
import { openDocx, createDocx } from 'documents.js';
|
|
@@ -227,25 +240,21 @@ run.bold = true;
|
|
|
227
240
|
run.color = { r: 1, g: 0, b: 0 };
|
|
228
241
|
const bytes = editor.toBytes();
|
|
229
242
|
|
|
230
|
-
// or start from nothing:
|
|
231
243
|
const fresh = createDocx();
|
|
232
244
|
fresh.body.appendParagraph().appendRun({ text: 'New document' });
|
|
233
245
|
```
|
|
234
246
|
|
|
235
|
-
A docx's
|
|
247
|
+
A docx's comments, footnotes, headers/footers, and numbering definitions never fit `ContentDocument`'s section/block shape — `readDocxExtras` is a second, independent read returning exactly that data:
|
|
236
248
|
|
|
237
249
|
```ts
|
|
238
250
|
import { readDocxExtras } from 'documents.js';
|
|
239
251
|
import { decodePackage } from 'ooxml.js';
|
|
240
252
|
|
|
241
253
|
const { comments, footnotes, headers, footers, numbering } = readDocxExtras(decodePackage(docxBytes));
|
|
242
|
-
console.log(comments[0]?.author, comments[0]?.text, footnotes[0]?.text, headers[0], footers[0]);
|
|
243
254
|
console.log(Object.values(numbering)[0]?.levels['0']?.format); // numbering is keyed by numId, each level by its own level index
|
|
244
255
|
```
|
|
245
256
|
|
|
246
|
-
`openPptx`/`createPptx` and `PptxSlide`/`PptxShape` are the pptx equivalent
|
|
247
|
-
|
|
248
|
-
`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). A list item reads back as well as appends: `OdtListItem.paragraphs()` and `.nestedLists()` return live views on its own `text:p` children and any `text:list` nested inside it (the read counterparts to `appendParagraph`/`addNestedList`), and `.text` is those paragraphs newline-joined, matching `OdtTableCell.text`/`OdpShape.text`'s own convention — a nested list's text belongs to that list's own items, not to the item containing it, since ODF nests lists structurally rather than flagging membership per paragraph. `editor.body.appendFormula(formula, frame)` writes a real embedded formula: a whole nested ODF formula sub-document inside the same package, referenced from a `draw:frame`/`draw:object`, which is how ODF embeds a formula at all (see [Architecture](#architecture)'s `src/odf-package/` entry) — the odt counterpart to `DocxParagraph.appendOfficeMath`. `openOdp`/`createOdp` and `OdpSlide`/`OdpShape` are the odp equivalent of `PptxSlide`/`PptxShape` (`slide.addTextBox`, `slide.addImage`, `slide.notes`), and reuse `OdtParagraph`/`OdtRun`/`OdtList` directly for a shape's own text content — a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` model `office:text` does, interned into the same `content.xml` style registry:
|
|
257
|
+
`openPptx`/`createPptx` and `PptxSlide`/`PptxShape` are the pptx equivalent. `openOdt`/`createOdt` and `OdtParagraph`/`OdtRun`/`OdtTable`/`OdtList` are the odt equivalent, built on ODF's style-name-referencing model. `openOdp`/`createOdp` and `OdpSlide`/`OdpShape` reuse `OdtParagraph`/`OdtRun`/`OdtList` directly (a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` model):
|
|
249
258
|
|
|
250
259
|
```ts
|
|
251
260
|
import { createOdp } from 'documents.js';
|
|
@@ -253,7 +262,7 @@ import { createOdp } from 'documents.js';
|
|
|
253
262
|
const editor = createOdp();
|
|
254
263
|
const slide = editor.addSlide();
|
|
255
264
|
const title = slide.addTextBox({ frame: { xPt: 40, yPt: 30, widthPt: 640, heightPt: 80 }, text: 'Title' });
|
|
256
|
-
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter
|
|
265
|
+
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter
|
|
257
266
|
const bullets = slide.addTextBox({ frame: { xPt: 40, yPt: 130, widthPt: 300, heightPt: 200 }, text: '' });
|
|
258
267
|
bullets.paragraphs()[0].remove();
|
|
259
268
|
bullets.addList().addItem().appendParagraph({ text: 'A real bulleted text:list' });
|
|
@@ -261,7 +270,7 @@ slide.notes = 'Speaker notes for this slide';
|
|
|
261
270
|
const bytes = editor.toBytes();
|
|
262
271
|
```
|
|
263
272
|
|
|
264
|
-
`createOds`/`openOds` and `OdsEditor`/`OdsSheet`/`OdsCell` are the spreadsheet equivalent —
|
|
273
|
+
`createOds`/`openOds` and `OdsEditor`/`OdsSheet`/`OdsCell` are the spreadsheet equivalent — the one editor family built from scratch (cell addressing has no docx/pptx analogue). Setting a cell far from the origin splits `table:number-*-repeated` runs in place rather than materialising every cell in between:
|
|
265
274
|
|
|
266
275
|
```ts
|
|
267
276
|
import { createOds } from 'documents.js';
|
|
@@ -269,13 +278,13 @@ import { createOds } from 'documents.js';
|
|
|
269
278
|
const editor = createOds();
|
|
270
279
|
const sheet = editor.addSheet('Sheet1');
|
|
271
280
|
sheet.printSettings = { pageSize: { widthPt: 595, heightPt: 842 }, margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, gridlines: true, headers: true, pageOrder: 'downThenOver' };
|
|
272
|
-
sheet.cell(0, 0).value = { kind: 'string', value: 'Total' }; // 0-based (row, column)
|
|
281
|
+
sheet.cell(0, 0).value = { kind: 'string', value: 'Total' }; // 0-based (row, column)
|
|
273
282
|
sheet.cell(0, 1).value = { kind: 'currency', value: 42.5, currency: 'USD' };
|
|
274
283
|
sheet.cell(500, 50).value = { kind: 'boolean', value: true }; // does not materialise 500x50 empty cells
|
|
275
284
|
const bytes = editor.toBytes();
|
|
276
285
|
```
|
|
277
286
|
|
|
278
|
-
`createOdg`/`openOdg` and `OdgEditor`/`OdgPage` are the drawing equivalent
|
|
287
|
+
`createOdg`/`openOdg` and `OdgEditor`/`OdgPage` are the drawing equivalent. `OdgPage.addTextBox`/`.addImage` return `OdpShape` instances; `addRect`/`addEllipse`/`addLine`/`addPath` return vector classes writing real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path` elements:
|
|
279
288
|
|
|
280
289
|
```ts
|
|
281
290
|
import { createOdg } from 'documents.js';
|
|
@@ -293,9 +302,7 @@ page.addTextBox({ frame: { xPt: 20, yPt: 200, widthPt: 300, heightPt: 30 }, text
|
|
|
293
302
|
const bytes = editor.toBytes();
|
|
294
303
|
```
|
|
295
304
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
Reading and writing PDF bytes directly, without going through docx/pptx:
|
|
305
|
+
### PDF bytes and `z.codec()` pairs
|
|
299
306
|
|
|
300
307
|
```ts
|
|
301
308
|
import { readPdf, writePdf } from 'documents.js';
|
|
@@ -304,24 +311,21 @@ const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text
|
|
|
304
311
|
const bytes = writePdf(layout);
|
|
305
312
|
```
|
|
306
313
|
|
|
307
|
-
The
|
|
314
|
+
The nine PDF round trips and ten PDF-bypassing bridges are also available as schema-validated [`z.codec()`](https://zod.dev) pairs (`pdfCodec`, `docxPdfCodec`, `pptxPdfCodec`, `odtPdfCodec`, `odpPdfCodec`, `odsPdfCodec`, `odgPdfCodec`, `xlsxPdfCodec`, `markdownPdfCodec`, `odtDocxCodec`, `odpPptxCodec`, `odsXlsxCodec`, `markdownDocxCodec`, `markdownOdtCodec`) — the no-options form, adding automatic two-way schema validation:
|
|
308
315
|
|
|
309
316
|
```ts
|
|
310
317
|
import { z } from 'zod';
|
|
311
|
-
import { docxPdfCodec, pdfCodec
|
|
318
|
+
import { docxPdfCodec, pdfCodec } from 'documents.js';
|
|
312
319
|
|
|
313
320
|
const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes has no %PDF- header
|
|
314
321
|
const pdfBytes2 = z.encode(pdfCodec, layout);
|
|
315
|
-
|
|
316
322
|
const pdfFromDocx = z.decode(docxPdfCodec, docxBytes);
|
|
317
323
|
const docxBack = z.encode(docxPdfCodec, pdfFromDocx);
|
|
318
324
|
```
|
|
319
325
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
`readDocxContent`/`readPptxContent`/`readOdtContent`/`readOdpContent`/`readOdsContent`/`readOdgContent`/`readMarkdownContent` (docx/pptx/odt/odp/ods/odg/markdown → `ContentDocument`), `buildMarkdownText` (`ContentDocument` → markdown text, markdown's own write-side counterpart — `MarkdownEditor.toMarkdownText` (`src/edit/markdown/editor.ts`) calls it directly as its own save step rather than wrapping a byte-level writer, so this remains the whole write path even though markdown now has a live-view editor), `convertWordprocessingToLayout`/`convertPresentationToLayout`/`convertSpreadsheetToLayout`/`convertDrawingToLayout` (`ContentDocument` → `LayoutDocument`), and `reconstructWordprocessing`/`reconstructPresentation`/`reconstructSpreadsheet`/`reconstructDrawing` (`LayoutDocument` → `ContentDocument`) are each exported individually too, for a caller that wants one stage of the pipeline without the rest. `readDocxContent` and `readOdtContent` both produce the identical `wordprocessing`-variant `ContentDocument` shape from two completely unrelated package formats (OOXML and ODF), which is what lets `odtToPdf` feed `convertWordprocessingToLayout` without a single line of that engine changing; `readMarkdownContent` produces that identical shape too, from markdown-codec's own `readMarkdown`, making markdown the third format sharing this one pivot and layout engine — not just a second data point; `readPptxContent` and `readOdpContent` do the same for the `presentation` variant and `convertPresentationToLayout`. `readOdgContent`/`convertDrawingToLayout` has no OOXML-side counterpart at all (no drawing-equivalent OOXML format this package reads); `readOdsContent`/`convertSpreadsheetToLayout` now does have one on the read side — `ooxml.js`'s own `readXlsxContent` — but only for the PDF-bypassing `odsToXlsx`/`xlsxToOds` bridge below, not for the PDF pivot: xlsx has no PDF conversion of its own, so `convertSpreadsheetToLayout` still has no xlsx-layout counterpart to reuse or be reused by. Both `convertSpreadsheetToLayout` and `convertDrawingToLayout` are genuinely new layout algorithms, since a spreadsheet's addressed-grid-with-print-settings semantics and a drawing's vector-primitive vocabulary (rect/ellipse/line/path) have no flow/pagination or direct-placement analogue; `convertDrawingToLayout` does still reuse `convertPresentationToLayout`'s own shape-conversion logic (`convertShape`, exported from `src/layout/slides.ts`) verbatim for whatever text/image/table content a drawing page also carries. `reconstructDrawing` is `reconstructWordprocessing`/`reconstructPresentation`'s drawing-side counterpart, but does no baseline/paragraph clustering at all — a drawing has no semantic structure to recover, only a near-1:1 `LayoutItem` → `ContentVector`/`ContentShape` mapping to make, in the same paint order the items were recovered in. `reconstructSpreadsheet` is a genuinely different geometry-recovery problem from either: a real gridline lattice on the page (drawn by a printed sheet with gridlines enabled) is used DIRECTLY as cell boundaries when one is detected; absent one, text is clustered into a 2D grid from geometry alone. It recovers what was printed, not what was entered: every cell keeps its rendered string verbatim in `displayText`, and additionally gets a heuristically re-typed `value` (number/percentage/currency/date/boolean) wherever exactly one reading of that string is defensible — an explicitly probabilistic step, reported per cell through `ReconstructOptions.onCellTypeInference`, and never extended to claiming a formula (see [Fidelity](#fidelity)). `reconstructWordprocessing`/`reconstructPresentation` additionally recover a page's vector primitives and, gated strictly on a real drawn gridline lattice, a real table — see the [Gotchas](#gotchas-and-quirks) entries on each.
|
|
326
|
+
### Special-case conversions
|
|
323
327
|
|
|
324
|
-
|
|
328
|
+
**`odmToPdf`** — ODF master document → PDF. A `.odm` never carries its chapters' content (each `text:section` is an external `.odt` reference), so it requires a caller-supplied `resolveSubDocument` callback. Not wired into the `DocumentConverter` port (its contract is bytes-in/bytes-out):
|
|
325
329
|
|
|
326
330
|
```ts
|
|
327
331
|
import { readFileSync } from 'node:fs';
|
|
@@ -333,9 +337,7 @@ const chapterBytes = new Map([
|
|
|
333
337
|
]);
|
|
334
338
|
|
|
335
339
|
try {
|
|
336
|
-
const pdfBytes = odmToPdf(odmBytes, {
|
|
337
|
-
resolveSubDocument: (href) => chapterBytes.get(href),
|
|
338
|
-
});
|
|
340
|
+
const pdfBytes = odmToPdf(odmBytes, { resolveSubDocument: (href) => chapterBytes.get(href) });
|
|
339
341
|
} catch (error) {
|
|
340
342
|
if (error instanceof OdmUnresolvedSectionError) {
|
|
341
343
|
console.error('missing chapters:', error.hrefs); // every unresolved href, not just the first
|
|
@@ -343,126 +345,79 @@ try {
|
|
|
343
345
|
}
|
|
344
346
|
```
|
|
345
347
|
|
|
346
|
-
`
|
|
347
|
-
|
|
348
|
-
`.odb` (ODF database front-end) support: `readOdbTables` extracts every table an embedded database declares, and `odbToXlsx`/`odbToCsv` turn that straight into xlsx or CSV bytes. Every embedded storage shape LibreOffice's own two embedded engines can produce is supported, dispatched automatically from the package's own connection URL and, for HSQLDB, its own per-table storage shape and script format: a MEMORY/TEXT table's rows inline in `database/script` as ordinary TEXT-format SQL (Tier 1, `src/hsqldb/script.ts`), a CACHED table's rows in a separate binary page-cache file, `database/data` (Tier 2, `src/hsqldb/cache.ts`/`rowformat.ts` — LibreOffice's own embedded-HSQLDB default, see Architecture/Gotchas for the exact scope and version pinning), a Firebird database's own `database/firebird.fbk` part — LibreOffice's modern default embedded engine since 4.1, a genuine gbak logical-backup stream rather than a raw on-disk database file (Tier 3; see the Gotchas entry below for the empirical finding this rests on) — and HSQLDB's own whole-script BINARY (`hsqldb.script_format=1`) and COMPRESSED (`=3`) serialisations of `database/script` itself (Tier 4, `src/hsqldb/binary-script.ts`). A caller never needs to know which shape, engine, or script format a given `.odb` used:
|
|
348
|
+
**`.odb` database front-end** — `readOdbTables` extracts every table; `odbToXlsx`/`odbToCsv` produce xlsx or CSV. All four storage tiers are supported (HSQLDB TEXT-script Tier 1, HSQLDB CACHED binary Tier 2, Firebird gbak Tier 3, HSQLDB BINARY/COMPRESSED Tier 4), dispatched automatically:
|
|
349
349
|
|
|
350
350
|
```ts
|
|
351
351
|
import { decodePackage } from 'odf.js';
|
|
352
352
|
import { odbToCsv, odbToXlsx, readOdbTables } from 'documents.js';
|
|
353
353
|
|
|
354
|
-
const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table
|
|
355
|
-
const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); //
|
|
356
|
-
|
|
357
|
-
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
|
|
354
|
+
const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table
|
|
355
|
+
const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // required when the .odb has more than one table
|
|
356
|
+
const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[]
|
|
358
357
|
```
|
|
359
358
|
|
|
360
|
-
|
|
359
|
+
Form/Report *structure*: `readOdbForms`/`readOdbReports` read every declared component's static structure (bound controls, bands/groups/functions):
|
|
361
360
|
|
|
362
361
|
```ts
|
|
363
362
|
import { decodePackage } from 'odf.js';
|
|
364
363
|
import { readOdbForms, readOdbReports } from 'documents.js';
|
|
365
364
|
|
|
366
|
-
const forms = readOdbForms(decodePackage(odbBytes));
|
|
367
|
-
const reports = readOdbReports(decodePackage(odbBytes));
|
|
368
|
-
|
|
369
|
-
// A caller wanting exactly one named form/report can call odf.js's own readOdbForm/readOdbReport directly instead -- both are re-exported unmodified alongside the two convenience functions above.
|
|
365
|
+
const forms = readOdbForms(decodePackage(odbBytes));
|
|
366
|
+
const reports = readOdbReports(decodePackage(odbBytes));
|
|
370
367
|
```
|
|
371
368
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
`readOdbTables` takes a decoded `Package` (matching `readOdtContent`/`readOdsContent`/etc.'s own convention), while `odbToXlsx`/`odbToCsv` take raw bytes and decode them internally, matching every other ergonomic conversion in this package. `.odb` has no `odbToPdf` ergonomic conversion over the whole database and no reverse (xlsx/CSV → `.odb`) direction, and — like `odmToPdf` — is not wired into the `DocumentConverter` port below: the write direction would need a real embedded SQL engine this package deliberately does not implement, and `.odb` as a whole has no single natural target format, since a database front-end's tables, its saved queries, and its reports are three unrelated output shapes rather than one. A rendered *report* is a narrower, real exception to that: it is an ordinary wordprocessing `ContentDocument`, so `odbReportToDocx`/`odbReportToOdt`/`odbReportToPdf` (see below) dispatch it to real bytes the same one-call way every other ergonomic conversion in this package does.
|
|
375
|
-
|
|
376
|
-
`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:
|
|
369
|
+
`readFirebirdBackup` decodes a Firebird `.fbk` directly:
|
|
377
370
|
|
|
378
371
|
```ts
|
|
379
372
|
import { readFirebirdBackup } from 'documents.js';
|
|
380
|
-
|
|
381
|
-
const { summary, tables } = readFirebirdBackup(firebirdBackupBytes); // summary: backupFormatVersion/transportable/compressed/pageSizeBytes; tables: the same HsqldbTable[] shape
|
|
373
|
+
const { summary, tables } = readFirebirdBackup(firebirdBackupBytes);
|
|
382
374
|
```
|
|
383
375
|
|
|
384
|
-
|
|
376
|
+
**SQL `SELECT` engine** — `parseSelect`/`evaluateSelect` run a bounded single-table `SELECT` over `readOdbTables`' output. Closed allowlist grammar: column list or `*` or aggregates (`COUNT`/`SUM`/`AVG`/`MIN`/`MAX`), `FROM` one table, optional `WHERE`/`GROUP BY`/`ORDER BY`. Everything else throws `HsqldbSqlUnsupportedError`:
|
|
385
377
|
|
|
386
378
|
```ts
|
|
387
379
|
import { decodePackage, readOdbInventory } from 'odf.js';
|
|
388
380
|
import { evaluateSelect, parseSelect, readOdbTables } from 'documents.js';
|
|
389
381
|
|
|
390
382
|
const pkg = decodePackage(odbBytes);
|
|
391
|
-
const [query] = readOdbInventory(pkg).queries;
|
|
392
|
-
const { columns, rows } = evaluateSelect(parseSelect(query.command), readOdbTables(pkg));
|
|
393
|
-
|
|
394
|
-
// Or write the query yourself, against whatever readOdbTables returned:
|
|
395
|
-
const byRegion = evaluateSelect(parseSelect('SELECT REGION, COUNT(*), SUM(AMOUNT) FROM SALES GROUP BY REGION ORDER BY REGION ASC'), readOdbTables(pkg));
|
|
383
|
+
const [query] = readOdbInventory(pkg).queries;
|
|
384
|
+
const { columns, rows } = evaluateSelect(parseSelect(query.command), readOdbTables(pkg));
|
|
396
385
|
```
|
|
397
386
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
A Report's own bands go one step further than a query: each bound control carries an `rpt:formula` attribute, and a report declares nested groups whose break tests and per-group totals are written in that same little language. `runRptReport` (`src/odb/formula/`) evaluates it over the result set the query engine just produced, turning a report's static structure into the band instances a renderer would lay out — each carrying its own evaluated values:
|
|
387
|
+
**rpt formula engine** — `runRptReport` evaluates a report's group breaks and per-group totals. Closed allowlist: `rpt:HASCHANGED(X)`, `rpt:LEFT(X;n)` (semicolon separator), `rpt:SUM`/`COUNT`/`AVG`/`MIN`/`MAX`, and `field:[COLUMN]`. Everything else throws `RptFormulaUnsupportedError`:
|
|
401
388
|
|
|
402
389
|
```ts
|
|
403
390
|
import { decodePackage, readOdbInventory } from 'odf.js';
|
|
404
391
|
import { evaluateSelect, parseSelect, readOdbReports, readOdbTables, rptDefinitionFromReport, runRptReport } from 'documents.js';
|
|
405
392
|
|
|
406
393
|
const pkg = decodePackage(odbBytes);
|
|
407
|
-
const [report] = readOdbReports(pkg);
|
|
394
|
+
const [report] = readOdbReports(pkg);
|
|
408
395
|
const query = readOdbInventory(pkg).queries.find((candidate) => candidate.name === report.command);
|
|
409
396
|
const rows = evaluateSelect(parseSelect(query.command), readOdbTables(pkg));
|
|
410
|
-
|
|
411
397
|
const { bands } = runRptReport(rptDefinitionFromReport(report), rows);
|
|
412
|
-
// bands: one entry per printed band, in print order -- 'report-header', then per row the 'group-header's that open at it, the
|
|
413
|
-
// 'detail' band, and the 'group-footer's that close after it, then 'report-footer'. Each carries `values`, one evaluated
|
|
414
|
-
// ContentCellValue per band element (undefined for an element with no formula of its own, e.g. a fixed-content label).
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
The function set is a closed allowlist here too: `rpt:HASCHANGED(X)` (the group-break test — true when `X` differs from its value on the preceding row), `rpt:LEFT(X;n)` (note the **semicolon** separator, LibreOffice's own formula-language convention), and `rpt:SUM`/`COUNT`/`AVG`/`MIN`/`MAX`, plus the separate `field:[COLUMN]` bound-field form, which is a plain value passthrough rather than a computation. Every other rpt function — and Report Builder ships many — throws `RptFormulaUnsupportedError` naming it. `parseRptFormula` is exported too, for a caller that wants one formula's AST without running a report. See [Gotchas](#gotchas-and-quirks) for the group-scoping rule, which is the substance of this engine.
|
|
418
|
-
|
|
419
|
-
`readOdbReportContent` (`src/odb/report/`) is all of the above in one call — the report's data binding resolved, its query run, its formulas evaluated, and its printed bands rendered as a real `ContentDocument`:
|
|
420
|
-
|
|
421
|
-
```ts
|
|
422
|
-
import { decodePackage } from 'odf.js';
|
|
423
|
-
import { readOdbReportContent } from 'documents.js';
|
|
424
|
-
|
|
425
|
-
const document = readOdbReportContent(decodePackage(odbBytes)); // a 'wordprocessing' ContentDocument -- one section, one block per printed band
|
|
426
|
-
const another = readOdbReportContent(decodePackage(odbBytes), { report: 'SalesByRegion' }); // required whenever the .odb declares more than one
|
|
427
398
|
```
|
|
428
399
|
|
|
429
|
-
|
|
400
|
+
**Report rendering** — `readOdbReportContent` resolves data binding, runs the query, evaluates formulas, and renders bands as a real `ContentDocument`. `odbReportToDocx`/`odbReportToOdt`/`odbReportToPdf` dispatch it to bytes:
|
|
430
401
|
|
|
431
402
|
```ts
|
|
432
403
|
import { decodePackage } from 'odf.js';
|
|
433
404
|
import { odbReportToDocx, odbReportToOdt, odbReportToPdf, readOdbReportContent } from 'documents.js';
|
|
434
405
|
|
|
435
406
|
const report = readOdbReportContent(decodePackage(odbBytes), { report: 'SalesByRegion' });
|
|
436
|
-
const docxBytes = odbReportToDocx(report);
|
|
437
|
-
const
|
|
438
|
-
const pdfBytes = odbReportToPdf(report); // via convertWordprocessingToLayout + writePdf -- options are DocumentToPdfOptions verbatim, the same type docxToPdf/odtToPdf/markdownToPdf already use; throws if content is not the wordprocessing variant readOdbReportContent always produces
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
Resolving the report's own `rpt:command`/`rpt:command-type` binding is the one part the formula engine never saw: `"table"` means the command names a table and the report reads all of it (turned into a real `SELECT * FROM "<table>"` and run through the same engine, rather than a second resolution rule that could disagree with it), `"query"` means it names a saved query in the `.odb`'s own `db:queries` whose `db:command` holds the SQL, and `"command"` means the command *is* the SQL. Rows arrive in that command's own `ORDER BY` order, and the report's `rpt:sort-expression` is deliberately *not* applied on top — a group's sort expression is a bare column name, so re-sorting by it would discard whatever finer ordering the command already asked for (the real fixture's saved query orders `REGION`, `QUARTER`, then `AMOUNT` **descending**, and the two group sort expressions name only the first two).
|
|
442
|
-
|
|
443
|
-
Each printed band becomes one single-row `ContentTable`, one cell per control, in document order — the same shape the band has in the report file itself, where every band *is* a `table:table` whose cells hold its controls. Every cell's paragraph carries the band's own name as its `styleId` (`Report Header`, `Page Header`, `Group Header 1`, `Detail`, `Group Footer 1`, `Report Footer`, …), so which band a block printed from survives into the document rather than having to be inferred from its position. Its three stages stay independently usable like every other `.odb` stage: `odbReportCommandSql` (a report → the SQL it issues), `resolveOdbReportRows` (a package + a report → those rows), and `renderOdbReportContent` (a report + any equivalently-shaped rows → the document — useful for rendering the same report over an unfiltered table, say). See [Fidelity](#fidelity) for what "structural, not pixel-faithful" means here in detail.
|
|
444
|
-
|
|
445
|
-
A standalone `.odf` (an ODF formula document) converts to PDF via `odfToPdf`, rendering the formula's own real MathML through a hand-written typesetting engine (`src/mathml/`) and the embedded STIX Two Math font, not a static image or a StarMath-text placeholder. Its `onDocument` callback reports a real `'formula'`-kind `ContentDocument`, the same as every other conversion reports its own pivot:
|
|
446
|
-
|
|
447
|
-
```ts
|
|
448
|
-
import { odfToPdf } from 'documents.js';
|
|
449
|
-
|
|
450
|
-
const pdfBytes = odfToPdf(odfBytes); // a single formula (or small formula document), faithfully typeset -- see Fidelity
|
|
407
|
+
const docxBytes = odbReportToDocx(report);
|
|
408
|
+
const pdfBytes = odbReportToPdf(report);
|
|
451
409
|
```
|
|
452
410
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
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:
|
|
411
|
+
**`odfToPdf`** — standalone `.odf` formula document → PDF via the MathML typesetting engine. No reverse `pdfToOdf` (recovering structured MathML from rendered glyphs is OCR-adjacent). Formulas embedded inside odt/odp/ods render automatically through `odtToPdf`/`odpToPdf`/`odsToPdf`:
|
|
456
412
|
|
|
457
413
|
```ts
|
|
458
|
-
import { odtToPdf } from 'documents.js';
|
|
414
|
+
import { odtToPdf, odfToPdf } from 'documents.js';
|
|
459
415
|
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
const pdfBytes = odtToPdf(odtBytes);
|
|
416
|
+
const pdfBytes = odfToPdf(odfBytes); // a single formula, faithfully typeset
|
|
417
|
+
const pdfFromOdtWithFormula = odtToPdf(odtBytes); // embedded formulas render as real typeset MathML
|
|
463
418
|
```
|
|
464
419
|
|
|
465
|
-
|
|
420
|
+
A formula's MathML travels inside the `ContentDocument` as a `ContentEmbeddedObjectBlock` whose `document` is a `'formula'`-kind `ContentDocument`:
|
|
466
421
|
|
|
467
422
|
```ts
|
|
468
423
|
import { convertWordprocessingToLayout, formulaOfBlock, readOdtContent } from 'documents.js';
|
|
@@ -472,264 +427,180 @@ const block = document.sections[0].blocks.find((b) => b.kind === 'embeddedObject
|
|
|
472
427
|
formulaOfBlock(block); // -> { mathml, starMath? }, or undefined for a non-formula embedded object
|
|
473
428
|
|
|
474
429
|
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer });
|
|
475
|
-
const pdfBytes = writePdf(layout, { formulas: positioned });
|
|
430
|
+
const pdfBytes = writePdf(layout, { formulas: positioned });
|
|
476
431
|
```
|
|
477
432
|
|
|
478
|
-
`
|
|
479
|
-
|
|
480
|
-
`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:
|
|
433
|
+
`layoutFormula`/`loadMathFont` are exported for direct formula layout. `buildOfficeMath`/`buildOfficeMathParagraph` translate MathML into OMML for docx. `readOfficeMath`/`collectOfficeMathElements` are the read-side inverse:
|
|
481
434
|
|
|
482
435
|
```ts
|
|
483
|
-
import { layoutFormula, loadMathFont } from 'documents.js';
|
|
436
|
+
import { buildOfficeMathParagraph, layoutFormula, loadMathFont, openDocx } from 'documents.js';
|
|
484
437
|
|
|
485
438
|
const { metricsAt } = loadMathFont();
|
|
486
439
|
const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), sizePt: 12, color: { r: 0, g: 0, b: 0 } });
|
|
487
|
-
// box: a MathBox -- positioned glyph runs, fraction/radical rules, and radical-hook strokes, ready for pdf-codec's own math-content-write.ts
|
|
488
|
-
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see Fidelity
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
`buildOfficeMath`/`buildOfficeMathParagraph` are the write-side counterpart, translating the same MathML into real OMML (OOXML's own math markup) rather than into positioned glyphs — `buildDocxPackage` uses them for every embedded formula, and they are exported for a caller assembling OOXML math itself, e.g. into a docx opened through `openDocx`:
|
|
492
|
-
|
|
493
|
-
```ts
|
|
494
|
-
import { buildOfficeMathParagraph, openDocx } from 'documents.js';
|
|
495
440
|
|
|
496
441
|
const editor = openDocx(existingDocxBytes);
|
|
497
|
-
const { diagnostics } = editor.body.appendParagraph().appendOfficeMath(mathml);
|
|
498
|
-
// diagnostics: an 'unsupported-element' or 'approximated-element' entry per construct OMML has no faithful counterpart for -- see Gotchas
|
|
499
|
-
|
|
500
|
-
const { element } = buildOfficeMathParagraph(mathml); // or build the fragment directly, for a caller placing it itself
|
|
442
|
+
const { diagnostics: ommlDiagnostics } = editor.body.appendParagraph().appendOfficeMath(mathml);
|
|
501
443
|
```
|
|
502
444
|
|
|
503
|
-
`readOfficeMath`/`collectOfficeMathElements` are the read-side inverse — an OOXML equation back to real MathML. `readDocxContent` runs them over every paragraph itself (see [Architecture](#architecture)'s `src/omml/` entry), so an equation in a docx arrives as an ordinary formula-carrying `ContentEmbeddedObjectBlock` with no caller involvement; these are exported for a caller mining equations out of a docx directly:
|
|
504
|
-
|
|
505
|
-
```ts
|
|
506
|
-
import { collectOfficeMathElements, readOfficeMath } from 'documents.js';
|
|
507
|
-
|
|
508
|
-
for (const equation of collectOfficeMathElements(paragraphElement.children)) {
|
|
509
|
-
const { mathml, diagnostics } = readOfficeMath(equation);
|
|
510
|
-
// mathml: the children of a <math> root -- exactly what ContentFormula.mathml holds, and what layoutFormula above consumes
|
|
511
|
-
// diagnostics: an 'unsupported-element' or 'approximated-element' entry per OMML construct MathML has no faithful counterpart for -- see Gotchas
|
|
512
|
-
}
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
Every module under `src/` is also directly deep-importable by its package-relative path, without going through the barrel — useful for a caller that wants exactly one conversion function and nothing else pulled in:
|
|
516
|
-
|
|
517
|
-
```ts
|
|
518
|
-
import { emuToPt } from 'documents.js/model/units';
|
|
519
|
-
import { buildOdtPackage } from 'documents.js/edit/odt/content';
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
This works via a `"./*"` wildcard entry in `package.json`'s `exports` map, resolving any subpath to the correspondingly-named file under `dist/` — the same directory structure `src/` has, one output file per source module, so `src/edit/odt/content.ts` becomes `dist/edit/odt/content.js`/`.cjs`/`.d.ts`/`.d.cts`.
|
|
523
|
-
|
|
524
445
|
## Fonts
|
|
525
446
|
|
|
526
|
-
Every X → PDF conversion
|
|
447
|
+
Every X → PDF conversion resolves each typeface through a real `FontRegistry`, in this order:
|
|
527
448
|
|
|
528
|
-
1. **The source document's own embedded faces
|
|
529
|
-
2. **Faces the caller supplied** through `options.fonts
|
|
530
|
-
3. **pdf-codec's vendored Carlito and Caladea
|
|
531
|
-
4. **The standard 14
|
|
449
|
+
1. **The source document's own embedded faces** — docx (`w:embed*`, obfuscated per ECMA-376), pptx (`p:embeddedFontLst`, unobfuscated), ODF (`Fonts/` under `svg:font-face-uri`). Extracted automatically.
|
|
450
|
+
2. **Faces the caller supplied** through `options.fonts`.
|
|
451
|
+
3. **pdf-codec's vendored Carlito and Caladea** — metric-compatible with Calibri and Cambria.
|
|
452
|
+
4. **The standard 14** — last resort.
|
|
532
453
|
|
|
533
|
-
The same registry drives both
|
|
454
|
+
The same registry drives both the `TextMeasurer` (line breaking) and the writer (glyph emission) — measuring against one font's metrics and drawing through another would wrap text at wrong positions.
|
|
534
455
|
|
|
535
456
|
```ts
|
|
536
457
|
import { docxToPdf } from 'documents.js';
|
|
537
458
|
|
|
538
|
-
//
|
|
539
|
-
const pdfBytes = docxToPdf(docxBytes);
|
|
459
|
+
const pdfBytes = docxToPdf(docxBytes); // nothing to configure for embedded fonts
|
|
540
460
|
|
|
541
|
-
// A face for a family the document didn't embed, plus a report of anything that still fell back.
|
|
542
461
|
const withFallbackFace = docxToPdf(docxBytes, {
|
|
543
462
|
fonts: [{ family: 'Brand Sans', bold: false, italic: false, bytes: brandSansTtfBytes }],
|
|
544
463
|
onFontSubstitution: (substitution) => console.warn(substitution.requestedFamily, '->', substitution.resolvedFamily),
|
|
545
464
|
});
|
|
546
465
|
```
|
|
547
466
|
|
|
548
|
-
A document that embeds nothing and asks for no
|
|
549
|
-
|
|
550
|
-
Two honest limits, both structural rather than provisional. An embedded face is normally **subsetted** by the application that saved it, so it can legitimately lack a character this package synthesises rather than reads (a list bullet, `sheets.ts`'s `###` column-overflow marker); pdf-codec reports that per character through `onMissingGlyph` and falls back for that one character, never for the run or the document. And `odfToPdf` accepts both font options and consults neither — a standalone formula document emits no positioned text at all, only the embedded STIX Two Math font's own glyphs, which are not registry-resolvable.
|
|
551
|
-
|
|
552
|
-
`extractOoxmlEmbeddedFonts`/`extractOdfEmbeddedFonts`, `extractSourceFonts`, and `createDocumentFontRegistry` are exported for a caller composing `readXContent` → `convertXToLayout` → `writePdf` themselves rather than going through an ergonomic conversion.
|
|
553
|
-
|
|
554
|
-
`extractSourceFontsForFormat` is the `DocumentFormat`-aware counterpart to `extractSourceFonts` above, for a caller holding a format + bytes rather than an already-decoded `Package`: docx/pptx decode via `ooxml.js`'s own `decodePackage`, odt/odp/ods/odg via `odf.js`'s. `xlsx`, `pdf`, `markdown`, and `odf` (a standalone formula document, which embeds only the STIX Two Math font pdf-codec itself carries, never a caller-resolvable face) throw `UnsupportedFontSourceFormatError` — none of the four has a source-embedded-font concept of its own to extract:
|
|
555
|
-
|
|
556
|
-
```ts
|
|
557
|
-
import { extractSourceFontsForFormat } from 'documents.js';
|
|
558
|
-
|
|
559
|
-
const faces = extractSourceFontsForFormat('docx', docxBytes); // -> readonly ProvidedFont[], the same shape createDocumentFontRegistry consumes
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
`describeFontFace` is the standalone-file counterpart to `extractSourceFonts`/`extractSourceFontsForFormat` above: where those extract the faces a document already embeds, `describeFontFace` inspects an arbitrary standalone `.ttf`/`.otf` font file the caller holds and reports its `family`, `bold`, and `italic` — the same `FontFace` shape (owned by `document-schema.js`) `ProvidedFont` builds on. It is a re-export of pdf-codec's own `readFontFace`, throws `FontFaceParseError` (also re-exported) for bytes that are not a parseable sfnt font, and takes a `source` string used only in diagnostics:
|
|
467
|
+
A document that embeds nothing and asks for no vendored-substitute family writes byte-identical output to the old standard-14-only pipeline. Two structural limits: an embedded face is normally subsetted, so it can legitimately lack a synthesised character (list bullet, `###` overflow marker) — resolved per character via `onMissingGlyph`. And `odfToPdf` accepts font options but consults neither — a standalone formula emits only the embedded STIX Two Math font's glyphs. `extractSourceFonts`/`extractSourceFontsForFormat`/`createDocumentFontRegistry` are exported for callers composing the pipeline manually. `describeFontFace` inspects a standalone `.ttf`/`.otf` file.
|
|
563
468
|
|
|
564
469
|
```ts
|
|
565
|
-
import { describeFontFace } from 'documents.js';
|
|
470
|
+
import { describeFontFace, extractSourceFontsForFormat } from 'documents.js';
|
|
566
471
|
|
|
567
|
-
const
|
|
472
|
+
const faces = extractSourceFontsForFormat('docx', docxBytes); // -> readonly ProvidedFont[]
|
|
473
|
+
const { family, bold, italic } = describeFontFace(fontBytes, 'BrandSans-Regular.ttf');
|
|
568
474
|
```
|
|
569
475
|
|
|
570
476
|
## Architecture
|
|
571
477
|
|
|
572
478
|
The package is layered from generic primitives outward to the two conversion directions:
|
|
573
479
|
|
|
574
|
-
- **`src/model/`** — thin
|
|
575
|
-
-
|
|
576
|
-
- **`src/ports/`** —
|
|
577
|
-
- **`src/xml/`** and **`src/opc/`** — parent-aware XML query/mutation and OPC package mechanics
|
|
578
|
-
- **`src/odf-package/`** —
|
|
579
|
-
- **`src/edit/`** — the read-and-write editable model: live-view classes
|
|
580
|
-
- **`src/fonts/`** — source-embedded font extraction
|
|
581
|
-
- **`src/mathml/`** — a MathML presentation-layer typesetting engine
|
|
582
|
-
- **`src/omml/`** — the MathML ⇄ OMML
|
|
583
|
-
- **`src/ooxml/`** —
|
|
584
|
-
- **`src/odf/`** —
|
|
585
|
-
- **`src/markdown/`** —
|
|
586
|
-
- **`src/layout/`** — the pure conversion algorithms
|
|
587
|
-
- **`src/hsqldb/`** —
|
|
588
|
-
- **`src/firebird/`** —
|
|
589
|
-
- **`src/odb/`** —
|
|
590
|
-
|
|
591
|
-
- **`src/
|
|
592
|
-
- **`src/
|
|
593
|
-
- **`src/
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
- **`src/codecs/`** — `registry.ts`'s `DOCUMENT_FORMAT_CODECS`, every `DocumentFormat`'s own read/build capability expressed as data (a `ContentCodec`/`LayoutCodec` pair per format, both types imported from `document-schema.js`) rather than three independent per-function switch statements re-deriving the same "given a format, which reader/builder do I call" dispatch. A format's `content` entry wraps the identical `readXContent`/`buildXPackage` pair every ergonomic conversion in this package already uses for it (via `decodeDocumentPackage`/`encodeDocumentPackage`, below, for the raw-package half); `pdf`'s `layout` entry wraps `readPdf`/`writePdf` directly. xlsx has a real `content` entry too, wrapping `ooxml.js`'s own `readXlsxContent`/`buildXlsxPackage` — this does not contradict this README's own "not re-exported from this package's public surface" statement elsewhere (that is about `src/index.ts`'s own export list, still true: neither name is exported from there), only that this internal registry may call them directly. `odf` (a standalone formula document) has `content.read` but no `content.write`, since `odf.js` has no write path for a formula document at all. `readDocumentMetadata`/`setDocumentMetadata` (`src/metadata/`, below) and `buildDocumentBytes` (`src/convert/from-package.ts`, above) all dispatch through this one registry rather than each maintaining its own per-format switch — this is what let `setDocumentMetadata`/`buildDocumentBytes` stop rejecting xlsx once the registry gained a real xlsx codec, with no change needed at either call site beyond removing the special case.
|
|
597
|
-
- **`src/metadata/`** — cross-format metadata read/write, both dispatched through `DOCUMENT_FORMAT_CODECS` (above) rather than a hand-written per-format switch. `read.ts`'s `readDocumentMetadata` resolves a `LayoutMetadata` for any of the ten `DocumentFormat`s, with one deliberately-kept named exception: xlsx does **not** dispatch through the registry's own `content` codec at all, instead rendering through `xlsxToPdf` and reading the resulting PDF's own metadata, because a direct `readXlsxContent(...).metadata` and that PDF-preview path disagree on real fields (`createdIso`/`modifiedIso`/`producer`) — confirmed directly rather than assumed (`read.test.ts`'s own xlsx case), so switching xlsx onto the uniform path here would silently change what this function reports. `write.ts`'s `setDocumentMetadata` patches `title`/`author`/`subject`/`keywords` in place without converting format: a `pdf` source/target patches the parsed `LayoutDocument` directly, and every other `REBUILD_FORMATS` member (`docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`markdown`/`xlsx`) rebuilds a fresh package from that format's own `ContentDocument` via the registry's `content` codec — xlsx joined this set once the registry gained a real xlsx codec (`src/codecs/registry.ts`), so it is no longer rejected the way it once was; `odf` is still rejected outright in both directions (no write path back out at all).
|
|
598
|
-
- **`src/package-codec.ts`** — `decodeDocumentPackage`/`encodeDocumentPackage`/`decodeOdbPackage` (Usage above), the format-aware counterpart to `ooxml.js`'s/`odf.js`'s own `decodePackage`/`encodePackage`. Dispatches docx/pptx/xlsx through `ooxml.js`'s OPC codec and odt/odp/ods/odg/odf through `odf.js`'s ODF codec by a plain format-membership lookup, throwing `UnsupportedPackageFormatError` (a named class, matching this package's own "recognised but unsupported" convention — `OdbUnsupportedFormatError`, `UnsupportedFontSourceFormatError`) for `markdown`/`pdf`, neither of which has a raw-package concept at all. `decodeOdbPackage` decodes `.odb` bytes through the identical `odf.js` `decodePackage` regardless — `.odb` is at the raw-zip-container level an ordinary ODF package — but is deliberately kept out of `decodeDocumentPackage`'s own `DocumentFormat`-keyed dispatch, since `'odb'` is not, and cannot be, a `DocumentFormat` member (see the `.odb` Architecture/Gotchas entries below); there is no `encodeOdbPackage`, since nothing in this package's `.odb` support ever writes a new `.odb` file.
|
|
599
|
-
|
|
600
|
-
Dependency direction among this package's own local modules is downward and checkable, with one deliberate exception (`layout`, noted below): `mathml`/`ports` import nothing local (`mathml` is fully self-contained — no dependency on `model`, `document-schema.js`, or any ODF package, since it consumes only its own locally-mirrored `MathMlNode` input and its own injected `MathFontMetrics` port); `model` imports nothing local at all any more — `formula.ts`'s former type-only `MathMlNode` import from `mathml` is gone with the local `EmbeddedFormula` type it served, since document-schema.js now owns a fully-specified `MathMlNode` of its own; `ooxml/*` imports no local module at all (now a thin adapter over `ooxml.js`'s own `readDocx`/`readPptx` — see the `src/ooxml/` entry above — with no `model`/`xml/*` dependency of its own left, since `ContentDocument`/`CONTENT_FORMAT_VERSION` now come straight from `document-schema.js`; no PDF knowledge either); `odf/*` imports `model` only, and only for `formula.ts`'s block/document builders and `geometry.ts`'s `Box`/`PAGE_SIZE_A4` (its own `ContentDocument`/`CONTENT_FORMAT_VERSION` usage is `document-schema.js`-direct too now — no PDF knowledge, no `xml/*` — `odf.js` already owns its own XML query helpers); `markdown` imports `model` only, and only for `formula.ts`'s stand-in text on the write side (`write.ts` flattens a formula block markdown cannot represent), plus the external `markdown-codec` dependency directly (no PDF knowledge, no odf.js/ooxml.js knowledge at all — the one adapter package in this family whose source format is not a zip archive); `omml` imports `mathml` (its node helpers, operator dictionary, `mathvariant` type, and length parser) and `xml/*` (`fragment.ts`'s `el`/`txt`, `entities.ts`'s `encodeXmlText`) only, plus `ooxml.js` for its own `XmlElement` output type — never `model`, `layout`, or any ODF package, and never in the other direction: `mathml` still imports nothing local at all, which is exactly why this translator is a sibling of it rather than a file inside it; `hsqldb` imports `document-schema.js` only (no odf.js knowledge); `firebird` imports `document-schema.js` (its own row/schema decoding, `ContentCellValue` only) and `hsqldb` (`HsqldbTable`/`HsqldbColumn`, a type-only import for its own output shape — the deliberate pivot-sharing point between Tier 1 and Tier 3) but no odf.js knowledge at all; `layout` imports `model`+`mathml`+`ports`, plus port contracts from `document-schema.js` (`TextMeasurer`, `StyledRun`/`WrappedLine`/etc., `MathFontMetrics`/`MathBox`) and byte/image utilities from `byte-codec` (`crc32`, `decodePng`, `readJpegInfo`), with only two deliberately PDF-read-natured residuals reaching into `pdf-codec` directly (`resolveStandardFont`/`STANDARD_METRICS` in `reconstruct.ts` — see the `src/layout/` entry above for exactly which); `odf-package` imports odf.js only (no local dependency, mirroring `opc`'s relationship to `ooxml.js`); `fonts` imports no local module at all either — only `ooxml.js`/`odf.js` for the two package shapes it reads and `pdf-codec` for the `ProvidedFont`/`FontRegistry` shapes it produces, so it sits beside `layout` rather than under it despite both feeding the same conversion; `odb` imports `hsqldb`+`firebird`+`model`+`odf-package`+odf.js only, and its own `odb/sql` and `odb/formula` subtrees import strictly less than that — `odb/values.ts` plus `document-schema.js`'s `ContentCellValue` plus `hsqldb`'s `HsqldbTable` type for the former, and `odb/values.ts` plus `ContentCellValue` plus `odb/sql`'s `SqlResultSet` type for the latter, with odf.js reaching `odb/formula` only through its one `definition.ts` adapter; `odb/report` is the one subtree that imports *more* than `odb` itself rather than less, since rendering is where the two halves finally meet — `odb/sql`, `odb/formula`, `odb/read.ts`, `hsqldb`'s `displayTextFor`, `model`'s `PAGE_SIZE_A4`, `document-schema.js`'s content vocabulary, and odf.js's `OdbReport` shape — and it still keeps each of those to one module: `Package` reaches only `source.ts`/`content.ts`, and `ContentDocument` only `render.ts`; `convert` composes everything else, including `fonts` and `pdf-codec` directly for `readPdf`/`writePdf`/`loadMathFont`/`createFontMeasurer`/`createFontRegistry` and `markdown-codec` indirectly via `markdown/read.ts`/`markdown/write.ts`/`markdown/text.ts`. Beyond this package's own local modules, six external dependencies each own a distinct concern with no overlap: `ooxml.js` (docx/pptx/xlsx ⇄ JSON), `odf.js` (odt/ods/odp/odg ⇄ JSON), `document-schema.js` (the shared `ContentDocument`/`LayoutDocument` schemas AND the port contracts — `TextMeasurer`, `ProvidedFont`/`FontSubstitution`, the `MathBox`/`MathFontMetrics` family), `pdf-codec` (the PDF codec itself, plus the text-layout/font-resolution primitives built on it), `byte-codec` (generic byte/image utilities — ByteWriter, CRC-32, deflate/inflate, PNG/JPEG encode/decode), and `markdown-codec` (CommonMark+GFM ⇄ `ContentDocument`). No `PdfObject`/`PdfDict`/`PdfStream` type appears anywhere in this package at all — that type is pdf-codec's own internal concern now, never exposed across the package boundary.
|
|
480
|
+
- **`src/model/`** — thin additions on top of `document-schema.js`, which owns the two pivot models (`LayoutDocument`, `ContentDocument`) imported, not defined here. Local: `bytes.ts` (magic-byte schemas), `units.ts` (EMU/twip/point conversions), `geometry.ts`/`color.ts`/`style.ts` (thin re-exports plus PDF-specific `flipY`), `paint-order.ts` (merges drawing page `shapes`/`vectors` by `paintOrder`), `formula.ts` (helpers around `ContentFormula`), `embedded-drawing.ts` (packages recovered vectors as a `ContentEmbeddedObjectBlock`).
|
|
481
|
+
- **`pdf-codec`** (external) — the hand-written PDF codec, plus generic byte/image primitives (now in `byte-codec`). See that package's own README.
|
|
482
|
+
- **`src/ports/`** — injectable ports: `throwIfAborted` (signal check at long-loop boundaries) and `ClockPort`/`systemClock`/`fixedClock` (injectable "now" for deterministic output — exported but not yet consumed by any conversion path).
|
|
483
|
+
- **`src/xml/`** and **`src/opc/`** — parent-aware XML query/mutation and OPC package mechanics over `ooxml.js`'s `Package`/`XmlNode`. `src/xml/odf-text.ts` holds `encodeOdfText`/`decodeOdfText` — see the ODF text gotcha below.
|
|
484
|
+
- **`src/odf-package/`** — ODF-side counterpart to `src/opc/`: manifest sync, media insertion (`addImageMedia`), and embedded formula sub-documents (`addFormulaObject`).
|
|
485
|
+
- **`src/edit/`** — the read-and-write editable model: live-view classes for all six editable formats, plus `buildXPackage` functions bridging `ContentDocument` to fresh packages. Key reuse patterns: `src/edit/odp/*` reuses `src/edit/odt/*` wholesale (identical `text:p`/`text:span` model); `src/edit/odg/*` reuses `OdpShape` for `draw:frame` content; `src/edit/drawingml/vector.ts` is the shared OOXML vector writer for docx and pptx; `src/edit/odg/vector.ts` is the shared ODF vector writer for odt/odp/odg. `src/edit/ods/*` is built from scratch (cell addressing) but reuses odt's style interning.
|
|
486
|
+
- **`src/fonts/`** — source-embedded font extraction (`obfuscation.ts` implements ECMA-376 Part 4, 2.8.1; `ooxml.ts`/`odf.ts` resolve font references) and `registry.ts`'s `createDocumentFontRegistry` composing the precedence chain as data.
|
|
487
|
+
- **`src/mathml/`** — a self-contained MathML presentation-layer typesetting engine (no import from `model`, `pdf-codec`, or `odf.js`; consumes only port contracts from `document-schema.js` and its own locally-mirrored `MathMlNode`). Covers `mrow`/`mi`/`mn`/`mo`/`mtext`/`mspace`/`msub`/`msup`/`msubsup`/`munder`/`mover`/`munderover`/`mfrac`/`msqrt`/`mroot`/`mtable`/`mtr`/`mtd`/`mstyle`/`semantics`, driven by the injected `MathFontMetrics` port. Stretches vertical fences and horizontal braces via the font's `MathVariants` data.
|
|
488
|
+
- **`src/omml/`** — the MathML ⇄ OMML structural translator, both directions. `write.ts` covers the identical construct set `src/mathml/layout.ts` typesets; `read.ts` covers strictly more (reads what Word authored, not just what this package writes). Lives outside `src/mathml/` because its I/O type is `ooxml.js`'s `XmlElement` and `src/mathml/` imports no package.
|
|
489
|
+
- **`src/ooxml/`** — thin adapters over `ooxml.js`'s own `readDocx`/`readPptx`, wrapping results into `ContentDocument`. `docx/formula.ts` is the one local reading pass (splicing OOXML math equations). `docx/extras.ts`'s `readDocxExtras` returns comments/footnotes/headers/footers/numbering.
|
|
490
|
+
- **`src/odf/`** — ODF-side counterparts: `readOdtContent`/`readOdpContent`/`readOdsContent`/`readOdgContent` are thin adapters over `odf.js`. `formula/read.ts`/`formula/detect.ts` handle embedded formula detection (genuinely new work with no `odf.js`-side equivalent).
|
|
491
|
+
- **`src/markdown/`** — third adapter family, via `markdown-codec`. `readMarkdownContent` passes `readMarkdown`'s result straight through (it already produces a full `ContentDocument`). `buildMarkdownText` wraps `writeMarkdown`. `text.ts` is the byte↔text boundary. `MarkdownEditor` holds a mutable in-memory `ContentDocument`.
|
|
492
|
+
- **`src/layout/`** — the pure conversion algorithms: `engine.ts` (wordprocessing → layout: flow, line-breaking, pagination), `slides.ts` (presentation → layout: direct placement), `sheets.ts` (spreadsheet → layout: grid, print settings, the first algorithm accepting `AbortSignal`), `drawing.ts` (drawing → layout: vector primitives + shape reuse), `reconstruct.ts` (layout → content: baseline clustering for wordprocessing/presentation, near-1:1 mapping for drawing, gridline-lattice-or-text-clustering for spreadsheet).
|
|
493
|
+
- **`src/hsqldb/`** — `.odb` decoders, four tiers: `script.ts` (TEXT-script DDL/DML parser), `rowformat.ts`/`cache.ts` (CACHED binary row-store), `binary-script.ts` (BINARY/COMPRESSED whole-script). All import only `document-schema.js` — no odf.js knowledge.
|
|
494
|
+
- **`src/firebird/`** — Tier 3: gbak logical-backup reader. `reader.ts` (attribute framing + RLE decompression + XDR decoding), `schema.ts`/`data.ts` (table/row walking). No ratified spec — built against Firebird's own engine source.
|
|
495
|
+
- **`src/odb/`** — decoder-selection and pivot-mapping: `read.ts` routes to the right tier, `spreadsheet.ts`/`csv.ts` map to output formats. `odb/sql/` is the bounded SQL engine, `odb/formula/` is the rpt formula engine, `odb/report/` is the renderer, `odb/values.ts` is shared comparison/aggregation semantics.
|
|
496
|
+
- **`src/convert/`** — the composition layer: `convert.ts` (all named functions + `convertDocument` + `resolveCompositionPlan`), `composition.ts` (the pathfinder and primitive registry), `codec.ts` (`z.codec()` pairs), `port.ts`/`local.ts` (the `DocumentConverter` port), `variant-bridges.ts` (cross-variant semantic transforms), `from-package.ts` (`buildDocumentBytes`).
|
|
497
|
+
- **`src/codecs/`** — `DOCUMENT_FORMAT_CODECS`: every format's read/build capability as data, so `readDocumentMetadata`/`setDocumentMetadata`/`buildDocumentBytes` dispatch through one registry.
|
|
498
|
+
- **`src/metadata/`** — cross-format metadata read/write via `DOCUMENT_FORMAT_CODECS`.
|
|
499
|
+
- **`src/package-codec.ts`** — `decodeDocumentPackage`/`encodeDocumentPackage`/`decodeOdbPackage`.
|
|
500
|
+
|
|
501
|
+
Dependency direction is downward and checkable. Six external dependencies each own a distinct concern: `ooxml.js` (docx/pptx/xlsx), `odf.js` (odt/ods/odp/odg), `document-schema.js` (shared schemas + port contracts), `pdf-codec` (PDF codec + text-layout/font primitives), `byte-codec` (byte/image utilities), `markdown-codec` (markdown). No `PdfObject`/`PdfDict`/`PdfStream` type appears anywhere in this package.
|
|
601
502
|
|
|
602
503
|
## Build, test, and lint
|
|
603
504
|
|
|
604
505
|
```sh
|
|
605
506
|
pnpm build # turbo run _build (tsdown -> dist/ (ESM + CJS + .d.ts))
|
|
606
|
-
pnpm typecheck # turbo run _typecheck _typecheck:node
|
|
507
|
+
pnpm typecheck # turbo run _typecheck _typecheck:node
|
|
607
508
|
pnpm lint # turbo run _lint (eslint . --fix --cache --max-warnings 0)
|
|
608
509
|
pnpm test # turbo run _test (vitest run --project unit)
|
|
609
|
-
pnpm test:workers # turbo run _test:workers (vitest run --config vitest.workers.config.ts --
|
|
510
|
+
pnpm test:workers # turbo run _test:workers (vitest run --config vitest.workers.config.ts -- Cloudflare Workers runtime)
|
|
610
511
|
pnpm test:watch # vitest --project unit
|
|
611
|
-
pnpm test:smoke # turbo run _test:smoke (rebuilds dist/,
|
|
512
|
+
pnpm test:smoke # turbo run _test:smoke (rebuilds dist/, verifies ESM/CJS parity, real round trips across all conversions, font resolution, from the built CJS bundle)
|
|
612
513
|
```
|
|
613
514
|
|
|
614
|
-
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.
|
|
615
|
-
|
|
616
515
|
To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
617
516
|
|
|
618
517
|
## Conventions
|
|
619
518
|
|
|
620
|
-
- **Zod-first schema/type/guard**, matching `ooxml.js`: every model type is inferred from its Zod schema
|
|
621
|
-
- **`z.codec()` for every schema-to-schema round trip
|
|
622
|
-
- **`PdfObject` has no Zod schema
|
|
623
|
-
- **No type assertions anywhere.** Every
|
|
624
|
-
- **Live views, not flatten-and-regenerate.**
|
|
625
|
-
- **
|
|
626
|
-
- **Conventional commits**, enforced via commitlint + husky
|
|
627
|
-
- **Worker-isomorphic runtime.**
|
|
519
|
+
- **Zod-first schema/type/guard**, matching `ooxml.js`: every model type is inferred from its Zod schema. `ContentBlock` (recursive) uses a hand-written structural guard + `z.custom`, not `z.lazy`.
|
|
520
|
+
- **`z.codec()` for every schema-to-schema round trip** — the no-options form; named functions remain the entry points for `signal`/`sink`/`onSubstitution`.
|
|
521
|
+
- **`PdfObject` has no Zod schema** — it never crosses a public boundary; narrows on its own `kind` discriminant.
|
|
522
|
+
- **No type assertions anywhere.** Every loosely-typed value is narrowed through a type guard or Zod parse at the boundary.
|
|
523
|
+
- **Live views, not flatten-and-regenerate.** Editor classes hold a reference into the real `Package`/`XmlElement` objects; saving is `encodePackage(pkg)`.
|
|
524
|
+
- **Three-tier PDF-read failure policy** — throw for unprocessable files, recover-with-diagnostic for malformed-but-salvageable, degrade-with-diagnostic for unsupported features. See pdf-codec's README.
|
|
525
|
+
- **Conventional commits**, enforced via commitlint + husky.
|
|
526
|
+
- **Worker-isomorphic runtime.** `src/` is typechecked against a web-only environment (`lib: ["ES2024", "WebWorker"]`, no `@types/node`); `eslint` bans Node-only imports/globals; `test:workers` proves PDF-bypassing paths run in `workerd`.
|
|
628
527
|
|
|
629
528
|
## Gotchas and quirks
|
|
630
529
|
|
|
631
|
-
- **`ooxml.js`'s typed readers
|
|
632
|
-
- **ODF
|
|
633
|
-
- **
|
|
634
|
-
- **A `DocumentPackage`
|
|
635
|
-
- **
|
|
636
|
-
-
|
|
637
|
-
-
|
|
638
|
-
- **`
|
|
639
|
-
-
|
|
640
|
-
-
|
|
641
|
-
- **`
|
|
642
|
-
- **`
|
|
643
|
-
-
|
|
644
|
-
-
|
|
645
|
-
- **`
|
|
646
|
-
-
|
|
647
|
-
-
|
|
648
|
-
- **`
|
|
649
|
-
-
|
|
650
|
-
-
|
|
651
|
-
- **A vector
|
|
652
|
-
-
|
|
653
|
-
- **
|
|
654
|
-
-
|
|
655
|
-
- **
|
|
656
|
-
- **
|
|
657
|
-
-
|
|
658
|
-
- **
|
|
659
|
-
- **
|
|
660
|
-
- **
|
|
661
|
-
- **
|
|
662
|
-
-
|
|
663
|
-
-
|
|
664
|
-
- **
|
|
665
|
-
- **
|
|
666
|
-
- **
|
|
667
|
-
-
|
|
668
|
-
-
|
|
669
|
-
- **
|
|
670
|
-
- **
|
|
671
|
-
- **The
|
|
672
|
-
- **
|
|
673
|
-
- **
|
|
674
|
-
- **
|
|
675
|
-
-
|
|
676
|
-
- **
|
|
677
|
-
- **
|
|
678
|
-
- **
|
|
679
|
-
- **
|
|
680
|
-
- **
|
|
681
|
-
-
|
|
682
|
-
- **
|
|
683
|
-
- **
|
|
684
|
-
- **
|
|
685
|
-
- **
|
|
686
|
-
-
|
|
687
|
-
- **
|
|
688
|
-
- **
|
|
689
|
-
-
|
|
690
|
-
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
- **
|
|
694
|
-
- **
|
|
695
|
-
-
|
|
696
|
-
-
|
|
697
|
-
- **
|
|
698
|
-
- **`
|
|
699
|
-
-
|
|
700
|
-
- **
|
|
701
|
-
- **`convertSpreadsheetToLayout` returns `{ document, formulas }`, not a bare `LayoutDocument`** — the same `SpreadsheetLayoutResult` shape `convertWordprocessingToLayout`/`convertPresentationToLayout` have always returned, for the same reason: a formula's CID-font glyph runs cannot travel through `LayoutDocument.pages[].items` at all (see the Gotchas entry on why), so they come back alongside the document and are handed to `writePdf({ formulas })`. `odsToPdf` threads them through exactly as `odtToPdf`/`odpToPdf` already did. A caller of the exported `convertSpreadsheetToLayout` reads `.document` where it previously used the return value directly. `convertDrawingToLayout` still returns a bare `LayoutDocument`, since `readOdgContent` runs no formula detection and a drawing page consequently never carries a formula block.
|
|
702
|
-
- **The formula-size fit (`formulaSizePtForFrame`, `src/layout/shared.ts`) is now one shared two-pass function rather than three copies of a height/2 heuristic**, consumed identically by `engine.ts` (flow placement), `slides.ts` (shape placement), and `sheets.ts` (cell-anchored placement): it lays the formula out once at a reference size to measure its natural width and height, then rescales by the frame's own declared width and height so the laid-out box fits both (whichever is the binding constraint), floored at 8pt. `layoutFormula`'s output scales linearly in `sizePt`, so a single rescale reaches the fit with no iteration — the height/2 heuristic this replaced overflowed a genuinely stacked formula (a fraction inside a radical is taller than twice its base font size), rendering it larger than the frame the source document drew it at. A docx OMML equation carries no geometry of its own (`equationFrame` synthesises `widthPt: 0`), so for docx the width contributes no constraint and height alone drives the size — the old heuristic's intent — while ODF-sourced formulas (which carry a real `draw:frame` width+height) get the full two-dimensional fit.
|
|
703
|
-
|
|
704
|
-
- **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 placement is now derived from the exact walk `odf.js` itself used, rather than approximated.** For **odt** (`src/odf/odt/read.ts`): a formula frame is found wherever it actually is — a direct child of `office:text`, one nested inside a `draw:g` group, one anchored inline inside a paragraph's own run content (`text:anchor-type="as-char"`, the shape LibreOffice writes for a formula typed into a sentence), and one inside a list item's own paragraph. Each block lands at its **true position** among the paragraphs/tables `odf.js` already read, because this adapter mirrors `readOdt`'s own `readBlocks` walk to *count* how many `ContentBlock`s each `office:text` child contributes — the per-element bookkeeping that was previously missing and forced every formula to be appended at the end (a `text:list` unwraps into one `ContentParagraph` per item at every nesting level, so "one raw child = one block" does not hold, which is exactly why counting rather than indexing is required). Two bounded, honest details remain: an *inline* formula's block is placed immediately **after** the paragraph containing it rather than truly inside it (`ContentRun` is text-only, so `ContentBlock` has no inline slot for an embedded object, and splitting the paragraph around the formula would invent a boundary the source never had), and an inline frame carries `svg:width`/`svg:height` but no `svg:x` — so its recovered frame is the declared size at a zero origin the text flow replaces, which is all the wordprocessing layout engine reads from it anyway. For **odp** (`src/odf/odp/read.ts`): every formula on every slide is detected, groups included. `collectSlideFormulaFrames` replicates `odf.js`'s own `walkDrawShapes` traversal exactly — document order, recursing into a `draw:g`'s children with that group's own `draw:transform` composed, one shape per `draw:frame` whose geometry `readDrawFrame` resolves and none for any it cannot — so the shape index it counts *is* the index `readOdp` assigned. The previous "skip the whole slide if it contains any `draw:g`" narrowing existed only because the old correspondence was "Nth top-level frame = `shapes[N]`", which a group breaks by splicing its own frames into the same flat array; deriving the index from the same walk removes the ambiguity rather than working around it. **ods needs no detection pass of its own at all, unlike odt and odp**: `odf.js` 2.2.0's own `readOds` walks each `table:table-cell`'s children with a real `TableCursor` and classifies an embedded formula sub-document directly (`readOdfFormulaDocument`, alongside the wordprocessing/presentation/spreadsheet/drawing kinds its 2.1.0 classifier already recognised), so a cell-anchored formula arrives as an ordinary `ContentSheet.embeddedObjects` entry already carrying its own anchor. `src/layout/sheets.ts` consumes that directly — see the cell-anchored-formula gotcha below.
|
|
705
|
-
- **A formula crossing a boundary that cannot typeset it degrades to its own plain-text stand-in — its StarMath annotation, or the literal `[formula]` — never to nothing. The docx bridges are no longer part of that list.** `buildDocxPackage` now writes a genuine OMML display equation (`m:oMathPara` > `m:oMath`, structurally translated by `src/omml/write.ts` — see the architecture entry above), so a formula crossing `odtToDocx`, or reaching a docx through any other `buildDocxPackage` caller, arrives as real, editable Word math rather than text. The stand-in survives there for exactly one case: a formula whose MathML produces no OMML content at all (an empty `mathml` array). An individual MathML construct with no OMML counterpart degrades on its own, *inside* the equation, as a literal-text run with an `unsupported-element` diagnostic reported through `buildDocxPackage`'s own `onMathDiagnostic` (threaded from `odtToDocx`/`markdownToDocx`'s `DocumentBridgeOptions`) — it never drags the whole formula down to text. `buildOdtPackage` is no longer on that list either: it writes a real embedded formula sub-document (a nested `Object N/content.xml` with its own `draw:frame`/`draw:object` reference and manifest entry — see the `src/odf-package/` architecture entry), with the identical single-case fallback, a formula carrying no MathML nodes at all. The markdown writer is the only genuinely stand-in-only path left, since CommonMark/GFM has no math construct whatsoever. **`odmToPdf` is not part of this list either**: a chapter's formula is an ordinary block inside that chapter's own `ContentDocument`, so it survives concatenation into the combined document exactly as a paragraph does and renders as genuine typeset MathML. That used to be a documented gap — the formulas travelled in a side-channel map keyed by `sourcePath`, and re-keying every entry against the combined document's own renumbered block indices was intractable — which moving a formula's content *into* the `ContentDocument` removed outright rather than solved.
|
|
706
|
-
- **OMML is read as well as written, but the two directions are deliberately not symmetric in coverage.** `readDocxContent` recovers a docx equation as a real `ContentEmbeddedObjectBlock` carrying its own MathML — the identical shape `readOdtContent` produces for an ODF embedded formula — so `docxToPdf` typesets a Word-authored equation, and `odt → docx → odt` carries a formula through as a formula. The reader covers strictly more than the writer emits, because it has to read what Word wrote rather than only what this package wrote: `m:d`, `m:nary`, `m:acc`, `m:bar`, `m:func`, and `m:sPre` have exact MathML inverses and no writer counterpart at all (see the `src/omml/` architecture entry). What that asymmetry costs in practice: a `docx → odt → docx` round trip of a Word-authored `m:d` comes back as explicit `mo` fence tokens inside an `mrow` rather than as an auto-growing `m:d` delimiter again, an `m:nary` comes back as a scripted operator followed by its operand rather than as an `m:nary`, and an `m:sPre` degrades outright on the way back out, since `mmultiscripts` is one of the constructs `src/omml/write.ts` has no OMML expression for. The mathematics survives every one of those hops; only the specific OMML construct that expressed it does not. Three further real, tracked read-side boundaries: an equation inside a TABLE CELL is now recovered too — `spliceDocxEmbeddedObjects` descends into every table's cells (and any table nested in a cell, recursively), pairing each `ContentTableCell` with its own `w:tc` and splicing the formula into THAT cell's blocks rather than only walking top-level paragraphs; OMML records no geometry whatsoever, so a recovered block's `frame` is a stand-in whose only meaningful field is `heightPt`, taken from the equation's own `w:rPr/w:sz` when it states one and from Word's own 11pt body default otherwise, stated as the exact inverse of the frame fit `src/layout/shared.ts`'s `formulaSizePtForFrame` applies; and an `mtext` that carried an explicit `mathvariant` was written as an ordinary styled math run, which OMML gives no way to distinguish from a styled `mi`, so it reads back as `mi`/`mn`/`mo` rather than as `mtext`.
|
|
707
|
-
- **The OMML translator covers exactly the construct set `src/mathml/layout.ts` typesets, no more — the two are kept aligned deliberately, not by accident.** `mrow`/`mstyle`/`semantics` flatten (every OMML argument slot already holds a sequence, so OMML has no row element of its own); `mi`/`mn`/`mo`/`mtext` become `m:r`/`m:t` runs, with `mtext` written as OMML normal text (`m:nor`) and every `mathvariant` mapped onto the `m:scr` script + `m:sty` style pair — a mapping with no residue, since OMML's two axes span MathML's fourteen values exactly. The honest limits: a stretchy fence is written as an ordinary operator run rather than as an auto-growing `m:d` delimiter — which now genuinely DIVERGES from the PDF path, where a fence does stretch to its content (see the stretchy-fence gotcha above): Word will render the docx fence at its base size where the PDF renders it assembled and full height. A tracked, bounded gap, not a silent one; closing it means emitting a real `m:d` with the fence characters as its `m:begChr`/`m:endChr`, which is a different write shape from the run-per-token one the rest of this translator uses. `munderover` becomes a nested `m:limUpp`/`m:limLow` pair rather than an `m:nary`, because `m:nary`'s own `m:e` slot is the *operand* being summed and MathML records no operand inside `munderover` at all (it sits outside as a following sibling, with nothing marking where it ends — choosing one would be guessing at operand scope), and `mspace` becomes a single literal space with an `approximated-element` diagnostic, since OMML has no width-parameterised spacer anywhere in its vocabulary. `mathvariant` is carried as markup only: the characters themselves stay in their base form rather than being rewritten into the Mathematical Alphanumeric Symbols block the way `applyMathVariant` does for glyph rendering, which would double-apply the style in Word. The `xmlns:m` declaration goes on the fragment's own root rather than on `w:document`, so an equation appended through `DocxParagraph.appendOfficeMath` stays valid inside a docx this package did not scaffold.
|
|
708
|
-
- **`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.
|
|
709
|
-
- **`readMarkdownContent` passes markdown-codec's `readMarkdown` return value straight through, unlike `readDocxContent`/`readOdtContent`/etc., which build a fresh `ContentDocument` envelope from a narrower, format-specific shape.** `markdown-codec`'s own `readMarkdown` already produces a full `document-schema.js` `ContentDocument` directly (`kind`/`formatVersion`/`metadata`/`sections`) — the identical `ContentDocument` type `documents.js` itself imports and re-exports from `document-schema.js`, with no local schema of its own to reconcile against — so, after narrowing to the `wordprocessing` variant, there is nothing left to rebuild.
|
|
710
|
-
- **Every construct-mapping gap either `readMarkdownContent` (read) or `buildMarkdownText` (write) cannot represent losslessly is markdown-codec's own documented, reachable `MarkdownDiagnosticCodes` entry, surfaced through whatever `sink` a caller passes to `readMarkdownContent`/`buildMarkdownText` directly (the `DocumentToPdfOptions`/`DocumentBridgeOptions` shapes `markdownToPdf`/`markdownToDocx`/`markdownToOdt` accept have no room for one — see those types' own doc comments) — not a silent approximation:**
|
|
711
|
-
- **`md/invented-page-geometry`** — markdown has no page concept of its own; every lowered document gets one `ContentSection` with A4 + 1in default page geometry (overridable via `readMarkdownContent`'s own `pageSize`/`margins` options). Fires unconditionally, once per lowered document.
|
|
712
|
-
- **`md/nested-emphasis-flattened`** — emphasis nested inside the identical kind (emphasis-in-emphasis, strong-in-strong) flattens to one run rather than preserving the nesting.
|
|
713
|
-
- **`md/link-title-dropped`** — a link or image's own title attribute (`[text](url "title")`) has no `ContentRun`/`ContentImageBlock` field to survive on.
|
|
714
|
-
- **`md/code-block-info-string-dropped`** — a fenced code block's own info string (the language tag after the opening fence) has no `ContentParagraph` field to survive on.
|
|
715
|
-
- **`md/blockquote-nested-depth`** — a blockquote nested beyond one level is recorded only as an indent depth (`indentLeftPt`), never a genuine container boundary; two independent blockquotes back to back at the same depth are indistinguishable from one that spans both.
|
|
716
|
-
- **`md/list-item-block-unlisted`** — a table or a resolved image directly inside a list item has no way to carry `ContentListMembership`, which lives only on `ContentParagraph`.
|
|
717
|
-
- **`md/list-item-multi-block-flattened`** — a list item containing more than one non-nested-list block loses its own item-boundary identity once lowered.
|
|
718
|
-
- **`md/image-unresolved`** — an image with no `MarkdownImageResolver` supplied (or one that returns `undefined`, or resolved bytes that are neither a readable PNG nor JPEG) degrades to a hyperlinked text run of its own alt text, never an invalid `ContentImageBlock`.
|
|
719
|
-
- **`md/raw-html-preserved-as-text` / `md/raw-html-dropped`** — raw HTML is preserved as literal text by default (styleId `HTMLPreformatted` for block-level HTML) or dropped entirely (`rawHtml: 'drop'`); markdown-codec's own read side never sanitises or interprets it.
|
|
720
|
-
- **`md/front-matter-key-unmapped`** — a leading YAML front matter block is not parsed by a real YAML/TOML engine; only `key: value` lines (plus one array special case for `keywords`) mapping onto five known `LayoutMetadata` fields are recognised, everything else is reported and dropped.
|
|
721
|
-
- **`md/heading-level-clamped`** — a `ContentDocument` heading styleId beyond `Heading6` (reachable from another format's `ContentDocument` via `docxToMarkdown`/`odtToMarkdown`) clamps to level 6, since neither ATX nor setext syntax spells a deeper level.
|
|
722
|
-
- **`md/adjacent-links-merged`** and **`md/code-span-as-monospace-run`** — a run of adjacent hyperlinks sharing one destination merges into a single markdown link; a monospace-font run without a genuine code-span origin still emits as a code span, since `ContentDocument` has no separate "this was actually a code span" marker.
|
|
723
|
-
- **`md/paragraph-indent-dropped`** — a paragraph carrying `indentLeftPt` with none of the five styleIds markdown-codec's own blockquote/code-block/rule/HTML-preformatted convention recognises (reachable via `docxToMarkdown`/`odtToMarkdown`) is a genuine cross-format ambiguity this package cannot resolve; the indent is dropped, the paragraph still renders.
|
|
724
|
-
- **`md/list-numid-fallback`** — a docx/odt-sourced `numId` (via `docxToMarkdown`/`odtToMarkdown`) that markdown-codec never minted itself falls back to a plain, tight, non-task bullet list.
|
|
725
|
-
- **`md/table-cell-formatting-dropped`** and **`md/table-cell-multi-paragraph-joined`** — a GFM table cell's own run-level formatting beyond plain text, and a cell containing more than one paragraph (both reachable via `docxToMarkdown`/`odtToMarkdown`, since docx/odt table cells support both), are both lossy: GFM's own table-cell grammar has no multi-paragraph or rich-formatting representation to write back to.
|
|
726
|
-
- **`buildMarkdownText` throws `MarkdownUnsupportedDocumentKindError` for a non-`'wordprocessing'` `ContentDocument`**, matching `buildDocxPackage`/`buildOdtPackage`'s own "throw outright for the wrong document kind" convention — markdown has no presentation/spreadsheet/drawing equivalent to render, so `docxToMarkdown`/`odtToMarkdown` never need a redundant guard of their own before calling it (see `src/markdown/write.ts`'s own module comment).
|
|
727
|
-
- **`decodeMarkdownText` (`src/markdown/text.ts`) throws `MarkdownInvalidUtf8Error` for malformed UTF-8 input, rather than silently producing U+FFFD replacement characters.** This is the third place this exact invariant is enforced independently: `MarkdownBytesSchema` (both `documents.js`'s own local copy in `src/model/bytes.ts` and markdown-codec's own in that package's `src/codec.ts`) catches it at the schema-validation boundary (`z.decode(markdownPdfCodec, ...)`/`z.decode(markdownDocxCodec, ...)`/etc.), and `decodeMarkdownText` catches it again for `markdownToPdf`/`markdownToDocx`/`markdownToOdt`, which call `readMarkdownContent` directly on already-decoded bytes rather than through a schema.
|
|
728
|
-
- **markdown was wired into the capability/path-resolver model (`src/convert/capability.ts`) as a genuine third `wordprocessing`-variant node, but the four markdown cross-format bridge functions are hand-written, not generically composed.** `resolveConversionPath` can, in principle, find a one-hop composed path for any pair sharing an intermediate node — the identical mechanism that already lets it independently rediscover the hand-composed `xlsxToPdf`/`pdfToXlsx` route (`xlsx → ods → pdf`) — but `createLocalDocumentConverter` (`src/convert/local.ts`) only ever executes a `'direct'` strategy, never a `'composed'` one. Wiring `markdown ⇄ docx`/`markdown ⇄ odt` into the `DocumentConverter` port therefore still required four real, callable, registered bridge functions (`markdownToDocx`/`docxToMarkdown`/`markdownToOdt`/`odtToMarkdown`, `src/convert/convert.ts`) added to `DIRECT_EDGES`, exactly as `xlsxToPdf`/`pdfToXlsx` needed hand composition despite the resolver's own theoretical reach — the resolver's composition ability describes what a caller *could* build by hand, not something the port executes automatically on their behalf.
|
|
530
|
+
- **`ooxml.js`'s typed readers are the basis for conversion** — `readDocxContent`/`readPptxContent` are thin wrappers, not independent walks. They are deliberately not re-exported (exposing both would invite using the wrong one). `readDocx`'s `comments`/`footnotes`/`headers`/`footers`/`numbering` are exposed via `readDocxExtras`. `readPptx` has no extras reader yet.
|
|
531
|
+
- **ODF text content is not a plain string.** ODF represents runs of spaces as `<text:s>`, tabs as `<text:tab/>`, line breaks as `<text:line-break/>` — all elements, not text nodes. Every ODF text getter MUST call `decodeOdfText`, never `textContent()` — which silently drops them (no error, just shorter text).
|
|
532
|
+
- **docx⇄PDF and pptx⇄PDF are explicitly not round-trip-lossless** — see [Fidelity](#fidelity). The cross-format bridge pairs are a genuinely different case.
|
|
533
|
+
- **A `DocumentPackage` from `onDocument`/`ConversionResult.package` is a snapshot, not a live view** — mutating `content` afterwards leaves `layout` stale; nothing detects or rejects that.
|
|
534
|
+
- **ODF text getters must call `decodeOdfText`.** See the dedicated gotcha above.
|
|
535
|
+
- **`readPdf` recovers rect/ellipse/line as their own `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds** via pdf-codec's shape-pattern detection — an axis-aligned closed four-corner subpath is a rect, four kappa-ratio cubics at cardinal points is an ellipse, an open single straight stroke is a line. A false positive changes kind, never geometry. Off-axis rotations, freeform curves, and multi-subpath figures narrow to `LayoutPath`.
|
|
536
|
+
- **`pdfToOds` re-types cells heuristically — this is probabilistic, not a fidelity guarantee.** A rendered PDF never carries a cell's typed value, only the printed string. Re-typing fires only where the string has exactly one defensible reading: the decimal must be exactly representable as a JS number; separators must be unambiguous (`"1,234"` is declined — competing European reading is 1.234); leading zeros decline (`"007"`); dates must self-state their component roles (ISO or named month accepted; `"01/02/2024"` declined). `TRUE`/`FALSE` re-type as booleans; `Yes`/`No` are declined. `displayText` always carries the rendered string verbatim. `onCellTypeInference` reports every decision. A formula is never claimed.
|
|
537
|
+
- **`reconstructWordprocessing`/`reconstructPresentation` recover vector primitives too**, in a nested drawing document — a rule under a heading, an underline, a cell background are all recovered as vectors (intended — discarding real content because it might be incidental is ruled out). A table's gridlines are excluded from vector recovery when the lattice claims them.
|
|
538
|
+
- **Recovered vectors round-trip through all four readers** — `buildDocxPackage`/`buildPptxPackage` write real DrawingML; `buildOdtPackage`/`buildOdpPackage` write real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path`. The six PDF-bypassing bridges carry vector geometry across too.
|
|
539
|
+
- **Each format wraps a vector shape differently.** OOXML: pptx gets a plain `p:sp`; docx gets a `w:drawing`/`wp:anchor` with `behindDoc="1"`/`wp:wrapNone` carrying a `wps:wsp`. ODF: odp appends to `draw:page`; odt anchors in a `text:p` with `style:horizontal-rel`/`style:vertical-rel="page"` (page-absolute coordinates) and `style:run-through="background"`.
|
|
540
|
+
- **`ContentStroke.style` is not written by vector writers.** `LayoutLine`/`LayoutPath` carry the enum, but neither ODF nor DrawingML vector writers read it — a hand-built vector with `stroke.style` paints solid. Cell borders are a separate path that does set the style.
|
|
541
|
+
- **`pdfToOds` recovers what was printed, not what was entered.** `reconstructSpreadsheet` tries a real gridline lattice first (`MIN_GRIDLINE_COUNT_PER_AXIS = 3`), using line positions directly as cell boundaries; absent one, clusters text into a grid from geometry. Column widths/row heights are measured, never invented. No print range/scale/repeat-rows/manual-breaks are inferred.
|
|
542
|
+
- **`OdsSheet.printSettings` round-trips every field** — `pageSize`/`margins`/`gridlines`/`headers`/`pageOrder`/`printRange`/`scalePercent`/`fitToPages`/`repeatColumns`/`repeatRows`/`manualBreaks`. The setter mints a fresh style chain (append-only convention).
|
|
543
|
+
- **`OdsSheet` column-width/row-height setters close the zero-size hazard.** An explicit-but-unstyled column/row element reads back at `widthPt`/`heightPt` 0, which wins over the layout engine's fallback — `xlsxToPdf`'s internal composition made this a real bug. `ensureColumnDefaultWidth`/`ensureRowDefaultHeight` stamp defaults on first individuation. `OdsSheet.addImage`/`addEmbeddedObject` write floating shapes and formula sub-documents.
|
|
544
|
+
- **`reconstructDrawing` maps recovered geometry near-1:1** — no clustering (a drawing has no semantic structure to infer). Kind survives where `readPdf` recovers it; a rotation not a multiple of 90° narrows to `path`. A wrapped multi-line text box comes back as separate single-line boxes (one `LayoutText` = one shape). A `path`'s reconstructed `frame` is the tight bounding box of all recovered points including cubic controls.
|
|
545
|
+
- **Two fill bugs fixed as part of `pdfToOdg`** (both pre-existing, exposed by real-file verification): `draw:fill="solid"` is now written explicitly whenever a fill is set (LibreOffice silently renders a `draw:path` with `draw:fill-color` alone as unfilled); and `writeEllipse` now emits a PDF `h` closepath operator (PDF fills close implicitly, but `readPdf` only marks `closed: true` when it sees `h`).
|
|
546
|
+
- **Vector fill/stroke uses a self-contained graphic-family style writer** (`src/edit/odg/style.ts`), not `odf.js`'s `StyleRegistry` — which recognises `'graphic'` but never emits `style:graphic-properties`.
|
|
547
|
+
- **`svg:d` is cross-checked against `odf.js`'s real parser** — `OdgPathVector.subpaths` re-derives by reparsing the written `svg:viewBox`/`svg:d` on every read.
|
|
548
|
+
- **Paint order is document order, never `draw:z-index`.** `shapes` and `vectors` arrays merge via the shared `paintOrder` field. An earlier `add*` call paints behind a later one.
|
|
549
|
+
- **`LayoutPathSchema` has no quadratic or elliptical-arc segment** — deliberately; real LibreOffice output only emits `M`/`L`/`H`/`V`/`C`/`Z`.
|
|
550
|
+
- **A rotated vector renders as `LayoutPath`** — `LayoutRect`/`LayoutEllipse` carry no rotation field. The rotation is exact (affine maps edges to edges, cubics to cubics); only the `rotationDeg` field is lost on PDF round trip.
|
|
551
|
+
- **`ContentVector.path.fillRule` is read from real `svg:fill-rule` markup.**
|
|
552
|
+
- **Cell borders render with real `style` (`solid`/`dashed`/`dotted`/`double`)** — `LayoutLineSchema` carries the enum, `pushCellBorderLines` sets it, pdf-codec renders it. The `'double'` inter-line offset is an internal constant (not in the data model).
|
|
553
|
+
- **Font resolution uses a real registry, standard 14 as last resort.** A family with no embedded/caller/vendored face (Aptos, third-party typefaces) renders through the nearest standard-14 face with a width-correction factor — expect a visual approximation, not line-identical output. MathML formula rendering is separate: it embeds STIX Two Math, not registry-resolvable.
|
|
554
|
+
- **Justified paragraphs stretch inter-word gaps** in all three layout engines (`engine.ts`, `slides.ts`, `sheets.ts`). `justifyLineGapsPt` divides slack evenly across detected word gaps; final lines stay left-aligned.
|
|
555
|
+
- **Encrypted PDFs and CCITT/JBIG2/JPX images are real capabilities** in pdf-codec — not scope boundaries. The permanent boundary is adversarial/malformed-input robustness.
|
|
556
|
+
- **PDF → docx/pptx/odt/odp table recovery requires a real drawn gridline lattice** — never text alignment (which would invent structure). A lattice with no text inside is rejected.
|
|
557
|
+
- **Merged table cells round-trip as merged.** docx: horizontal merge collapses to one `w:tc` with `w:gridSpan`; vertical merge needs one `w:tc` per covered row with `w:vMerge`. ODF: one entry per grid position, covered cells get `table:covered-table-cell`.
|
|
558
|
+
- **docx headers/footers/comments/footnotes/numbering are readable via `readDocxExtras`** — `readDocxContent` still drops them (`ContentDocument` has nowhere to put them). `PAGE`/`NUMPAGES` field substitution is never read (it's a render-time value).
|
|
559
|
+
- **A docx inline image reads as a real `ContentImageBlock`** — `buildDocxPackage` recognises `readDocx`'s two-block pattern (empty-text paragraph + image) and writes it back as one paragraph, avoiding spurious blank paragraphs on round trip.
|
|
560
|
+
- **pptx speaker notes survive via a hidden `/Subtype /Text` annotation** — specific to this package's writer/reader pair; other PDF producers/consumers won't see it.
|
|
561
|
+
- **`odmToPdf` is the one non-bytes-in/bytes-out conversion** — chapters are external `.odt` references requiring `resolveSubDocument`. All unresolved sections are collected before throwing `OdmUnresolvedSectionError`.
|
|
562
|
+
- **`.odb` has no `odbToPdf`** — a database front-end's tables/queries/reports are three unrelated output shapes. Rendered *reports* are the exception: `odbReportToDocx`/`odbReportToOdt`/`odbReportToPdf` take an already-rendered `ContentDocument`.
|
|
563
|
+
- **The rpt formula engine's group scoping cascades enclosing breaks inward.** A group at level L starts a new instance when its own expression breaks OR when any enclosing group breaks — otherwise a "Q2" subtotal would span two regions. `HASCHANGED` itself knows nothing about groups; the cascade lives in the report structure. Aggregates are computed over complete ranges (not running totals); group expressions may not transitively depend on aggregates (circular).
|
|
564
|
+
- **The rpt function set is a closed allowlist; separator is semicolon.** `rpt:HASCHANGED`/`rpt:LEFT`/`rpt:SUM`/`COUNT`/`AVG`/`MIN`/`MAX`/`field:[COLUMN]` — everything else throws. `[NAME]` and `"NAME"` are one concept. Three refusals where guessing would produce wrong values: non-boolean group expressions, `rpt:LEFT` over non-text, per-row formulas in report header/footer.
|
|
565
|
+
- **The rpt engine emits no page headers/footers** — the renderer places them under a single-logical-page model, at report scope.
|
|
566
|
+
- **The SQL engine is a closed allowlist** — JOINs, subqueries, `UNION`, `DISTINCT`, `HAVING`, `LIMIT`, aliases, `CASE`, arithmetic, etc. all throw `HsqldbSqlUnsupportedError` naming the construct. Silently dropping a clause would return plausible wrong rows.
|
|
567
|
+
- **Four SQL semantics decisions:** (1) NULL is `{ kind: 'empty' }`, three-valued logic; (2) values compare within classes (numeric/boolean/text), cross-class throws; (3) `GROUP BY` puts NULLs in one group, first-appearance order; `COUNT(*)` counts rows, `COUNT(column)` counts non-NULL; (4) `ORDER BY` sorts NULLs last under ASC, stable.
|
|
568
|
+
- **Unquoted SQL identifiers fold to upper case; double-quoted match exactly.**
|
|
569
|
+
- **All four `.odb` decoder tiers are implemented.** Tier 4 (BINARY/COMPRESSED) is a sibling of Tier 2, not a new value decoder — it recovers DDL as TEXT-format script text and decodes rows through the same per-column encoder. An external-only connection is a permanent scope boundary.
|
|
570
|
+
- **The CACHED-table decoder is scoped to HSQLDB 1.8.x** (LibreOffice's bundled version). No ratified spec; ground truth is the decompiled engine source, cross-checked against a JDBC oracle.
|
|
571
|
+
- **A CACHED table's index count comes from its `SET TABLE ... INDEX'...'` line's token count** — `tokens.length - 1`. Traversing index 0's tree suffices (every index spans the same rows); the AVL tree is walked by child positions, never key comparisons.
|
|
572
|
+
- **DATE/TIME/TIMESTAMP from CACHED tables need a timezone** — the file doesn't record one. `{ timeZone }` option (IANA name), defaulting to local zone. Affects Tier 2 and 4 only.
|
|
573
|
+
- **BIGINT/DECIMAL/NUMERIC beyond double precision carry `exactValue`** — a decimal-string sidecar, built via `BigInt` digit manipulation, attached only when `Number()` would lose precision.
|
|
574
|
+
- **`.odb` Tier 3 (Firebird) has no ratified spec.** The `database/firebird.fbk` part is a gbak logical backup stream, not a raw ODS page dump (confirmed by hex-inspecting a real fixture). Built against Firebird's own engine source; format version 10 (FB2.5→FB3.0).
|
|
575
|
+
- **Three real fixtures back the Firebird reader**, generated via headless LibreOffice 26.2 UNO automation, cross-verified field-by-field against LibreOffice's own SDBC.
|
|
576
|
+
- **BLOB columns are genuinely decoded.** TEXT blobs arrive as UTF-8 strings; binary blobs as base64 `data:` URIs. NULL blobs write no record. No `att_end` terminator after blob data.
|
|
577
|
+
- **FB4+-only types (`INT128`/`DECFLOAT`) are an environmental hard stop** — LibreOffice's bundled FB3 engine cannot declare them, so no `.odb` exists to verify against.
|
|
578
|
+
- **Firebird gbak mixes two byte-level encodings:** little-endian for tags/attributes, big-endian XDR for row field values.
|
|
579
|
+
- **STIX Two Math is embedded as a whole `CFF ` table** — pdf-codec's scope decision, not this package's.
|
|
580
|
+
- **Stretchy fences stretch vertically via `MathVariants`** — parentheses, brackets, braces, floor/ceiling, angle brackets, bars. `msqrt`/`mroot` radicals render through the font's √ construction plus a vinculum rule. Multi-character `mo` never stretches.
|
|
581
|
+
- **Over/under-braces stretch horizontally** via the identical `MathFontMetrics.stretch` port, called with `axis: 'horizontal'`.
|
|
582
|
+
- **Stretched fence glyphs have no ToUnicode mapping** — pdf-codec wraps them in `/ActualText` spans for text extraction.
|
|
583
|
+
- **Big operators (`∑`/`∏`/`⋃`) are NOT stretchy** — they grow via `largeop`, matching MathML3.
|
|
584
|
+
- **The operator dictionary is a bounded ~60-entry table**, not the full MathML3 spec.
|
|
585
|
+
- **`mover`/`munder` centre at the font's `MathTopAccentAttachment` point** when available, geometric centring otherwise.
|
|
586
|
+
- **Greek `mathvariant` covers the alphabet, nabla, partial, and six symbol-variant glyphs** — generated from Unicode's `UnicodeData.txt`.
|
|
587
|
+
- **Cell-anchored formulas render for real** — `sheets.ts` resolves the anchor against positioned column/row geometry. The print range widens to cover the anchor cell when no explicit range is declared. A formula in a repeat band renders on every page. Hidden anchor rows/columns skip the formula.
|
|
588
|
+
- **`convertSpreadsheetToLayout` returns `{ document, formulas }`** — formula CID-font glyph runs can't travel through `LayoutDocument.pages[].items`.
|
|
589
|
+
- **`formulaSizePtForFrame` is one shared two-pass fit** — lay out once at reference size, rescale to fit both frame width and height, floored at 8pt. docx OMML (no geometry) uses height alone.
|
|
590
|
+
- **Embedded-formula detection in odt/odp is genuinely new work** — `collectFormulaFrames`/`collectSlideFormulaFrames` mirror `odf.js`'s own walks. ods needs no detection pass (`odf.js` 2.2.0 classifies formula sub-documents directly).
|
|
591
|
+
- **A formula that cannot typeset degrades to its plain-text stand-in, never to nothing.** `buildDocxPackage` writes real OMML; `buildOdtPackage` writes real embedded formula sub-documents. The markdown writer is the only stand-in-only path. `odmToPdf` carries formulas through as ordinary blocks.
|
|
592
|
+
- **OMML read/write are deliberately asymmetric** — the reader covers more (`m:d`, `m:nary`, `m:acc`, `m:bar`, `m:func`, `m:sPre`) because it must read what Word wrote. `docx → odt → docx` round trips keep the mathematics but may change the OMML construct.
|
|
593
|
+
- **The OMML translator covers exactly what `src/mathml/layout.ts` typesets.** A stretchy fence diverges: PDF stretches it, docx writes it at base size. `munderover` becomes nested `m:limUpp`/`m:limLow` (no operand scope in MathML).
|
|
594
|
+
- **`sourcePath` traces a `LayoutItem` to its `ContentDocument` origin, but only within one read+layout pass** — not an edit-tracking mechanism.
|
|
595
|
+
- **`readMarkdownContent` passes `readMarkdown`'s result straight through** — `markdown-codec` already produces a full `ContentDocument`.
|
|
596
|
+
- **Every markdown construct-mapping gap is a documented `MarkdownDiagnosticCodes` entry** (`md/invented-page-geometry`, `md/nested-emphasis-flattened`, `md/link-title-dropped`, `md/code-block-info-string-dropped`, `md/blockquote-nested-depth`, `md/list-item-block-unlisted`, `md/list-item-multi-block-flattened`, `md/image-unresolved`, `md/raw-html-preserved-as-text`/`md/raw-html-dropped`, `md/front-matter-key-unmapped`, `md/heading-level-clamped`, `md/adjacent-links-merged`, `md/code-span-as-monospace-run`, `md/paragraph-indent-dropped`, `md/list-numid-fallback`, `md/table-cell-formatting-dropped`, `md/table-cell-multi-paragraph-joined`) — never a silent approximation.
|
|
597
|
+
- **`buildMarkdownText` throws for non-`'wordprocessing'` `ContentDocument`.**
|
|
598
|
+
- **`decodeMarkdownText` throws on malformed UTF-8** rather than producing U+FFFD.
|
|
599
|
+
- **The composition engine routes every pair generically** through a declarative primitive registry and minimum-cost pathfinder. `resolveCompositionPlan` finds the minimum-cost route (same-variant bridge < cross-variant transform < via-PDF multi-hop). Named functions are thin forwarders.
|
|
729
600
|
|
|
730
601
|
## Fidelity
|
|
731
602
|
|
|
732
|
-
|
|
603
|
+
Read as **row → column**. `✓` lossless, `~` bounded, `✗` lossy, `✗✗` severe, `→` one-way, `–` no conversion. `.odm`/`.odb` sit outside this table.
|
|
733
604
|
|
|
734
605
|
| ↓ from \ to → | docx | pptx | xlsx | odt | odp | ods | odg | odf | markdown | pdf |
|
|
735
606
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
@@ -744,66 +615,54 @@ The prose below is authoritative; this table is a quick-reference summary of it,
|
|
|
744
615
|
| **markdown** | ~ | – | ✗✗ | ~ | – | – | – | – | — | ~ |
|
|
745
616
|
| **pdf** | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | – | ✗✗ | — |
|
|
746
617
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
**docx/pptx/odt/odp/ods/odg → PDF** is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, `basedOn` chains, placeholder inheritance for docx/pptx; `style:default-style`/`style:parent-style-name` chains for odt/odp). `odg` renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF `m`/`l`/`c`/`h` content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful **visual approximation**, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — how close depends on which typeface the document asks for and whether it embedded one, see the font-resolution gotcha above.
|
|
618
|
+
73 of 90 directional pairs are routable. The `ContentDocument`/`LayoutDocument` pivots are the hub, not PDF — fourteen bridges bypass PDF entirely.
|
|
750
619
|
|
|
751
|
-
**
|
|
620
|
+
**X → PDF** is a genuine layout render: positioned text, images, tables, lists, vector primitives, styled through the full cascade. It is a faithful visual approximation, not pixel-identical — closeness depends on font availability.
|
|
752
621
|
|
|
753
|
-
**
|
|
622
|
+
**odf → PDF and embedded formulas** render faithful mathematical typesetting through STIX Two Math: real box-model layout, per-glyph metrics, font-wide constants from the `MATH` table, stretchy fences and braces via `MathVariants`. `pdfToOdf` is not attempted — recovering a semantic operator tree from glyphs is OCR-adjacent.
|
|
754
623
|
|
|
755
|
-
**PDF →
|
|
624
|
+
**PDF → docx/pptx/odt/odp** is best-effort reconstruction from geometry. Reading order, font properties, page count survive; paragraph boundaries are inferred from baseline spacing. Tables recover only from a real gridline lattice. Vector primitives recover into a nested drawing document.
|
|
756
625
|
|
|
757
|
-
**PDF →
|
|
626
|
+
**PDF → odg** is near-1:1 mapping (no clustering needed). Kind narrows upstream: rotated rects, freeform curves, multi-subpath figures become `path`.
|
|
758
627
|
|
|
759
|
-
|
|
628
|
+
**PDF → ods** recovers what was printed, not what was entered. The printed string always survives in `displayText`; re-typed `value` is explicitly probabilistic inference.
|
|
760
629
|
|
|
761
|
-
|
|
630
|
+
**`markdownToPdf`/`pdfToMarkdown`** is the lossiest round trip: `markdownToPdf` is faithful, but `pdfToMarkdown` stacks reconstruction lossiness PLUS markdown's coarser vocabulary (no colour, font, size, alignment).
|
|
762
631
|
|
|
763
|
-
**The first three
|
|
632
|
+
**The first three bridge pairs** (odt⇄docx, odp⇄pptx, ods⇄xlsx) bypass PDF entirely — no layout engine, no reconstruction. Text, styling, tables, lists, rotated shapes survive completely. `ods⇄xlsx` has small format-boundary limits (time cells, formula dialects). Embedded formulas survive `odtToDocx` as real OOXML math.
|
|
764
633
|
|
|
765
|
-
**The two markdown
|
|
634
|
+
**The two markdown bridge pairs** bypass PDF too, but markdown's grammar has no construct for colour/font/size/alignment — `docxToMarkdown`/`odtToMarkdown` drop them (format-boundary loss, not approximation).
|
|
766
635
|
|
|
767
|
-
**Four cross-variant
|
|
636
|
+
**Four cross-variant bridges** (docx⇄pptx, odt⇄odp) go through a semantic transform — slide boundaries are heuristic, but blocks survive intact.
|
|
768
637
|
|
|
769
|
-
**`.odb`
|
|
638
|
+
**`.odb` extraction** is genuine verified data extraction across all four tiers, differing by what each storage shape carries. BLOB content recovers byte-for-byte. No reverse direction.
|
|
770
639
|
|
|
771
|
-
**
|
|
640
|
+
**SQL/rpt engines** are exact within their closed grammars, hard failures outside — never approximations.
|
|
772
641
|
|
|
773
|
-
**
|
|
774
|
-
|
|
775
|
-
**Rendering a `.odb` Report (`readOdbReportContent`) is structurally faithful, not pixel-faithful, and the line between those is exactly where odf.js's own report reader stops.** What is exact: which bands print, in what order, against which rows, with which group instances open, and what every formula in them evaluates to — all of that is the two engines above, which are exact within their own closed sets. What is *structural*: each printed band becomes one single-row `ContentTable`, one cell per control in document order, which is the shape the band genuinely has in the report file (every band there *is* a `table:table` whose cells hold its controls) rather than a guess at one. The alternative shape — a paragraph per field — was rejected, not merely not chosen: it would stack a detail row's Customer and Amount vertically, destroying the one relationship a banded report's layout grid exists to express.
|
|
776
|
-
|
|
777
|
-
What is *not* reproduced is presentation, because it is not read in the first place: a control's own font, colour, alignment, number format, and grid position live in its style, which odf.js's report reader deliberately does not resolve (that reader's own finding 3 states it — a control's grid position is presentation, not structure). So a numeric value renders as its own plain display text (`1200.5`, not the `1,200.50` the report's own format might produce), no band carries a font or a border, and column widths divide the section's content width equally between a band's cells, which is a stated fallback rather than a recovered measurement. Pagination is not reproduced either: this renderer declares one logical page rather than guessing where breaks fall (see the Gotchas entry for what that means for the two page bands). The bands' own identity does survive, as each cell's paragraph `styleId` (`Group Footer 1`, `Detail`, …), so a consumer can restyle by band without having to infer which band a block came from.
|
|
778
|
-
|
|
779
|
-
Verified end to end against the real report in the real LibreOffice-generated `.odb`: `src/odb/report/content.test.ts` renders `form-and-report.odb`'s own `SalesByRegion` — its binding resolved from `rpt:command-type="query"` to that package's own `HighValueSales` command, its rows decoded by the Tier 3 Firebird reader, its formulas evaluated by `src/odb/formula/` — and asserts the entire block sequence exactly: every band in print order, both `REGION` groups each containing its own `QUARTER` sub-groups, every detail row in the query's own order, and the `SUM(AMOUNT)` total in all three scopes, each computed by hand from the real six-row `SALES` data and asserted as both its rendered text and its exact number (`1540.50`/`2750.25`/`1810.00` per quarter, `4290.75`/`1810.00` per region, `6100.75` overall; and over all six rows rather than the four the query keeps, `1540.50`/`2750.25`/`95.75`/`1810.00`/`60.00`, `4290.75`/`1905.75`/`60.00`, and `6256.50`). The rendered document is also parsed against `ContentDocumentSchema` and pushed through `convertWordprocessingToLayout`/`writePdf`, so the claim that it needs no `odbToPdf` of its own is proven rather than asserted.
|
|
780
|
-
|
|
781
|
-
**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.
|
|
642
|
+
**Report rendering** is structurally faithful, not pixel-faithful: band order/content/formulas are exact; fonts/colours/number formats/pagination are not reproduced (odf.js's report reader doesn't resolve styles).
|
|
782
643
|
|
|
783
644
|
## Release and publishing
|
|
784
645
|
|
|
785
|
-
`.github/workflows/ci.yml` runs commitlint, lint, typecheck,
|
|
786
|
-
|
|
787
|
-
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.
|
|
646
|
+
`.github/workflows/ci.yml` runs commitlint, lint, typecheck, unit suite, and smoke test on every push/PR. On push to `main` where all pass, `release.config.ts` drives semantic-release: commit history decides the version bump, `CHANGELOG.md` and `package.json` are committed back, a GitHub Release is cut, and the package publishes to npmjs.org via OIDC trusted publishing (no `NPM_TOKEN`). Publication is detected by diffing `package.json`'s version before/after. A second job republishes under `@exadev/documents.js` to GitHub Packages; a third generates an SPDX SBOM and signs build-provenance attestations.
|
|
788
647
|
|
|
789
648
|
## Contributing
|
|
790
649
|
|
|
791
|
-
|
|
650
|
+
Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by commitlint via a husky `commit-msg` hook — semantic-release's version bump depends on these. `pre-commit` runs `lint-staged`; `pre-push` runs the test suite. Single `main` branch, no open PR workflow established.
|
|
792
651
|
|
|
793
652
|
## References
|
|
794
653
|
|
|
795
|
-
- [ooxml.js](https://github.com/ExaDev/ooxml.js) —
|
|
796
|
-
- [document-schema.js](https://github.com/ExaDev/document-schema.js) —
|
|
797
|
-
- [markdown-codec](https://github.com/ExaDev/markdown-codec) —
|
|
798
|
-
- [pdf-codec](https://github.com/ExaDev/pdf-codec) — the
|
|
799
|
-
- [byte-codec](https://github.com/ExaDev/byte-codec) —
|
|
800
|
-
- [odf.js](https://github.com/ExaDev/odf.js) —
|
|
801
|
-
- [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font
|
|
802
|
-
- [firebirdsql/firebird](https://github.com/FirebirdSQL/firebird) —
|
|
654
|
+
- [ooxml.js](https://github.com/ExaDev/ooxml.js) — docx/pptx/xlsx ⇄ JSON handling and typed reading, including `readXlsxContent`/`buildXlsxPackage` (consumed by the `odsToXlsx`/`xlsxToOds` bridge and internal codecs, not re-exported).
|
|
655
|
+
- [document-schema.js](https://github.com/ExaDev/document-schema.js) — owns `ContentDocument`/`LayoutDocument` and the port contracts; shared by all sibling packages.
|
|
656
|
+
- [markdown-codec](https://github.com/ExaDev/markdown-codec) — CommonMark+GFM ⇄ `ContentDocument` handling. The third format (after docx/odt) sharing the wordprocessing pivot.
|
|
657
|
+
- [pdf-codec](https://github.com/ExaDev/pdf-codec) — the hand-written PDF codec (`readPdf`/`writePdf`/`pdfCodec`), the embedded STIX Two Math font, and text-measurement/font-resolution primitives.
|
|
658
|
+
- [byte-codec](https://github.com/ExaDev/byte-codec) — generic byte/image utilities (ByteWriter, CRC-32, deflate/inflate, PNG/JPEG), extracted from pdf-codec.
|
|
659
|
+
- [odf.js](https://github.com/ExaDev/odf.js) — ODF codec (odt/ods/odp/odg), also built on `document-schema.js`. Style interning, rotation, `svg:d` parsing, and manifest handling consumed directly.
|
|
660
|
+
- [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font. Vendored within pdf-codec (OFL-1.1).
|
|
661
|
+
- [firebirdsql/firebird](https://github.com/FirebirdSQL/firebird) — ground truth for `src/firebird/`, since gbak backup format has no ratified spec. Read as source material only, not a build/runtime dependency.
|
|
803
662
|
|
|
804
663
|
## npm aliases
|
|
805
664
|
|
|
806
|
-
This package also publishes under
|
|
665
|
+
This package also publishes under:
|
|
807
666
|
|
|
808
667
|
- [js.documents](https://www.npmjs.com/package/js.documents)
|
|
809
668
|
|