documents.js 1.50.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -11
- package/dist/index.cjs +3609 -102
- package/dist/index.d.cts +265 -8
- package/dist/index.d.ts +265 -8
- package/dist/index.js +3591 -110
- package/package.json +3 -3
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
|
-
> Bidirectional docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, six further cross-format bridges (odt⇄docx, odp⇄pptx, ods⇄xlsx) that bypass PDF entirely, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, and a fully hand-written PDF codec, built on [ooxml.js](https://github.com/ExaDev/ooxml.js) and [odf.js](https://github.com/ExaDev/odf.js).
|
|
5
|
+
> Bidirectional docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, six further cross-format bridges (odt⇄docx, odp⇄pptx, ods⇄xlsx) that bypass PDF entirely, Tier 1 `.odb` (ODF database front-end) table extraction to xlsx/CSV, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, a hand-written MathML presentation-layer typesetting engine with embedded-font PDF rendering (odf → PDF, plus formulas embedded inside odt/odp), and a fully hand-written PDF codec, built on [ooxml.js](https://github.com/ExaDev/ooxml.js) and [odf.js](https://github.com/ExaDev/odf.js).
|
|
6
6
|
|
|
7
|
-
`documents.js` depends on `ooxml.js` for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions `ooxml.js` deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-**and-write** manipulation API for docx/pptx content — `ooxml.js`'s own typed readers (`readDocx`/`readPptx`) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are entirely hand-written: no external PDF library (`pdf-lib`, `pdfjs-dist`, `mupdf`, or any other) is a dependency. The one exception is [`fflate`](https://github.com/101arrowz/fflate) for raw DEFLATE/zlib compression underneath PDF's `FlateDecode` filter and PNG's `IDAT` chunks — the same dependency `ooxml.js` itself already relies on for ZIP handling.
|
|
7
|
+
`documents.js` depends on `ooxml.js` for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions `ooxml.js` deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-**and-write** manipulation API for docx/pptx content — `ooxml.js`'s own typed readers (`readDocx`/`readPptx`) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are entirely hand-written: no external PDF library (`pdf-lib`, `pdfjs-dist`, `mupdf`, or any other) is a dependency. The one exception is [`fflate`](https://github.com/101arrowz/fflate) for raw DEFLATE/zlib compression underneath PDF's `FlateDecode` filter and PNG's `IDAT` chunks — the same dependency `ooxml.js` itself already relies on for ZIP handling. `src/mathml/` (the MathML typesetting engine) and the OpenType/CFF font parsing this package's own PDF writer uses to embed a real math font (`src/pdf/sfnt.ts`/`math-*.ts`) are both hand-written too, for the same "no supply-chain surface beyond what's already declared" reason — the one bundled binary asset is the vendored STIX Two Math font itself (OFL-1.1, see [Fidelity](#fidelity) and `assets/fonts/NOTICE.md`), not a library.
|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
@@ -81,6 +81,27 @@ const { document, diagnostics } = await converter.convert(
|
|
|
81
81
|
|
|
82
82
|
`DocumentFormat` includes `xlsx` alongside `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`pdf` — not because xlsx has a PDF conversion of its own, but because `createLocalDocumentConverter`'s `{ source, targetFormat }` contract already generalises past "targetFormat always means pdf": `odt`→`docx`, `docx`→`odt`, `odp`→`pptx`, `pptx`→`odp`, `ods`→`xlsx`, and `xlsx`→`ods` are six further entries in the same `conversions` list, routed to the six bridge functions above with an empty `diagnostics` array.
|
|
83
83
|
|
|
84
|
+
Getting back the intermediate `DocumentPackage` (content + layout, from `document-content-model`) a conversion built internally, instead of only the target bytes — every ergonomic conversion function above accepts an `onDocument` callback for this, and the port surfaces the same value as `package` on its `ConversionResult`:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { docxToPdf } from 'documents.js';
|
|
88
|
+
|
|
89
|
+
const pdfBytes = docxToPdf(docxBytes, {
|
|
90
|
+
onDocument: (pkg) => {
|
|
91
|
+
console.log(pkg.content.kind); // 'wordprocessing'
|
|
92
|
+
console.log(pkg.layout?.pages.length); // populated for every X-to-PDF/PDF-to-X conversion
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// or via the port:
|
|
97
|
+
const { document, package: pkg } = await converter.convert(
|
|
98
|
+
{ source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
|
|
99
|
+
{ signal: new AbortController().signal },
|
|
100
|
+
);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For the six PDF-bypassing bridges, `pkg.layout` is always `undefined` — a bridge never runs a layout engine, so there is nothing to populate it with; running one purely to fill this field would be wasted work no caller asked for.
|
|
104
|
+
|
|
84
105
|
Reading and editing docx/pptx content directly, without going through PDF at all:
|
|
85
106
|
|
|
86
107
|
```ts
|
|
@@ -200,6 +221,61 @@ try {
|
|
|
200
221
|
|
|
201
222
|
`odmToPdf` is not one of the twelve round-trip conversions or the six bridges above, has no `z.codec()` pair, and is not wired into the `DocumentConverter` port below — see Gotchas for why.
|
|
202
223
|
|
|
224
|
+
Tier 1 `.odb` (ODF database front-end) support: `readOdbTables` extracts every table an embedded HSQLDB database declares in its own TEXT-format `database/script` part, and `odbToXlsx`/`odbToCsv` turn that straight into xlsx or CSV bytes — a `.odb` never has any table data of its own inside the ODF package for any other embedded storage shape (see Gotchas/Fidelity for what that means for binary HSQLDB and Firebird):
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { decodePackage } from 'odf.js';
|
|
228
|
+
import { odbToCsv, odbToXlsx, readOdbTables } from 'documents.js';
|
|
229
|
+
|
|
230
|
+
const xlsxBytes = odbToXlsx(odbBytes); // one xlsx sheet per table, a header row of column names then one row per record
|
|
231
|
+
const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // exactly one named table as CSV -- required whenever the .odb has more than one table
|
|
232
|
+
|
|
233
|
+
const tables = readOdbTables(decodePackage(odbBytes)); // Package -> HsqldbTable[], for a caller that wants the raw table/column/row data without going through xlsx or CSV
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`readOdbTables` takes a decoded `Package` (matching `readOdtContent`/`readOdsContent`/etc.'s own convention), while `odbToXlsx`/`odbToCsv` take raw bytes and decode them internally, matching every other ergonomic conversion in this package. `.odb` has no PDF conversion, no reverse (xlsx/CSV → `.odb`) direction, and — like `odmToPdf` — is not wired into the `DocumentConverter` port below, since Reports require live SQL execution to render (categorically out of scope) and the write direction would need a real embedded SQL engine this package deliberately does not implement.
|
|
237
|
+
|
|
238
|
+
A standalone `.odf` (an ODF formula document) converts to PDF via `odfToPdf`, rendering the formula's own real MathML through a hand-written typesetting engine (`src/mathml/`) and the embedded STIX Two Math font, not a static image or a StarMath-text placeholder:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { odfToPdf } from 'documents.js';
|
|
242
|
+
|
|
243
|
+
const pdfBytes = odfToPdf(odfBytes); // a single formula (or small formula document), faithfully typeset -- see Fidelity
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`odfToPdf` is not one of the twelve round-trip conversions above either: there is no `pdfToOdf` (recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one — see [Fidelity](#fidelity)), no `z.codec()` pair, and — unlike `odmToPdf` — it *is* wired into the `DocumentConverter` port below, as a `DocumentFormat: 'odf'` source with only a `'pdf'` target.
|
|
247
|
+
|
|
248
|
+
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:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import { odtToPdf } from 'documents.js';
|
|
252
|
+
|
|
253
|
+
// odtBytes contains an ordinary paragraph followed by an embedded formula object (LibreOffice: Insert > Object > Formula) --
|
|
254
|
+
// the formula renders as real typeset MathML in the output PDF, at the position and approximate size of its own source frame.
|
|
255
|
+
const pdfBytes = odtToPdf(odtBytes);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This works by threading the formula's own raw MathML alongside the `ContentDocument` `readOdtContent`/`readOdpContent` already produce (see `OdtContentResult`/`OdpContentResult`'s own `formulas` field) rather than through `ContentDocument` itself, since document-content-model's own `ContentEmbeddedObject.document` field has no MathML-shaped variant to carry it in:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { convertWordprocessingToLayout, readOdtContent } from 'documents.js';
|
|
262
|
+
|
|
263
|
+
const { document, formulas } = readOdtContent(pkg); // formulas: ReadonlyMap<sourcePath, EmbeddedFormula>
|
|
264
|
+
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer, formulas });
|
|
265
|
+
const pdfBytes = writePdf(layout, { formulas: positioned }); // writePdf's own formula-aware option -- see Architecture
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`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:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { layoutFormula, loadMathFont } from 'documents.js';
|
|
272
|
+
|
|
273
|
+
const { metricsAt } = loadMathFont();
|
|
274
|
+
const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), sizePt: 12, color: { r: 0, g: 0, b: 0 } });
|
|
275
|
+
// box: a MathBox -- positioned glyph runs, fraction/radical rules, and radical-hook strokes, ready for src/pdf's own math-content-write.ts
|
|
276
|
+
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see Fidelity
|
|
277
|
+
```
|
|
278
|
+
|
|
203
279
|
## Architecture
|
|
204
280
|
|
|
205
281
|
The package is layered from generic primitives outward to the two conversion directions:
|
|
@@ -208,16 +284,20 @@ The package is layered from generic primitives outward to the two conversion dir
|
|
|
208
284
|
- **`src/bytes/`** and **`src/image/`** — generic byte and image-container primitives with zero PDF or OOXML knowledge: a chunked byte writer, a backtracking byte reader, CRC32, and a hand-written PNG decoder/encoder (palette/gray/RGB/alpha, multi-`IDAT` files, all five scanline filters) plus JPEG marker scanning for dimensions only — JPEG's compressed bytes pass through completely unchanged in both directions. `src/bytes/flate.ts` is the only file that imports `fflate`, mirroring how `ooxml.js`'s own `src/zip.ts` wraps it for ZIP handling.
|
|
209
285
|
- **`src/xml/`** and **`src/opc/`** — parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built over `ooxml.js`'s `Package`/`XmlNode`, needed because `ooxml.js`'s own XML nodes have no parent pointers and `ooxml.js` never writes new parts into an existing package.
|
|
210
286
|
- **`src/edit/`** — the read-and-write editable model: live-view classes (`DocxEditor`/`DocxParagraph`/`DocxRun`/`DocxTable`, `PptxEditor`/`PptxSlide`/`PptxShape`, `OdtEditor`/`OdtParagraph`/`OdtRun`/`OdtTable`/`OdtList`, `OdpEditor`/`OdpSlide`/`OdpShape`, `OdsEditor`/`OdsSheet`/`OdsCell`, `OdgEditor`/`OdgPage`/`OdgBoxVector`/`OdgLineVector`/`OdgPathVector`) wrapping the actual `XmlElement` objects inside a decoded `Package`, plus `buildDocxPackage`/`buildPptxPackage`/`buildOdtPackage`/`buildOdpPackage`/`buildOdsPackage`/`buildOdgPackage` bridging a `ContentDocument` to a fresh package built entirely through those same primitives — `pdfToOdt`/`pdfToOdp`/`pdfToOds`/`pdfToOdg` each call the matching one. `src/edit/odp/*` reuses `src/edit/odt/*`'s own paragraph/run/list/style-interning classes WHOLESALE rather than reimplementing them for presentations: a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` content model `office:text` does, interned into the identical `content.xml` `office:automatic-styles` registry (`src/edit/odt/props.ts`'s `applyStyleChange`) — `OdpShape.appendParagraph`/`.paragraphs()`/`.addList()` return real `OdtParagraph`/`OdtList` instances, not odp-specific lookalikes. The genuinely new odp-specific work is `draw:page`/`draw:frame` mechanics (a slide is a `draw:page`, a shape's geometry is explicit `svg:x`/`svg:y`/`svg:width`/`svg:height` rather than pptx's placeholder-inheritance-heavy model) and rotation: `OdpShape.rotationDeg` is a genuine `draw:transform` setter built on `odf.js`'s own `applyOdfTransform`/`resolveOdfShapeGeometry` (`typed/shared/transform.ts`) — the write-side inverse of the exact function odf.js's own reader uses — unlike `PptxShape`, which has no rotation setter yet (see Gotchas below). `src/edit/ods/*` has no docx/pptx/odt/odp analogue to reuse for its core concern (cell addressing) but still reuses `src/edit/odt/*`'s style interning and `src/edit/odt/content.ts`'s `populateParagraph` for cell text content — `src/edit/ods/address.ts` is the write-side counterpart to `odf.js`'s own read-side `table:number-*-repeated`-aware cursor: setting a distant cell's value splits the covering repeated run in place at that one position rather than materialising every cell in between, exactly mirroring the read-side hazard `odf.js`'s own `typed/shared/a1.ts` already solved. `src/edit/ods/print-settings.ts` is the newest addition: `OdsSheet.printSettings`'s own getter/setter, mining `styles.xml`'s `office:automatic-styles`/`office:master-styles` directly (a part no other `src/edit/ods/*` module needed to touch before) rather than `content.xml` alone, reusing `odf.js`'s own exported `findStyleElement`/`resolvePageLayoutProperties`/`parsePageSize`/`parseMargins` for the read half and `src/edit/odt/automatic-styles.ts`'s `nextStyleName` (already generic over which `office:automatic-styles` element it scans) for the write half's own fresh-name minting. `src/edit/odg/*` reuses `OdpShape`/`buildTextBoxFrame`/`insertImageFrameMedia` WHOLESALE for `draw:frame` text/image content (a drawing page's `draw:frame` content model and geometry resolution — rotation included — are byte-for-byte identical to a presentation's, both resolved through `odf.js`'s own shared `readDrawFrame`), so there is no separate `OdgShape` class at all; the genuinely new work is the vector-primitive classes (no rotation, a per-kind attribute vocabulary: `svg:x`/`y`/`width`/`height` for rect/ellipse/path, `svg:x1`/`y1`/`x2`/`y2` for a line) and their own fill/stroke, which needed a small, self-contained graphic-family style writer (`src/edit/odg/style.ts`) since `odf.js`'s own `StyleRegistry` recognises `'graphic'` as a style family but its `StylePropertiesSchema` only ever models text/paragraph formatting — it has no fill/stroke fields and never emits a `style:graphic-properties` element. A path vector's own `svg:d` is generated by `src/edit/odg/svg-path.ts`, the write-side inverse of `odf.js`'s own `typed/shared/path.ts` parser — always absolute, always space-separated commands, anchoring `svg:viewBox` at `"0 0 {widthPt} {heightPt}"` so the written numbers are the exact source `ContentPathPoint` values with no rescaling arithmetic either way (see Gotchas below for the cross-check against that exact parser).
|
|
211
|
-
- **`src/
|
|
212
|
-
|
|
287
|
+
- **`src/mathml/`** — a MathML presentation-layer typesetting engine, comparable in scope to the standard-14 text-layout half of `src/pdf/` — genuinely self-contained: no import from `model`, `pdf`, or `odf.js` at all (not even `document-content-model`), matching `src/layout/`'s own "pure conversion algorithm" isolation one tier further down. `nodes.ts` defines `MathMlNode`/`MathMlElement` as a local, structurally-compatible mirror of `odf.js`'s own `XmlNode` (the same "mirror the shape, don't import the package" trick `src/interop.test.ts` already proves holds between `ooxml.js` and `odf.js`), so `odf.js`'s `readOdfFormula`'s real return value type-checks against it with zero cast. `variant.ts` maps `mathvariant` to the Unicode Mathematical Alphanumeric Symbols block (Latin/Greek/digits, including the block's own well-known Letterlike-Symbols hole-fillers — italic small h, eleven Script/Fraktur/Double-struck capitals — generated directly from Unicode's own `UnicodeData.txt`, not transcribed by hand). `operators.ts` is a deliberately bounded operator dictionary (lspace/rspace/stretchy/largeop/movablelimits per operator), not the MathML3 spec's own multi-thousand-entry table. `layout.ts` is the recursive box-model engine itself (`mrow`/`mi`/`mn`/`mo`/`mtext`/`mspace`/`msub`/`msup`/`msubsup`/`munder`/`mover`/`munderover`/`mfrac`/`msqrt`/`mroot`/`mtable`/`mtr`/`mtd`/`mstyle`/`semantics`, plus a text-content fallback with a diagnostic for anything else), driven entirely by the injected `MathFontMetrics` port (`metrics.ts`) rather than any font-parsing code of its own — `src/pdf/math-font.ts` is the real implementation. `compose.ts`/`radical.ts`/`length.ts` are its own small geometry helpers (baseline-offset box placement, a hand-drawn hooked radical sign built from line segments rather than a bare glyph substitute, MathML length-unit parsing). Output is a flat `MathBox` (positioned glyph runs, rules, and strokes, box-local top-left/y-down coordinates) — `src/pdf/math-content-write.ts` is the one consumer that turns it into PDF content-stream bytes.
|
|
288
|
+
- **`src/pdf/`** — the hand-written PDF codec, importing `model`/`bytes`/`image`/`mathml` (no OOXML knowledge at all):
|
|
289
|
+
- **Write**: `objects.ts` (the `PdfObject` discriminated union), `afm-widths.ts`/`encoding.ts`/`winansi.ts`/`fonts.ts` (standard-14 metrics, WinAnsi encoding, family resolution), `measure.ts`/`text-layout.ts` (greedy line-wrapping), `matrix.ts`, `content-write.ts` (`LayoutItem[]` → content-stream operators), `write.ts` (the full object graph, classic cross-reference table, trailer, and — when `WritePdfOptions.formulas` is non-empty — one embedded math composite font group, allocated once and shared across pages).
|
|
290
|
+
- **Embedded math font**: `sfnt.ts` (a generic sfnt table-directory reader), `math-cmap.ts`/`math-hmtx.ts`/`math-table.ts` (Unicode → glyph ID, per-glyph advance widths, and the OpenType `MATH` table's constants/glyph-info subtables — every offset cross-checked against the real vendored font while these were built, not transcribed from the spec alone), `math-font.ts` (parses and caches the vendored STIX Two Math font once per process, exposing a size-specific `MathFontMetrics` implementation), `math-font-write.ts` (builds the `/Type0`/`/CIDFontType0`/`/FontDescriptor`/`/FontFile3`/ToUnicode object group), `math-content-write.ts` (a `PositionedFormula[]` → PDF content-stream bytes, Identity-H 2-byte CIDs for text-showing, `re`/`m`/`l` operators for rules and the radical hook). See [Fidelity](#fidelity) for the CFF-full-embed (not glyph-subsetted) simplification this makes.
|
|
213
291
|
- **Read**: `lexer.ts`/`parse.ts` (byte tokenizer and tokens → `PdfObject`), `filters.ts`/`predictors.ts` (Flate/LZW/ASCII85/ASCIIHex/RunLength, TIFF/PNG predictors), `xref.ts`/`document.ts` (classic and cross-reference-stream resolution, object streams, `/Prev` chains, linear-scan recovery, the page tree with attribute inheritance), `content-read.ts`/`interpret.ts` (the content-stream tokenizer and graphics/text state machine, including form-XObject recursion), `cmap.ts`/`font-style.ts`/`font-read.ts` (`/ToUnicode` CMaps, font-dictionary resolution), `images-read.ts` (Image XObjects → PNG/JPEG bytes), `read.ts` (`readPdf`, assembling all of the above into a `LayoutDocument`).
|
|
214
292
|
- `codec.ts` — `pdfCodec`, a `z.codec()` pair over `readPdf`/`writePdf` (PDF bytes ⇄ `LayoutDocument`).
|
|
215
293
|
- **`src/ooxml/`** — resolves a `Package` into a `ContentDocument`: `docx/read.ts` and `pptx/read.ts` are now thin adapters over `ooxml.js`'s own `readDocx`/`readPptx`, wrapping their `{ metadata, sections }`/`{ metadata, slides }` result into `ContentDocument`'s `wordprocessing`/`presentation` shape. The docx style cascade (`docDefaults` → named-style `basedOn` chains → paragraph-mark run properties → character styles → direct formatting), the pptx placeholder → layout → master → theme inheritance cascade, and DrawingML geometry/colour resolution all now live upstream in `ooxml.js` itself, not in this package.
|
|
216
|
-
- **`src/odf/`** — the ODF-side counterpart to `src/ooxml/`, resolving an `odf.js` `Package` into a `ContentDocument`: `odt/read.ts`'s `readOdtContent` is a thin adapter over `odf.js`'s own `readOdt`, wrapping its `{ metadata, sections }` result into the identical `wordprocessing` shape `readDocxContent` produces — the concrete proof that odt and docx genuinely share one pivot and one layout engine. `odp/read.ts`'s `readOdpContent` is the same adapter over `odf.js`'s `readOdp`, wrapping `{ metadata, slides }` into the identical `presentation` shape `readPptxContent` produces. `ods/read.ts`'s `readOdsContent` wraps `odf.js`'s `readOds`'s `{ metadata, sheets }` into the `spreadsheet` `ContentDocument` variant, and `odg/read.ts`'s `readOdgContent` wraps `odf.js`'s `readOdg`'s `{ metadata, pages }` into the `drawing` variant — `odg` still has no OOXML-side sibling adapter at all (no drawing-equivalent OOXML format this package reads); `ods` now does, `ooxml.js`'s own `readXlsxContent`/`buildXlsxPackage`, consumed directly by `src/convert/convert.ts`'s `odsToXlsx`/`xlsxToOds` bridge (see below) but deliberately not re-exported from this package's own public surface, mirroring the `readDocx`/`readPptx` non-re-export choice above. `buildOdtPackage`/`buildOdpPackage`/`buildOdsPackage`/`buildOdgPackage` (`src/edit/{odt,odp,ods,odg}/content.ts`) each bridge a `ContentDocument` back to a fresh package built on that format's own live-view editor, closing the PDF → odt/odp/ods/odg direction (`pdfToOdt`/`pdfToOdp`/`pdfToOds`/`pdfToOdg` each call the matching one) — see the `pdfToOds` gotcha below for `buildOdsPackage`'s own printSettings-writing addition.
|
|
217
|
-
- **`src/layout/`** — the pure conversion algorithms, importing
|
|
218
|
-
- **`src/
|
|
294
|
+
- **`src/odf/`** — the ODF-side counterpart to `src/ooxml/`, resolving an `odf.js` `Package` into a `ContentDocument`: `odt/read.ts`'s `readOdtContent` is a thin adapter over `odf.js`'s own `readOdt`, wrapping its `{ metadata, sections }` result into the identical `wordprocessing` shape `readDocxContent` produces — the concrete proof that odt and docx genuinely share one pivot and one layout engine. `odp/read.ts`'s `readOdpContent` is the same adapter over `odf.js`'s own `readOdp`, wrapping `{ metadata, slides }` into the identical `presentation` shape `readPptxContent` produces. `ods/read.ts`'s `readOdsContent` wraps `odf.js`'s `readOds`'s `{ metadata, sheets }` into the `spreadsheet` `ContentDocument` variant, and `odg/read.ts`'s `readOdgContent` wraps `odf.js`'s `readOdg`'s `{ metadata, pages }` into the `drawing` variant — `odg` still has no OOXML-side sibling adapter at all (no drawing-equivalent OOXML format this package reads); `ods` now does, `ooxml.js`'s own `readXlsxContent`/`buildXlsxPackage`, consumed directly by `src/convert/convert.ts`'s `odsToXlsx`/`xlsxToOds` bridge (see below) but deliberately not re-exported from this package's own public surface, mirroring the `readDocx`/`readPptx` non-re-export choice above. `buildOdtPackage`/`buildOdpPackage`/`buildOdsPackage`/`buildOdgPackage` (`src/edit/{odt,odp,ods,odg}/content.ts`) each bridge a `ContentDocument` back to a fresh package built on that format's own live-view editor, closing the PDF → odt/odp/ods/odg direction (`pdfToOdt`/`pdfToOdp`/`pdfToOds`/`pdfToOdg` each call the matching one) — see the `pdfToOds` gotcha below for `buildOdsPackage`'s own printSettings-writing addition. `formula/read.ts`'s `readOdfFormulaContent`/`readOdfEmbeddedFormula` are the same thin-adapter pattern over `odf.js`'s own `readOdfFormula`, for a standalone `.odf` and an embedded sub-object respectively (the latter reading the sub-object's own `content.xml`/`meta.xml` directly out of the outer package's flat `Package.parts` record, no separate unzip step needed); `formula/detect.ts`'s `detectEmbeddedFormulaFrames` is genuinely new work with no `odf.js`-side equivalent at all — `odf.js`'s own `readDrawFrameContent` doesn't recognise a `draw:object`-bearing `draw:frame` yet, so `odt/read.ts` and `odp/read.ts` each run this as a second pass over the same package's raw `content.xml` to find and inject a formula's own placeholder block (see the Gotchas entry below for the exact scope and positioning caveats this second pass carries).
|
|
295
|
+
- **`src/layout/`** — the pure conversion algorithms, importing `model` and (for formula placement) `mathml`: `engine.ts` (`ContentDocument` wordprocessing → `LayoutDocument`: flow, line-breaking, pagination — fed identically by docx- and odt-sourced content; also returns `WordprocessingLayoutResult.formulas`, every embedded formula block it laid out via `src/mathml`'s `layoutFormula`, positioned in PDF page space — see the Gotchas entry below on why a formula can't become an ordinary `LayoutItem`), `slides.ts` (`ContentDocument` presentation → `LayoutDocument`: direct EMU-to-point placement, no pagination needed — fed identically by pptx- and odp-sourced content; also exports `convertShape`, the single-`ContentShape`-to-`LayoutItem[]` conversion `drawing.ts` below reuses verbatim, now optionally formula-aware via its own trailing `formulaContext` parameter so `drawing.ts`'s existing call site keeps compiling unchanged), `sheets.ts` (`ContentDocument` spreadsheet → `LayoutDocument`: resolve the print range, build cumulative column/row offsets skipping hidden ones, reserve header/repeat-row-column space, resolve an explicit or non-iterative fit-to-page scale, partition into column/row bands honouring manual breaks with the same "an oversized item gets its own band and overflows rather than looping" guarantee `engine.ts`'s `ensureRoom` documents, emit pages in `downThenOver`/`overThenDown` order, then per page paint backgrounds/gridlines/headers/cell text with default alignment by value kind and `###`/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts an `AbortSignal`, since a 50k-cell sheet needs cancellation where a docx/pptx page count never did), `drawing.ts` (`ContentDocument` drawing → `LayoutDocument`: one `ContentDrawPage` per PDF page, direct placement like `slides.ts`, with one new emission path — a `ContentVector` `rect`/`ellipse`/`line` maps onto the pre-existing `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds, and a `path` vector's local, viewBox-relative subpath points are resolved through the vector's own frame offset then a single page-space flip into a `LayoutPath` value; vectors paint before shapes, a documented, bounded choice — see this module's own top-of-file note — since `ContentDrawPageSchema` keeps `shapes` and `vectors` as two independently paint-ordered arrays with no field recording their relative order when the two genuinely overlap), `reconstruct.ts` (`LayoutDocument` → `ContentDocument`: `reconstructWordprocessing`/`reconstructPresentation` do baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs; `reconstructDrawing` does no clustering at all, since a drawing has no such structure to infer in the first place — every `LayoutItem` maps close to 1:1 back onto a `ContentVector` `rect`/`ellipse`/`line`/`path` or a `ContentShape`, in the exact z-order it was painted, bucketed into `ContentDrawPageSchema`'s own two independently-ordered `shapes`/`vectors` arrays the same way `drawing.ts` produced them; `reconstructSpreadsheet` tries a real gridline lattice first — scanning the page's `LayoutLine`/stroked-single-segment-`LayoutPath` items for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid, using those line positions directly as cell boundaries when found — and falls back to text-position clustering otherwise, reusing this same module's `clusterIntoLines` for rows and a parallel recurring-x-position generalisation of `clusterIntoParagraphs`'s own `dominantLeftX` for columns; every recovered cell is a bare string, column widths/row heights are genuinely measured from whichever geometry was used, and no print range/scale/repeat-rows/repeat-columns/manual-breaks are ever inferred).
|
|
296
|
+
- **`src/hsqldb/`** — `script.ts`, the Tier 1 `.odb` decoder: a small, bounded HSQLDB TEXT-script-format (`hsqldb.script_format=0`) DDL/DML text parser, not a database engine — `parseHsqldbScript(bytes)` extracts `CREATE TABLE`'s own column names/types and `INSERT INTO`'s own row values into `HsqldbTable[]`, tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwing `HsqldbScriptParseError` for anything matching neither list. Mirrors `src/pdf/`'s own isolation discipline: it imports only `document-content-model`'s `ContentCellValue` type, no odf.js `Package`/`XmlElement` knowledge at all — the caller is responsible for handing it raw bytes already extracted from a real `.odb` package.
|
|
297
|
+
- **`src/odb/`** — the decoder-selection and pivot-mapping layer sitting between odf.js's `.odb` support and `src/hsqldb/`: `read.ts`'s `readOdbTables(pkg)` calls odf.js's own `readOdbInventory` to classify the package's connection (throwing `OdbNoEmbeddedDataSourceError` for an external-only datasource) and its embedded engine (throwing `OdbUnsupportedFormatError`, naming Firebird or HSQLDB's own binary/compressed script formats explicitly, for anything Tier 1 doesn't cover), then routes a genuine HSQLDB TEXT script to `parseHsqldbScript`. `spreadsheet.ts`'s `odbTablesToSpreadsheetDocument` maps `HsqldbTable[]` onto the same `ContentSheet`-based `ContentDocument` spreadsheet variant `readOdsContent`/`buildOdsPackage` already produce and consume, feeding `odbToXlsx`'s call into `buildXlsxPackage` directly. `csv.ts`'s `buildOdbTableCsv` writes exactly one named table as CSV bytes, with no `ContentSheet`/xlsx machinery involved at all, throwing `OdbTableNotSpecifiedError`/`OdbTableNotFoundError` (naming every available table) when the caller's own `table` option doesn't resolve to exactly one table.
|
|
298
|
+
- **`src/convert/`** — `convert.ts` (the twelve PDF-pivot round-trip ergonomic wrappers, a dedicated "Six cross-format bridges" section: `odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, each a direct `readXContent` → `buildYPackage` composition bypassing PDF entirely — see [Fidelity](#fidelity) — `odmToPdf`, the one further conversion shaped around a caller-supplied `resolveSubDocument` callback rather than being purely bytes-in/bytes-out, since a `.odm` master document's own chapters are external references odf.js's `readOdm` never inlines — see Gotchas — `odbToXlsx`/`odbToCsv`, thin compositions over `readOdbTables` and `src/odb/`'s own pivot/CSV mapping, and `odfToPdf`, a standalone `.odf` formula document → PDF via `readOdfFormulaContent` → `src/mathml`'s `layoutFormula` → `writePdf`'s own formula-aware option, with no reverse `pdfToOdf` at all), `codec.ts` (`docxPdfCodec`/`pptxPdfCodec`/`odtPdfCodec`/`odpPdfCodec`/`odsPdfCodec`/`odgPdfCodec` plus `odtDocxCodec`/`odpPptxCodec`/`odsXlsxCodec`, a `z.codec()` pair over each — `odmToPdf`/`odbToXlsx`/`odbToCsv`/`odfToPdf` have no codec of their own, for the same fixed-signature/one-directional reasons each has no port entry, or a one-way port entry, below), `port.ts`/`local.ts` (the swappable `DocumentConverter` contract and its synchronous local implementation, covering `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`odf` → `pdf`, `pdf` → `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`, and the six bridge pairs — `DocumentFormat` includes `xlsx` for exactly this reason, even though xlsx has no PDF conversion of its own; `odm` and `odb` are deliberately not `DocumentFormat` members, since neither `odmToPdf` nor `odbToXlsx`/`odbToCsv` is wired into this port at all; `odf` IS a member, but with only the one `odf → pdf` entry — no `pdf → odf`). Every conversion function that builds a `ContentDocument`/`LayoutDocument` internally (the twelve PDF-pivot conversions and the six bridges; `odfToPdf` accepts but never invokes it) also accepts an `onDocument` callback, and `ConversionResult` carries the same value through the port as an optional `package` field — the full `DocumentPackage` (content + layout, from `document-content-model`) that conversion built, not just its target bytes.
|
|
219
299
|
|
|
220
|
-
Dependency direction is strictly downward and checkable: `model`/`bytes` import nothing local; `image` imports `bytes` only; `pdf` imports `model`+`bytes`+`image
|
|
300
|
+
Dependency direction is strictly downward and checkable: `model`/`bytes`/`mathml` import nothing local (`mathml` is fully self-contained — no dependency on `model`, `document-content-model`, or any ODF/PDF package, since it consumes only its own locally-mirrored `MathMlNode` input and its own injected `MathFontMetrics` port); `image` imports `bytes` only; `pdf` imports `model`+`bytes`+`image`+`mathml`; `ooxml/*` imports `xml`/`model` only (no PDF knowledge); `odf/*` imports `model` only (no PDF knowledge, no `xml/*` — `odf.js` already owns its own XML query helpers); `hsqldb` imports `document-content-model` only (no odf.js knowledge); `layout` imports `model`+`mathml`; `odb` imports `hsqldb`+`model`+odf.js only; `convert` composes everything else. No `PdfObject`/`PdfDict`/`PdfStream` type appears outside `src/pdf/`.
|
|
221
301
|
|
|
222
302
|
## Build, test, and lint
|
|
223
303
|
|
|
@@ -227,7 +307,7 @@ pnpm typecheck # tsc --noEmit
|
|
|
227
307
|
pnpm lint # eslint . --max-warnings 0
|
|
228
308
|
pnpm test # vitest run --project unit
|
|
229
309
|
pnpm test:watch # vitest --project unit
|
|
230
|
-
pnpm test:smoke # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise,
|
|
310
|
+
pnpm test:smoke # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise, a real createOdg/odgToPdf/pdfToOdg round trip (a curved path, a filled rect, and text, built entirely through the odg live-view editor, converted to PDF and reconstructed back to odg via reconstructDrawing), and a real odfToPdf conversion (a fraction, rendered via the embedded STIX Two Math font -- checked by confirming the built PDF contains a real /Type0/Identity-H/CIDFontType0C font resource, proving the base64-embedded font asset itself survived the tsdown build), from the built CJS bundle
|
|
231
311
|
pnpm test:corpus # optional real-world PDF conformance checks against a local, gitignored test/corpus/ (see Fidelity)
|
|
232
312
|
```
|
|
233
313
|
|
|
@@ -247,6 +327,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
247
327
|
|
|
248
328
|
- **`ooxml.js`'s typed readers (`readDocx`/`readPptx`) are now the actual basis for conversion** — `readDocxContent`/`readPptxContent` are thin wrappers around them, not an independent walk of `word/document.xml`/`ppt/slides/slideN.xml`. They are still deliberately not re-exported from this package's own public surface: `readDocx`/`readPptx` also carry `comments`/`footnotes`/`headers`/`footers` (docx) that `ContentDocument` doesn't model, so exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models.
|
|
249
329
|
- **The docx⇄PDF and pptx⇄PDF conversions are explicitly not round-trip-lossless** — in deliberate contrast to `ooxml.js`'s own `packageCodec`, which is byte/part-faithful by design. See [Fidelity](#fidelity). The six cross-format bridges below (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`) are a genuinely different case — see the [Fidelity](#fidelity) section's own paragraph on them.
|
|
330
|
+
- **A `DocumentPackage` returned via `onDocument`/`ConversionResult.package` is a snapshot from that one conversion pass, not a live view** — its `layout` correlates with its `content` only as of the exact read+layout that produced it (`document-content-model`'s own `DocumentPackageSchema` doc comment), so if a caller mutates the returned `content` afterwards, the `layout` sitting alongside it silently goes stale; nothing in this package (or `document-content-model`) detects or rejects that.
|
|
250
331
|
- **Building the six cross-format bridges surfaced two real, previously-undiscovered gaps in existing `populateParagraph` write paths, both now fixed.** `buildDocxPackage`'s `populateParagraph` (`src/edit/docx/content.ts`) never wrote a paragraph's own `list` membership back (`ContentParagraph.list`, docx's flat `numId`/`level` model) — only read, never written, since no existing caller had ever round-tripped a list-bearing paragraph through it. `buildOdtPackage`'s `populateParagraph` (`src/edit/odt/content.ts`) never wrote a paragraph's own `styleId` back at all (`readOdtContent`/`readOdfParagraph` in `odf.js` reads it unconditionally from `text:style-name`, but nothing on the write side ever set that attribute). Both are now fixed: `DocxParagraph.list` is set unconditionally alongside `styleId`/`alignment`, matching that function's own existing pattern; `OdtParagraph.styleId` is set conditionally alongside `alignment`, matching odt's own local convention. `buildOdtPackage` additionally gained `appendBlocks`/`appendListRun` (`src/edit/odt/content.ts`) — ODF has no flat per-paragraph list property to set the way docx does, so a run of consecutive `ContentParagraph`s sharing `list.numId` is grouped and written as a real, potentially multi-level `text:list`/`text:list-item` tree via `OdtList`/`OdtListItem`, the structural inverse of `odf.js`'s own list-reading (a fresh `text:list` per `numId` change, one level of nesting per `list.level` step, descending only one level at a time since ODF can only open a nested list from inside an existing item). Both gaps were invisible before this task specifically because nothing had previously round-tripped a list-bearing paragraph or a styled paragraph through `docx ⇄ odt` at all — the PDF-pivot conversions never exercised `buildDocxPackage`/`buildOdtPackage` on content read back from the OTHER format.
|
|
251
332
|
- **A table shape inside an odp slide does not survive `odpToPptx`.** `buildPptxPackage`'s `appendShape` (`src/edit/pptx/content.ts`) silently drops any non-paragraph block found inside a shape's own text-box loop — a scope choice whose own comment ("PDF-reconstructed shapes never mix kinds") assumed its only caller was the PDF-reconstruction path, where that is true. `odpToPptx` is a second, non-PDF-reconstructed caller for which it is not: a real odp `draw:frame` containing a `table:table` directly (not inside a text box) reads as a `ContentShape` with a `'table'` block, and that block is silently dropped, leaving an empty pptx text box where the table was. Everything else on the same slide — a rotated shape, grouped shapes, an image, speaker notes — survives correctly (see `src/convert/bridges.test.ts`'s own dedicated fidelity-gap test, which proves both halves against the existing `minimalOdpBytes()` fixture). A real, tracked, bounded gap, not a silent one: closing it means teaching `buildPptxPackage`/`buildOdpPackage` to write a real table into a slide shape, a materially larger feature than this bridge's own scope.
|
|
252
333
|
- **The `ods ⇄ xlsx` bridge inherits several real, format-boundary fidelity limits from `ooxml.js`'s brand-new `readXlsxContent`/`buildXlsxPackage`, on top of its own pivot-copy design.** xlsx has no `percentage`/`currency` cell type of its own (both are a plain numeric cell plus a number-format style neither this reader nor this writer interprets) — an ods `percentage`/`currency` cell survives the `odsToXlsx` hop with its numeric *value* intact but downgrades to a plain `number` *kind*, permanently (currency's own currency code is dropped outright). xlsx also has only one rare `t="d"` cell type covering BOTH date and time — an ods `time` cell survives as a `date`-kind cell carrying its original value string verbatim, but mislabelled; an ods `date` cell is unaffected (it was already the kind xlsx's own `t="d"` maps onto). A formula (`table:formula`/`<f>`) is carried completely verbatim in both directions — never parsed, translated, or evaluated by either this package's own reader or writer — but a REAL spreadsheet application does evaluate a workbook's own `<f>`/`table:formula` on open: confirmed against genuine LibreOffice 26.2, an ods formula authored in OpenFormula syntax (`of:=[.B2]*2`) becomes a formula ERROR (`Err:510`) when the bridged xlsx is opened in real Calc, even though the formula's own cached value is still present and correctly readable via `readXlsxContent` — going the other way is less fragile in practice only because a genuine xlsx formula (bare Excel A1 syntax, e.g. `B2*2`) happens to still parse under LibreOffice's own more lenient, backward-compatible ODF formula grammar, not because of anything this bridge does differently in either direction. Column widths survive the `odsToXlsx` hop within roughly a pixel of rounding tolerance (see `src/convert/bridges.test.ts`'s own `COLUMN_WIDTH_TOLERANCE_PT`) but are then dropped entirely on the return `xlsxToOds` hop — not a character-width-unit rounding loss, but `buildOdsPackage` not writing `ContentSheetColumn.widthPt` at all, a pre-existing, already-documented gap in that file's own module comment, unrelated to and unfixed by this bridge. A boolean cell written by `buildXlsxPackage` renders as a raw `1`/`0` rather than `TRUE`/`FALSE` when opened in real Excel/Calc, since that writer's own genuinely-minimal `xl/styles.xml` (one default cell format, no boolean-specific number format) has nothing else to apply — the underlying `{ kind: 'boolean', value: true }` is still read back correctly by `readXlsxContent` regardless; this is a real-application *display* gap, not a data-fidelity one. `readXlsxContent`'s own cell.value.kind never produces `'error'` from an odf.js-sourced document at all, for a structural reason rather than a bug: ODF's `office:value-type` enumeration has no `error` member, so `OdsCell.value`'s own write-side choice for a `kind: 'error'` cell is to write it as a genuine, non-empty `office:string-value` carrying the error's own text — an `xlsxToOds` → `odsToXlsx` round trip of a genuine xlsx `t="e"` error cell therefore turns it into a plain `string` cell carrying the identical text; the message survives, the `error` semantic does not.
|
|
@@ -274,13 +355,25 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
274
355
|
- **Table cell `colSpan`/`rowSpan` and pptx shape rotation are read from a `ContentDocument` but not yet written back** by `buildDocxPackage`/`buildOdtPackage`/`buildPptxPackage` — a merged cell round-trips as an ordinary unmerged one, and a rotated *pptx* shape round-trips unrotated (`buildOdpPackage` does not share the rotation half of this gap — see the `OdpShape.rotationDeg` gotcha above). Both are bounded, tracked gaps (the cell's own text content and the shape's own position are still correct), not silent ones.
|
|
275
356
|
- **docx headers/footers, live `PAGE`/`NUMPAGES` field substitution, and inline images are not read** by `readDocxContent` — a deliberate, tracked scope narrowing from the original design, not an oversight.
|
|
276
357
|
- **pptx speaker notes survive `pptxToPdf`/`pdfToPptx`, but not through any real PDF feature.** PDF has no native concept of hidden presenter notes, so `convertPresentationToLayout` carries `ContentSlide.notes` as a hidden `/Subtype /Text` annotation on the page (the same construct Acrobat's own sticky-note tool uses, marked with the `Hidden` annotation flag so it never renders or prints), and `reconstructPresentation` reads it back via a `/T` marker that distinguishes this package's own notes annotation from a genuine third-party sticky note. This is a round-trip mechanism specific to this package's own writer/reader pair — a PDF produced by anything else will never carry it, and a PDF consumer other than this package's own `readPdf` will never see it as anything but an invisible, empty sticky note.
|
|
277
|
-
- **`odmToPdf` is the one conversion in this package that is not purely bytes-in/bytes-out.** A `.odm` (ODF master document) never carries its own chapters' content — each `text:section` is a bare external reference (`text:section-source`'s `xlink:href` + `text:filter-name`) to a standalone `.odt` file, confirmed against real, unmodified LibreOffice 26.2 output while building `odf.js`'s own `readOdm`: a self-closing `text:section-source` with no `xlink:show`/`xlink:type`, no manifest entry for the linked part, and no chapter text anywhere in the master document's own `content.xml`. There is consequently no way for `odmToPdf` to read a chapter's content from the `.odm` bytes alone — it takes an `options.resolveSubDocument` callback, called once per section with that section's own `href`, to hand back the chapter's own `.odt` bytes. Every section left unresolved (no callback given, or the callback returns `undefined` for that `href`) is collected across the *whole* document before anything throws, and reported together in one `OdmUnresolvedSectionError` naming every unresolved `href` — not just whichever section the read loop happened to reach first. `odmToPdf` is consequently not one of the twelve round-trip conversions or six bridges above, and is deliberately not wired into the `DocumentConverter` port either: that port's `convert(request, options)` contract is a fixed single-bytes-in/bytes-out shape, and widening it with a resolver parameter for this one format would leak an odm-specific concern into every other conversion's own request shape — a caller wanting `odmToPdf` behind the port can wrap it in their own adapter. `OdmSection.inlineContent` (declared by `odf.js`'s own `readOdm` for schema-completeness, covering a producer that caches a chapter's content inline rather than only linking it) is handled too, via the same `readOdfParagraph`/`readOdfTable` primitives `odf.js`'s own `readOdt` calls internally — but the installed `odf.js` 1.
|
|
358
|
+
- **`odmToPdf` is the one conversion in this package that is not purely bytes-in/bytes-out.** A `.odm` (ODF master document) never carries its own chapters' content — each `text:section` is a bare external reference (`text:section-source`'s `xlink:href` + `text:filter-name`) to a standalone `.odt` file, confirmed against real, unmodified LibreOffice 26.2 output while building `odf.js`'s own `readOdm`: a self-closing `text:section-source` with no `xlink:show`/`xlink:type`, no manifest entry for the linked part, and no chapter text anywhere in the master document's own `content.xml`. There is consequently no way for `odmToPdf` to read a chapter's content from the `.odm` bytes alone — it takes an `options.resolveSubDocument` callback, called once per section with that section's own `href`, to hand back the chapter's own `.odt` bytes. Every section left unresolved (no callback given, or the callback returns `undefined` for that `href`) is collected across the *whole* document before anything throws, and reported together in one `OdmUnresolvedSectionError` naming every unresolved `href` — not just whichever section the read loop happened to reach first. `odmToPdf` is consequently not one of the twelve round-trip conversions or six bridges above, and is deliberately not wired into the `DocumentConverter` port either: that port's `convert(request, options)` contract is a fixed single-bytes-in/bytes-out shape, and widening it with a resolver parameter for this one format would leak an odm-specific concern into every other conversion's own request shape — a caller wanting `odmToPdf` behind the port can wrap it in their own adapter. `OdmSection.inlineContent` (declared by `odf.js`'s own `readOdm` for schema-completeness, covering a producer that caches a chapter's content inline rather than only linking it) is handled too, via the same `readOdfParagraph`/`readOdfTable` primitives `odf.js`'s own `readOdt` calls internally — but the installed `odf.js` 1.10.0 never actually populates it for any real document `readOdm` was tested against, so this branch is exercised only by a directly-constructed `OdmSection` in this package's own test suite, not by any `.odm` fixture.
|
|
359
|
+
- **`.odb` never gets PDF conversion, and never will.** A `.odb`'s own Reports are live SQL-backed layouts — rendering one faithfully means actually executing its query against a real database engine, categorically out of scope for a hand-written codec that never runs SQL. `readOdbTables`/`odbToXlsx`/`odbToCsv` extract table *data*, not the database's own forms/reports/queries (whose *names* `odf.js`'s `readOdbInventory` surfaces, but never their content — see `odf.js`'s own implementation notes).
|
|
360
|
+
- **Only HSQLDB's TEXT script format (`hsqldb.script_format=0`) is implemented.** `readOdbTables` detects and *names* two further shapes it does not implement, rather than silently returning no tables: HSQLDB's own BINARY (`hsqldb.script_format=1`) and COMPRESSED (`hsqldb.script_format=3`, gzip) script formats, and a Firebird-backed embedded `.odb` (`database/firebird.fbk`-style opaque storage, LibreOffice's other bundled embedded engine) — each throws `OdbUnsupportedFormatError` with a `format` field naming exactly which. An external-only connection (no embedded engine at all — MySQL/PostgreSQL/JDBC/ODBC) is a third, *permanent* scope boundary, not a missing tier: `readOdbTables` throws `OdbNoEmbeddedDataSourceError` rather than attempting anything network-facing.
|
|
361
|
+
- **STIX Two Math (the embedded formula font) is a CFF-flavoured OpenType font (an `OTTO` sfnt wrapping a `CFF ` table), not TrueType/glyf** — confirmed by inspecting the vendored font's own sfnt table directory while `src/pdf/math-font.ts` was built; the design plan this feature was built against assumed glyf and asked to confirm which and handle accordingly. Genuine Type2-charstring glyph subsetting (re-encoding charstrings, rebuilding the CFF `INDEX` structures with a renumbered, minimal glyph set) is a substantially larger undertaking than TrueType glyf/loca subsetting, and is out of scope for this pass: the **entire** `CFF ` table is embedded verbatim, unmodified, as a single `/FontFile3` `/Subtype /CIDFontType0C` stream — a real, correct, working embedded font, just not glyph-subsetted. Everything else genuinely IS built from a targeted parse of only what's used: `cmap` resolves exactly the Unicode code points a document's formulas actually reference to glyph IDs, and the emitted `/W` widths array and ToUnicode CMap only ever cover those same glyph IDs, not the font's full ~5,500-glyph repertoire. A CID-keyed composite font built this way needs no `/CIDToGIDMap` at all (that key exists only for `/CIDFontType2`): per ISO 32000-1 9.7.4.2, a `/CIDFontType0` whose `/FontFile3` is a "bare" (non-CID-keyed) CFF program is read with CID treated as directly indexing the CFF's own `CharStrings` INDEX by glyph order — i.e. CID == GID — exactly the numbering `cmap`-derived glyph IDs already use, so Identity-H text-showing needs no further remapping anywhere in the write path.
|
|
362
|
+
- **The OpenType `MATH` table's `MathVariants` subtable (stretchy glyph assembly — building a tall parenthesis or brace from reusable top/middle/bottom/extender pieces) is deliberately not parsed.** A stretchy fence (`(`/`)`/`[`/`]`/`{`/`}`/`|`/`‖`) or a stretchy `<mo>` wrapping a tall construct (a large fraction, a tall matrix) always renders at its own base glyph's fixed size, not dynamically resized to its content's own height — a documented, honest fallback the task's own design plan explicitly allowed for when the full variants subtable proves too large a sub-scope. The `MathConstants` subtable (every fraction/radical/script-positioning constant this package's own layout engine actually uses) and the `MathGlyphInfo` subtable (italics correction, top-accent attachment) ARE both genuinely parsed in full — see `src/pdf/math-table.ts`.
|
|
363
|
+
- **A token element's (`mi`/`mn`/`mo`/`mtext`) own box height comes from the font's nominal design ascent/descent (`hhea`'s own `ascender`/`descender`), not a tight per-glyph ink bounding box.** `src/mathml/` never parses glyph outlines (no `glyf`/CFF charstring geometry extraction anywhere in this package — see the CFF-embedding gotcha above), so every token run shares one uniform vertical extent regardless of which characters it actually contains. Accurate enough for box-model layout (spacing, baseline alignment, page placement) but not pixel-tight around an unusually tall or shallow glyph.
|
|
364
|
+
- **The MathML operator dictionary (`src/mathml/operators.ts`) is a deliberately bounded ~60-entry table, not the MathML3 specification's own multi-thousand-entry, form-dependent (prefix/infix/postfix) one.** It covers arithmetic, relational, set/logic, calculus big-operators, fences, and punctuation — the operators real formulas overwhelmingly use — with one entry per character regardless of which position it appears in, falling back to a single sane infix-shaped default (thick-space spacing, no stretch/largeop/movablelimits) for anything else.
|
|
365
|
+
- **`mover`/`munder`/`munderover` centre an over/under-script geometrically over the wider of the two boxes, not at the base glyph's own font-declared accent-attachment point (`MathTopAccentAttachment`, which the embedded font's `MathGlyphInfo` subtable DOES carry and this package DOES parse — see the CFF-embedding gotcha above — just not consumed here).** Visually correct for the common case of a single-character base (geometric centre ≈ optical centre for a roughly symmetric glyph); measurably different only for a multi-character or asymmetric base under a genuine `accent="true"` mark. A real, bounded simplification, not a data gap — the metric this would need is already being parsed for a different purpose.
|
|
366
|
+
- **Greek `mathvariant` mapping covers the plain alphabet plus nabla (∇) and partial differential (∂), not the OpenType/Unicode Greek "symbol variant" set** (epsilon/theta/kappa/phi/rho/pi symbol glyphs — `ϵ`/`ϑ`/`ϰ`/`ϕ`/`ϱ`/`ϖ` styled to e.g. bold). Latin letters, digits, and the two named symbols above are fully covered, generated directly from Unicode's own `UnicodeData.txt` (see `src/mathml/variant.ts`'s own generation note) rather than transcribed by hand.
|
|
367
|
+
- **Embedded-formula detection inside odt/odp is genuinely new work with no `odf.js`-side equivalent (`readDrawFrameContent` doesn't recognise a `draw:object`-bearing `draw:frame` at all yet — see the `src/odf/` architecture entry above), and each format's own detection carries its own real, bounded scope narrowing.** For **odt** (`src/odf/odt/read.ts`): only a `draw:frame` that is a *direct child of `office:text`* is detected — a formula anchored inline inside a paragraph's own run content, or nested inside a `draw:g` group, is not. Detected formulas are appended to the **end** of the section's own `blocks` array, in the order their frames appear in the document, not interleaved at their true original position among the paragraphs/tables `odf.js`'s own reader already produced — true positional interleaving would need per-element block-count bookkeeping this adapter doesn't have (a `text:list`, for instance, unwraps into many `ContentParagraph` blocks from one raw XML element, so "one raw child = one block" doesn't hold in general). For **odp** (`src/odf/odp/read.ts`): only a top-level `draw:frame` on a `draw:page` with no `draw:g` sibling at all is detected (a slide containing any group is skipped entirely for formula detection, to avoid mismatching a formula onto the wrong shape) — but where it IS detected, position is exact, not appended: `odf.js`'s own `walkDrawShapes` already produces exactly one `ContentShape` per top-level `draw:frame` in document order, so the Nth frame maps precisely onto `shapes[N]`. **ods embedded-formula detection is not implemented at all** — `odf.js`'s `readOds` has no existing floating-drawing/anchor-resolution mechanism (`ContentSheetImage`/`ContentSheet.embeddedObjects` are both already-known, pre-existing unpopulated gaps this task does not newly create — see the `ContentSheetCellSchema` gotcha above for the sibling gap on the write side), so there is no `readDrawFrame`-equivalent entry point to hook a formula-frame scan onto the way odt/odp have; `src/layout/sheets.ts` accordingly has no formula-handling branch at all, with a comment marking why.
|
|
368
|
+
- **A formula that isn't rendered as real MathML (an odm chapter's own embedded formula, or any formula crossing the `odtToDocx`/`docxToOdt`/`odpToPptx`/`pptxToOdp` bridges) survives only as its own plain-text placeholder** — the formula's StarMath annotation if it had one, or the literal `[formula]` otherwise (see `src/odf/formula/placeholder.ts`). `odmToPdf`'s own per-chapter `readOdtContent` call discards that chapter's own `formulas` map entirely (re-keying every formula's `sourcePath` against the final combined document's own renumbered block indices is a materially larger undertaking than this task's own scope, and `.odm` has no confirmed real-world test fixture to validate it against regardless — see the `odmToPdf` gotcha below). `buildDocxPackage` has no MathML-writing path of its own (OOXML's own math markup, OMML, is a different vocabulary this package does not write), so the six cross-format bridges never consult a formula's real MathML either, even when bridging between two formats that both, individually, support real formula rendering elsewhere in this package.
|
|
278
369
|
- **`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.
|
|
279
370
|
|
|
280
371
|
## Fidelity
|
|
281
372
|
|
|
282
373
|
**docx/pptx/odt/odp/ods/odg → PDF** is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, `basedOn` chains, placeholder inheritance for docx/pptx; `style:default-style`/`style:parent-style-name` chains for odt/odp). `odg` renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF `m`/`l`/`c`/`h` content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful **visual approximation**, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — see the standard-14 font substitution gotcha above.
|
|
283
374
|
|
|
375
|
+
**odf → PDF (`odfToPdf`), and a formula embedded inside odt/odp,** render **faithful mathematical typesetting**, not a static image or a plain-text placeholder: real box-model layout (script/limit positioning, fraction/radical geometry with correct rule thickness, table column alignment, `mathvariant` → Mathematical Alphanumeric Symbols mapping) through the embedded STIX Two Math font, with genuine per-glyph metrics (advance width, italic correction, top-accent attachment) and font-wide layout constants (axis height, fraction/radical rule thickness and gaps, script shift amounts) parsed directly from that font's own `MATH` table — not approximated or hand-tuned. The honest limits: stretchy delimiters render at a fixed size rather than dynamically assembling to their content's own height (the `MathVariants` subtable isn't parsed), a token's own box height comes from the font's nominal ascent/descent rather than a tight per-glyph ink bound, `mover`/`munder` centre geometrically rather than at the font's own declared accent-attachment point, and the operator dictionary and Greek `mathvariant` mapping each cover a deliberately bounded, common-case set rather than the full specification — see the Gotchas entries above for the exact boundary of each. **`pdfToOdf` (PDF → structured MathML) is not attempted, on either direction** — recovering a semantic operator tree (is this pair of glyphs a fraction, or a coincidentally stacked pair of ordinary characters? is a raised glyph a superscript, or just a smaller font size used for emphasis?) from nothing but positioned glyphs and paths is a categorically different, OCR-adjacent problem, with no geometry-reconstruction analogue anywhere else in this package: `reconstructWordprocessing`/`reconstructPresentation` recover paragraph/shape *structure* from geometry, never semantic *meaning* the way recognising a fraction would require.
|
|
376
|
+
|
|
284
377
|
**PDF → docx/pptx/odt/odp** is necessarily a **best-effort reconstruction** from geometry: a PDF page is just positioned glyphs and images, with no semantic paragraph or shape structure to recover. Reading order, bold/italic/colour/font-size, and page/slide count are preserved; paragraph and text-block boundaries are inferred from baseline spacing and left-margin indentation, not recovered exactly.
|
|
285
378
|
|
|
286
379
|
**PDF → odg** (`reconstructDrawing`) is a best-effort reconstruction too, but for the opposite reason: not because a drawing's structure is hard to infer, but because a drawing has no semantic structure to infer at all, so there is no clustering step to get right or wrong in the first place. Every recovered `LayoutItem` maps close to 1:1 onto a `ContentVector`/`ContentShape`, in the exact order it was painted. What is genuinely lossy is upstream of `reconstructDrawing`, in what a PDF's own content-stream operators can even preserve: position, size, and fill/stroke colour survive within ordinary floating-point tolerance regardless of vector kind, but a filled-and-stroked rect, any ellipse, and any line each come back as a generic `path` vector rather than their original kind, since PDF has no native rect/ellipse/line primitive beyond one narrow fast-path case — see the `reconstructDrawing` gotcha above for the exact boundary. The one place this genuinely reorganises content rather than just approximating it: a single wrapped multi-line text box comes back as several separate single-line text boxes, one per line PDF's own line-wrapper produced, since `reconstructDrawing` maps one `LayoutText` item to one shape with no clustering — the text survives, its original grouping into one box does not. Verified against real LibreOffice 26.2, not merely against this package's own reader: a richly-varied `.odg` (overlapping rects, a filled-and-stroked ellipse, a stroked line, a filled-and-stroked Bezier curve, a wrapped text label) round-tripped through `odgToPdf` then `pdfToOdg` opens as a valid drawing with correct position, colour, and z-order throughout, the curve genuinely curved rather than polygon-approximated, and only the vector-kind-narrowing and text-splitting above visibly distinguishing it from the source.
|
|
@@ -307,7 +400,8 @@ Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), e
|
|
|
307
400
|
|
|
308
401
|
- [ooxml.js](https://github.com/ExaDev/ooxml.js) — the sibling package this depends on for all docx/pptx/xlsx ⇄ JSON handling and cascade-resolved typed reading, including its own `readXlsxContent`/`buildXlsxPackage` (a `ContentDocument`-shaped xlsx reader/writer pair), consumed directly by `src/convert/convert.ts`'s `odsToXlsx`/`xlsxToOds` bridge but not re-exported from this package's own public surface.
|
|
309
402
|
- [document-content-model](https://github.com/ExaDev/document-content-model) — the sibling package that owns `ContentDocument`/`LayoutDocument` themselves; both `ooxml.js` and `documents.js` import from it rather than each maintaining an independent copy.
|
|
310
|
-
- [odf.js](https://github.com/ExaDev/odf.js) — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on `document-content-model`. A dependency of `documents.js` for: this package's `Odt`/`Ods`/`Odp`/`OdgBytesSchema` (`src/model/bytes.ts`), which validate against its `ODF_MEDIA_TYPES` table; `src/interop.test.ts`, a type-level guard that `ooxml.js`'s and `odf.js`'s raw `XmlElement`/`XmlNode`/`Attribute`/`Package` container types stay structurally compatible; `src/odf/odt/read.ts`'s `readOdtContent`, a thin adapter over `odf.js`'s own `readOdt`, feeding `odtToPdf`/`pdfToOdt` (`src/convert/convert.ts`); `src/odf/odp/read.ts`'s `readOdpContent`, the same adapter over `odf.js`'s `readOdp`, feeding `odpToPdf`/`pdfToOdp`; `src/odf/ods/read.ts`'s `readOdsContent`, the same adapter over `odf.js`'s `readOds`, feeding `odsToPdf`/`pdfToOds`, and reused directly by `src/edit/ods/print-settings.ts`'s own `readSheetPrintSettings` (`findStyleElement`/`resolvePageLayoutProperties`/`parsePageSize`/`parseMargins`, the same style-chain-resolution primitives `readOds`'s own `readPrintSettings` is built on); `src/odf/odg/read.ts`'s `readOdgContent`, the same adapter over `odf.js`'s `readOdg` — including its own `typed/shared/path.ts`, the real-LibreOffice-output-verified `svg:d`/`draw:points` parser this package's `writePath` content is ultimately sourced from, and which `src/edit/odg/svg-path.ts`'s `buildSvgPathData` (the write-side inverse) also cross-checks its own output against directly — feeding `odgToPdf`/`pdfToOdg` (the latter re-reading a rebuilt package's own real geometry through this same `readOdg`, not just writing one); `src/edit/odt/*`'s `StyleRegistry`/`resolveStyle` (style interning), `src/edit/odp/shape.ts`'s `applyOdfTransform`/`resolveOdfShapeGeometry` (rotation), and `src/edit/odt/automatic-styles.ts`'s `ensureAutomaticStyles`/`nextStyleName` (reused by `src/edit/odg/style.ts`'s own graphic-family style writer and `src/edit/ods/print-settings.ts`'s own page-layout/master-page/table-style minting), all consumed directly rather than reimplemented. odt, odp, ods, and odg → `ContentDocument` reading and PDF conversion are now all integrated both ways.
|
|
403
|
+
- [odf.js](https://github.com/ExaDev/odf.js) — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on `document-content-model`. A dependency of `documents.js` for: this package's `Odt`/`Ods`/`Odp`/`OdgBytesSchema` (`src/model/bytes.ts`), which validate against its `ODF_MEDIA_TYPES` table; `src/interop.test.ts`, a type-level guard that `ooxml.js`'s and `odf.js`'s raw `XmlElement`/`XmlNode`/`Attribute`/`Package` container types stay structurally compatible; `src/odf/odt/read.ts`'s `readOdtContent`, a thin adapter over `odf.js`'s own `readOdt`, feeding `odtToPdf`/`pdfToOdt` (`src/convert/convert.ts`); `src/odf/odp/read.ts`'s `readOdpContent`, the same adapter over `odf.js`'s `readOdp`, feeding `odpToPdf`/`pdfToOdp`; `src/odf/ods/read.ts`'s `readOdsContent`, the same adapter over `odf.js`'s `readOds`, feeding `odsToPdf`/`pdfToOds`, and reused directly by `src/edit/ods/print-settings.ts`'s own `readSheetPrintSettings` (`findStyleElement`/`resolvePageLayoutProperties`/`parsePageSize`/`parseMargins`, the same style-chain-resolution primitives `readOds`'s own `readPrintSettings` is built on); `src/odf/odg/read.ts`'s `readOdgContent`, the same adapter over `odf.js`'s `readOdg` — including its own `typed/shared/path.ts`, the real-LibreOffice-output-verified `svg:d`/`draw:points` parser this package's `writePath` content is ultimately sourced from, and which `src/edit/odg/svg-path.ts`'s `buildSvgPathData` (the write-side inverse) also cross-checks its own output against directly — feeding `odgToPdf`/`pdfToOdg` (the latter re-reading a rebuilt package's own real geometry through this same `readOdg`, not just writing one); `src/odf/formula/read.ts`'s `readOdfFormulaContent`/`readOdfEmbeddedFormula`, thin adapters over `odf.js`'s own `readOdfFormula`, feeding `odfToPdf` and the odt/odp embedded-formula paths respectively; `src/edit/odt/*`'s `StyleRegistry`/`resolveStyle` (style interning), `src/edit/odp/shape.ts`'s `applyOdfTransform`/`resolveOdfShapeGeometry` (rotation), and `src/edit/odt/automatic-styles.ts`'s `ensureAutomaticStyles`/`nextStyleName` (reused by `src/edit/odg/style.ts`'s own graphic-family style writer and `src/edit/ods/print-settings.ts`'s own page-layout/master-page/table-style minting), all consumed directly rather than reimplemented. odt, odp, ods, and odg → `ContentDocument` reading and PDF conversion are now all integrated both ways.
|
|
404
|
+
- [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font `src/pdf/math-font.ts` parses and `odfToPdf` (and the odt/odp embedded-formula paths) render through, vendored at `assets/fonts/STIXTwoMath-Regular.otf` and embedded into `dist/` as a base64 string (`src/mathml/assets/stix-two-math-font.ts`, generated by `scripts/generate-math-font-asset.mjs`) rather than read from disk at runtime. Copyright 2001-2021 The STIX Fonts Project Authors, licensed [OFL-1.1](assets/fonts/OFL.txt) — see `assets/fonts/NOTICE.md` for the exact source commit and version this was vendored from.
|
|
311
405
|
|
|
312
406
|
## License
|
|
313
407
|
|