js.documents 1.61.0 → 1.62.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 +83 -43
- package/dist/{blr-types-CYYC3yYU.d.cts → blr-types-DVgO1DQ9.d.cts} +1 -1
- package/dist/{blr-types-CYYC3yYU.d.ts → blr-types-DVgO1DQ9.d.ts} +1 -1
- package/dist/convert/capability.cjs +1 -0
- package/dist/convert/capability.d.cts +1 -1
- package/dist/convert/capability.d.ts +1 -1
- package/dist/convert/capability.js +1 -0
- package/dist/convert/convert.cjs +29 -27
- package/dist/convert/convert.d.cts +9 -3
- package/dist/convert/convert.d.ts +9 -3
- package/dist/convert/convert.js +29 -27
- package/dist/edit/docx/content.cjs +75 -13
- package/dist/edit/docx/content.d.cts +8 -2
- package/dist/edit/docx/content.d.ts +8 -2
- package/dist/edit/docx/content.js +75 -13
- package/dist/edit/docx/paragraph.cjs +14 -0
- package/dist/edit/docx/paragraph.d.cts +5 -0
- package/dist/edit/docx/paragraph.d.ts +5 -0
- package/dist/edit/docx/paragraph.js +14 -0
- package/dist/edit/docx/run.cjs +1 -1
- package/dist/edit/docx/run.js +1 -1
- package/dist/edit/docx/table.cjs +41 -0
- package/dist/edit/docx/table.d.cts +7 -1
- package/dist/edit/docx/table.d.ts +7 -1
- package/dist/edit/docx/table.js +42 -1
- package/dist/edit/geometry.cjs +38 -0
- package/dist/edit/geometry.d.cts +7 -0
- package/dist/edit/geometry.d.ts +7 -0
- package/dist/edit/geometry.js +36 -0
- package/dist/edit/odg/content.cjs +15 -12
- package/dist/edit/odg/content.js +15 -12
- package/dist/edit/odg/editor.d.cts +1 -1
- package/dist/edit/odg/editor.d.ts +1 -1
- package/dist/edit/odg/page.d.cts +3 -3
- package/dist/edit/odg/page.d.ts +3 -3
- package/dist/edit/odg/vector.cjs +22 -13
- package/dist/edit/odg/vector.d.cts +1 -1
- package/dist/edit/odg/vector.d.ts +1 -1
- package/dist/edit/odg/vector.js +23 -14
- package/dist/edit/odp/content.cjs +15 -0
- package/dist/edit/odp/content.js +16 -1
- package/dist/edit/odp/editor.d.cts +1 -1
- package/dist/edit/odp/editor.d.ts +1 -1
- package/dist/edit/odp/image.d.cts +1 -1
- package/dist/edit/odp/image.d.ts +1 -1
- package/dist/edit/odp/shape.cjs +11 -30
- package/dist/edit/odp/shape.d.cts +2 -2
- package/dist/edit/odp/shape.d.ts +2 -2
- package/dist/edit/odp/shape.js +13 -33
- package/dist/edit/odp/slide.cjs +11 -0
- package/dist/edit/odp/slide.d.cts +14 -4
- package/dist/edit/odp/slide.d.ts +14 -4
- package/dist/edit/odp/slide.js +12 -1
- package/dist/edit/odt/content.cjs +56 -16
- package/dist/edit/odt/content.d.cts +4 -2
- package/dist/edit/odt/content.d.ts +4 -2
- package/dist/edit/odt/content.js +56 -17
- package/dist/edit/odt/editor.d.cts +1 -1
- package/dist/edit/odt/editor.d.ts +1 -1
- package/dist/edit/odt/table.cjs +36 -0
- package/dist/edit/odt/table.d.cts +1 -36
- package/dist/edit/odt/table.d.ts +1 -36
- package/dist/edit/odt/table.js +37 -1
- package/dist/edit/pptx/content.cjs +71 -3
- package/dist/edit/pptx/content.js +71 -3
- package/dist/edit/pptx/editor.d.cts +1 -1
- package/dist/edit/pptx/editor.d.ts +1 -1
- package/dist/edit/pptx/image.d.cts +1 -1
- package/dist/edit/pptx/image.d.ts +1 -1
- package/dist/edit/pptx/shape.cjs +23 -1
- package/dist/edit/pptx/shape.d.cts +2 -2
- package/dist/edit/pptx/shape.d.ts +2 -2
- package/dist/edit/pptx/shape.js +23 -3
- package/dist/edit/pptx/slide.cjs +10 -1
- package/dist/edit/pptx/slide.d.cts +11 -4
- package/dist/edit/pptx/slide.d.ts +11 -4
- package/dist/edit/pptx/slide.js +10 -1
- package/dist/edit/pptx/table.cjs +136 -0
- package/dist/edit/pptx/table.d.cts +2 -0
- package/dist/edit/pptx/table.d.ts +2 -0
- package/dist/edit/pptx/table.js +131 -0
- package/dist/firebird/backup.d.cts +1 -1
- package/dist/firebird/backup.d.ts +1 -1
- package/dist/firebird/blr-types.cjs +2 -2
- package/dist/firebird/blr-types.d.cts +1 -1
- package/dist/firebird/blr-types.d.ts +1 -1
- package/dist/firebird/blr-types.js +2 -2
- package/dist/firebird/data.cjs +68 -14
- package/dist/firebird/data.d.cts +1 -1
- package/dist/firebird/data.d.ts +1 -1
- package/dist/firebird/data.js +68 -14
- package/dist/firebird/schema.cjs +13 -1
- package/dist/firebird/schema.d.cts +1 -1
- package/dist/firebird/schema.d.ts +1 -1
- package/dist/firebird/schema.js +13 -1
- package/dist/{geometry-DvwdrIij.d.cts → geometry-DaJr8yQW.d.cts} +2 -2
- package/dist/{geometry-DvwdrIij.d.ts → geometry-DaJr8yQW.d.ts} +2 -2
- package/dist/hsqldb/binary-script.cjs +154 -0
- package/dist/hsqldb/binary-script.d.cts +15 -0
- package/dist/hsqldb/binary-script.d.ts +15 -0
- package/dist/hsqldb/binary-script.js +151 -0
- package/dist/hsqldb/cache.cjs +18 -11
- package/dist/hsqldb/cache.d.cts +9 -4
- package/dist/hsqldb/cache.d.ts +9 -4
- package/dist/hsqldb/cache.js +18 -11
- package/dist/hsqldb/rowformat.cjs +60 -16
- package/dist/hsqldb/rowformat.d.cts +2 -21
- package/dist/hsqldb/rowformat.d.ts +2 -21
- package/dist/hsqldb/rowformat.js +60 -17
- package/dist/{image-BZDy72Jb.d.cts → image-7snmKIxn.d.cts} +1 -1
- package/dist/{image-B-xQWYYS.d.ts → image-Cs8xF753.d.ts} +1 -1
- package/dist/{image-CeqwL-Qs.d.ts → image-DHcA9kaK.d.ts} +1 -1
- package/dist/{image-DWX6cNOD.d.cts → image-M5SezZVI.d.cts} +1 -1
- package/dist/index.cjs +54 -3
- package/dist/index.d.cts +28 -20
- package/dist/index.d.ts +27 -19
- package/dist/index.js +13 -4
- package/dist/layout/cell-typing.cjs +209 -0
- package/dist/layout/cell-typing.d.cts +22 -0
- package/dist/layout/cell-typing.d.ts +22 -0
- package/dist/layout/cell-typing.js +208 -0
- package/dist/layout/drawing.cjs +193 -17
- package/dist/layout/drawing.js +193 -17
- package/dist/layout/engine.cjs +46 -29
- package/dist/layout/engine.d.cts +0 -2
- package/dist/layout/engine.d.ts +0 -2
- package/dist/layout/engine.js +47 -30
- package/dist/layout/lattice.cjs +139 -0
- package/dist/layout/lattice.d.cts +20 -0
- package/dist/layout/lattice.d.ts +20 -0
- package/dist/layout/lattice.js +135 -0
- package/dist/layout/reconstruct.cjs +176 -113
- package/dist/layout/reconstruct.d.cts +2 -0
- package/dist/layout/reconstruct.d.ts +2 -0
- package/dist/layout/reconstruct.js +173 -110
- package/dist/layout/shared.cjs +52 -0
- package/dist/layout/shared.d.cts +4 -2
- package/dist/layout/shared.d.ts +4 -2
- package/dist/layout/shared.js +51 -1
- package/dist/layout/sheets.cjs +45 -11
- package/dist/layout/sheets.js +46 -12
- package/dist/layout/slides.cjs +7 -7
- package/dist/layout/slides.d.cts +0 -3
- package/dist/layout/slides.d.ts +0 -3
- package/dist/layout/slides.js +7 -7
- package/dist/markdown/write.cjs +19 -6
- package/dist/markdown/write.js +19 -6
- package/dist/mathml/layout.cjs +2 -2
- package/dist/mathml/layout.d.cts +1 -1
- package/dist/mathml/layout.d.ts +1 -1
- package/dist/mathml/layout.js +2 -2
- package/dist/model/embedded-drawing.cjs +32 -0
- package/dist/model/embedded-drawing.d.cts +8 -0
- package/dist/model/embedded-drawing.d.ts +8 -0
- package/dist/model/embedded-drawing.js +30 -0
- package/dist/model/formula.cjs +31 -0
- package/dist/model/formula.d.cts +6 -6
- package/dist/model/formula.d.ts +6 -6
- package/dist/model/formula.js +27 -0
- package/dist/model/geometry.d.cts +1 -1
- package/dist/model/geometry.d.ts +1 -1
- package/dist/model/paint-order.cjs +15 -0
- package/dist/model/paint-order.d.cts +14 -0
- package/dist/model/paint-order.d.ts +14 -0
- package/dist/model/paint-order.js +14 -0
- package/dist/odb/components.cjs +12 -0
- package/dist/odb/components.d.cts +6 -0
- package/dist/odb/components.d.ts +6 -0
- package/dist/odb/components.js +10 -0
- package/dist/odb/read.cjs +11 -7
- package/dist/odb/read.d.cts +3 -2
- package/dist/odb/read.d.ts +3 -2
- package/dist/odb/read.js +11 -7
- package/dist/odf/formula/detect.cjs +86 -18
- package/dist/odf/formula/detect.d.cts +10 -6
- package/dist/odf/formula/detect.d.ts +10 -6
- package/dist/odf/formula/detect.js +86 -19
- package/dist/odf/formula/read.cjs +4 -16
- package/dist/odf/formula/read.d.cts +4 -8
- package/dist/odf/formula/read.d.ts +4 -8
- package/dist/odf/formula/read.js +5 -17
- package/dist/odf/odp/read.cjs +9 -19
- package/dist/odf/odp/read.d.cts +2 -7
- package/dist/odf/odp/read.d.ts +2 -7
- package/dist/odf/odp/read.js +10 -20
- package/dist/odf/odt/read.cjs +63 -23
- package/dist/odf/odt/read.d.cts +2 -7
- package/dist/odf/odt/read.d.ts +2 -7
- package/dist/odf/odt/read.js +64 -24
- package/dist/omml/write.cjs +258 -0
- package/dist/omml/write.d.cts +2 -0
- package/dist/omml/write.d.ts +2 -0
- package/dist/omml/write.js +256 -0
- package/dist/rowformat-Cl2exlEh.d.cts +25 -0
- package/dist/rowformat-Cl2exlEh.d.ts +25 -0
- package/dist/{schema-Acco3Ezi.d.cts → schema-BeR_KJ0X.d.cts} +3 -1
- package/dist/{schema-VkAA6pF5.d.ts → schema-CSKwSb_v.d.ts} +3 -1
- package/dist/{shape-BUfjw5eZ.d.cts → shape-BhkP6HB8.d.cts} +6 -2
- package/dist/{shape-CTkl6kt8.d.ts → shape-BkVcwPcf.d.ts} +6 -2
- package/dist/{shape-BfN3m52m.d.ts → shape-CVXedeL7.d.ts} +3 -2
- package/dist/{shape-CN2ycxOo.d.cts → shape-CXZ6fejV.d.cts} +3 -2
- package/dist/table-C3CPZ--2.d.cts +35 -0
- package/dist/table-DjYcNOFm.d.cts +44 -0
- package/dist/table-DqfqMkVq.d.ts +44 -0
- package/dist/table-DvS_7UQS.d.ts +35 -0
- package/dist/{vector-ClohqsOe.d.cts → vector-Puuqu_IT.d.cts} +4 -0
- package/dist/{vector-ClohqsOe.d.ts → vector-Puuqu_IT.d.ts} +4 -0
- package/dist/write-DuyfnbL_.d.cts +16 -0
- package/dist/write-Zqwx6pka.d.ts +16 -0
- package/package.json +4 -4
- package/dist/odf/formula/placeholder.cjs +0 -36
- package/dist/odf/formula/placeholder.d.cts +0 -14
- package/dist/odf/formula/placeholder.d.ts +0 -14
- package/dist/odf/formula/placeholder.js +0 -36
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 — docx, pptx, odt, odp, ods, odg, xlsx, and markdown all read into and build from the same `ContentDocument`/`LayoutDocument` model, with PDF simply the one format every variant can reach (docx/pptx/odt/odp/ods/odg/xlsx/markdown ⇄ PDF, fourteen pairs, all round-tripping both ways), plus ten further cross-format bridges, five pairs (odt⇄docx, odp⇄pptx, ods⇄xlsx, markdown⇄docx, markdown⇄odt) that bypass PDF entirely for pairs already sharing a pivot variant directly. Also included: a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, `.odb` (ODF database front-end) table extraction to xlsx/CSV from an embedded HSQLDB TEXT script (Tier 1), HSQLDB's own binary CACHED-table row-store format (Tier 2), and an embedded Firebird database's own gbak logical-backup format (Tier 3), 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), [odf.js](https://github.com/ExaDev/odf.js), and [markdown-codec](https://github.com/ExaDev/markdown-codec).
|
|
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 simply the one format every variant can reach (docx/pptx/odt/odp/ods/odg/xlsx/markdown ⇄ PDF, fourteen pairs, all round-tripping both ways), plus ten further cross-format bridges, five pairs (odt⇄docx, odp⇄pptx, ods⇄xlsx, markdown⇄docx, markdown⇄odt) that bypass PDF entirely for pairs already sharing a pivot variant directly. Also included: a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, `.odb` (ODF database front-end) table extraction to xlsx/CSV from an embedded HSQLDB TEXT script (Tier 1), HSQLDB's own binary CACHED-table row-store format (Tier 2), and an embedded Firebird database's own gbak logical-backup format (Tier 3), plus static Form/Report *structure* reading (bound controls, bands/groups/functions — not live-data rendering), 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 matching MathML → OMML translator so an embedded formula reaches a docx as real, editable Word math, and a fully hand-written PDF codec, built on [ooxml.js](https://github.com/ExaDev/ooxml.js), [odf.js](https://github.com/ExaDev/odf.js), and [markdown-codec](https://github.com/ExaDev/markdown-codec).
|
|
6
6
|
|
|
7
7
|
`documents.js` depends on `ooxml.js` for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions `ooxml.js` deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-**and-write** manipulation API for docx/pptx content — `ooxml.js`'s own typed readers (`readDocx`/`readPptx`) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are provided by [`pdf-codec`](https://github.com/ExaDev/pdf-codec), a sibling package extracted from this one: a hand-written, dependency-minimal PDF codec with no external PDF library (`pdf-lib`, `pdfjs-dist`, `mupdf`, or any other) as a dependency — see pdf-codec's own README for how it's built and what it embeds (including the vendored STIX Two Math font this package renders formulas through). `src/mathml/` (the MathML typesetting engine) stays in this package and is hand-written too, for the same "no supply-chain surface beyond what's already declared" reason, but consumes pdf-codec's embedded math font through a structurally-typed port rather than any font-parsing code of its own — see [Architecture](#architecture). CommonMark+GFM markdown reading/writing is provided by [`markdown-codec`](https://github.com/ExaDev/markdown-codec), the same "hand-write the format instead of wrapping a third-party library" bet applied to markdown: no `micromark`/`remark`/`marked`/`markdown-it`/`commonmark`/`mdast`/`unified`/`turndown`/`showdown` dependency anywhere in that package.
|
|
8
8
|
|
|
@@ -84,7 +84,7 @@ const pdfFromOdg = odgToPdf(odgBytes);
|
|
|
84
84
|
const odgBytes2 = pdfToOdg(pdfFromOdg);
|
|
85
85
|
|
|
86
86
|
const pdfFromOds = odsToPdf(odsBytes);
|
|
87
|
-
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed,
|
|
87
|
+
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed, then heuristically re-types it -- see Fidelity
|
|
88
88
|
|
|
89
89
|
const pdfFromXlsx = xlsxToPdf(xlsxBytes); // composes xlsxToOds -> odsToPdf internally -- still a real, direct, single-call conversion
|
|
90
90
|
const xlsxBytes2 = pdfToXlsx(pdfFromXlsx); // composes pdfToOds -> odsToXlsx internally
|
|
@@ -107,7 +107,7 @@ const docxFromMarkdown = markdownToDocx(markdownBytes);
|
|
|
107
107
|
const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct -- dropped on this hop, not merely approximated
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
Each takes an optional `{ signal }` — there is no `onSubstitution`/`sink` option here, since there is no font substitution or PDF-parse degradation to report; a wrong-kind `ContentDocument` throws outright rather than becoming a diagnostic.
|
|
110
|
+
Each takes an optional `{ signal }` — there is no `onSubstitution`/`sink` option here, since there is no font substitution or PDF-parse degradation to report; a wrong-kind `ContentDocument` throws outright rather than becoming a diagnostic. `odtToDocx`/`markdownToDocx` additionally take `onMathDiagnostic`, called once per MathML construct that degraded or was approximated while an embedded formula was translated into OOXML math — an embedded formula crosses into a docx as a real OMML equation (see [Architecture](#architecture)'s `src/omml/` entry), so this reports only what OMML genuinely has no counterpart for, never the whole formula.
|
|
111
111
|
|
|
112
112
|
The same conversions behind a swappable port, for a caller that wants to inject a different implementation later without changing call sites:
|
|
113
113
|
|
|
@@ -185,7 +185,7 @@ import { createOdp } from 'documents.js';
|
|
|
185
185
|
const editor = createOdp();
|
|
186
186
|
const slide = editor.addSlide();
|
|
187
187
|
const title = slide.addTextBox({ frame: { xPt: 40, yPt: 30, widthPt: 640, heightPt: 80 }, text: 'Title' });
|
|
188
|
-
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter
|
|
188
|
+
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter -- PptxShape has the equivalent a:xfrm/@rot setter now too, see Architecture below
|
|
189
189
|
const bullets = slide.addTextBox({ frame: { xPt: 40, yPt: 130, widthPt: 300, heightPt: 200 }, text: '' });
|
|
190
190
|
bullets.paragraphs()[0].remove();
|
|
191
191
|
bullets.addList().addItem().appendParagraph({ text: 'A real bulleted text:list' });
|
|
@@ -251,7 +251,7 @@ const docxBack = z.encode(docxPdfCodec, pdfFromDocx);
|
|
|
251
251
|
|
|
252
252
|
The ten PDF-bypassing bridges above get the same treatment: `odtDocxCodec`, `odpPptxCodec`, `odsXlsxCodec` (odt bytes ⇄ docx bytes, odp bytes ⇄ pptx bytes, ods bytes ⇄ xlsx bytes), and `markdownDocxCodec`/`markdownOdtCodec` (markdown bytes ⇄ docx bytes, markdown bytes ⇄ odt bytes) — the no-options form again, `odtToDocx`/`docxToOdt`/`markdownToDocx`/`docxToMarkdown`/etc. remain the entry points for `signal`.
|
|
253
253
|
|
|
254
|
-
`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 — there is no live-view editor for markdown, so this is the whole write path, not one stage of a larger one), `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
|
|
254
|
+
`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 — there is no live-view editor for markdown, so this is the whole write path, not one stage of a larger one), `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.
|
|
255
255
|
|
|
256
256
|
One further conversion, `odmToPdf`, is shaped differently from every conversion above: a `.odm` (ODF master document, a "book" of chapters) never carries its own chapters' content — each `text:section` is a bare external reference to a standalone `.odt` file, confirmed against real LibreOffice output (see Gotchas below) — so producing a PDF needs a caller-supplied `resolveSubDocument` callback to hand back each chapter's own bytes given that section's `href`. Every chapter's own `ContentSection[]` is concatenated in `text:section` document order into one combined document, with an explicit page break marking each chapter boundary, and fed through the same `convertWordprocessingToLayout` engine every `wordprocessing`-variant conversion above already uses unmodified:
|
|
257
257
|
|
|
@@ -277,7 +277,7 @@ try {
|
|
|
277
277
|
|
|
278
278
|
`odmToPdf` is not one of the fourteen round-trip conversions or the ten bridges above, has no `z.codec()` pair, and is not wired into the `DocumentConverter` port below — see Gotchas for why.
|
|
279
279
|
|
|
280
|
-
`.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.
|
|
280
|
+
`.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:
|
|
281
281
|
|
|
282
282
|
```ts
|
|
283
283
|
import { decodePackage } from 'odf.js';
|
|
@@ -289,6 +289,20 @@ const csvBytes = odbToCsv(odbBytes, { table: 'CUSTOMERS' }); // exactly one name
|
|
|
289
289
|
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
|
|
290
290
|
```
|
|
291
291
|
|
|
292
|
+
A `.odb`'s own Form/Report *structure* (as opposed to `readOdbTables`' table *data*): `odf.js` 2.0.0's `OdbInventory.forms`/`.reports` carry each declared component's own name and href, and its `readOdbForm`/`readOdbReport` resolve one named component into its real static structure — a form's bound controls, a report's bands/groups/functions — re-exported here unmodified. `readOdbForms`/`readOdbReports` are this package's own "read every declared one at once" convenience, the `readOdbTables`-shaped one-call ergonomic this data did not have before `odf.js` made forms/reports real:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { decodePackage } from 'odf.js';
|
|
296
|
+
import { readOdbForms, readOdbReports } from 'documents.js';
|
|
297
|
+
|
|
298
|
+
const forms = readOdbForms(decodePackage(odbBytes)); // OdbForm[] -- each form's own bound controls (form:text/form:data-field/etc), plus its content read as an ordinary ODT document via odf.js's readOdt
|
|
299
|
+
const reports = readOdbReports(decodePackage(odbBytes)); // OdbReport[] -- each report's own bands (report-header/detail/report-footer/...), groups, and functions, with each control's own data-bound field name resolved from its rpt:formula
|
|
300
|
+
|
|
301
|
+
// 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.
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
This is *structure*, not *rendering*: a Report is a live SQL-backed layout, and actually rendering one with real data means executing its query against a real database engine — categorically out of scope for a hand-written codec that never runs SQL (see Gotchas).
|
|
305
|
+
|
|
292
306
|
`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.
|
|
293
307
|
|
|
294
308
|
`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:
|
|
@@ -299,7 +313,7 @@ import { readFirebirdBackup } from 'documents.js';
|
|
|
299
313
|
const { summary, tables } = readFirebirdBackup(firebirdBackupBytes); // summary: backupFormatVersion/transportable/compressed/pageSizeBytes; tables: the same HsqldbTable[] shape
|
|
300
314
|
```
|
|
301
315
|
|
|
302
|
-
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:
|
|
316
|
+
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:
|
|
303
317
|
|
|
304
318
|
```ts
|
|
305
319
|
import { odfToPdf } from 'documents.js';
|
|
@@ -319,16 +333,21 @@ import { odtToPdf } from 'documents.js';
|
|
|
319
333
|
const pdfBytes = odtToPdf(odtBytes);
|
|
320
334
|
```
|
|
321
335
|
|
|
322
|
-
|
|
336
|
+
The formula's real MathML travels **inside** the `ContentDocument`: `readOdtContent`/`readOdpContent` return a bare `ContentDocument` (exactly like `readDocxContent`/`readPptxContent`), and an embedded formula is an ordinary `ContentEmbeddedObjectBlock` whose own `document` is a genuine `'formula'`-kind `ContentDocument` carrying `{ mathml, starMath? }` — document-schema.js's fifth `ContentDocument` variant. There is no side-channel map to thread anywhere:
|
|
323
337
|
|
|
324
338
|
```ts
|
|
325
|
-
import { convertWordprocessingToLayout, readOdtContent } from 'documents.js';
|
|
339
|
+
import { convertWordprocessingToLayout, formulaOfBlock, readOdtContent } from 'documents.js';
|
|
340
|
+
|
|
341
|
+
const document = readOdtContent(pkg);
|
|
342
|
+
const block = document.sections[0].blocks.find((b) => b.kind === 'embeddedObject');
|
|
343
|
+
formulaOfBlock(block); // -> { mathml, starMath? }, or undefined for a non-formula embedded object
|
|
326
344
|
|
|
327
|
-
const { document, formulas } =
|
|
328
|
-
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer, formulas });
|
|
345
|
+
const { document: layout, formulas: positioned } = convertWordprocessingToLayout(document, { measurer });
|
|
329
346
|
const pdfBytes = writePdf(layout, { formulas: positioned }); // writePdf's own formula-aware option -- see Architecture
|
|
330
347
|
```
|
|
331
348
|
|
|
349
|
+
`writePdf`'s `formulas` option is the one place a formula still travels beside its document rather than within it, and for a different reason: a rendered formula's CID-font glyph runs have no `LayoutItem` kind to be (see pdf-codec's own README), so `convertWordprocessingToLayout`/`convertPresentationToLayout` return the positioned results alongside the `LayoutDocument`.
|
|
350
|
+
|
|
332
351
|
`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:
|
|
333
352
|
|
|
334
353
|
```ts
|
|
@@ -340,6 +359,18 @@ const { box, diagnostics } = layoutFormula(mathml, { metrics: metricsAt(12), siz
|
|
|
340
359
|
// diagnostics: a 'missing-glyph' or 'unsupported-element' entry for anything this engine couldn't render faithfully -- see Fidelity
|
|
341
360
|
```
|
|
342
361
|
|
|
362
|
+
`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`:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { buildOfficeMathParagraph, openDocx } from 'documents.js';
|
|
366
|
+
|
|
367
|
+
const editor = openDocx(existingDocxBytes);
|
|
368
|
+
const { diagnostics } = editor.body.appendParagraph().appendOfficeMath(mathml); // appends a real m:oMathPara > m:oMath equation
|
|
369
|
+
// diagnostics: an 'unsupported-element' or 'approximated-element' entry per construct OMML has no faithful counterpart for -- see Gotchas
|
|
370
|
+
|
|
371
|
+
const { element } = buildOfficeMathParagraph(mathml); // or build the fragment directly, for a caller placing it itself
|
|
372
|
+
```
|
|
373
|
+
|
|
343
374
|
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:
|
|
344
375
|
|
|
345
376
|
```ts
|
|
@@ -353,23 +384,24 @@ This works via a `"./*"` wildcard entry in `package.json`'s `exports` map, resol
|
|
|
353
384
|
|
|
354
385
|
The package is layered from generic primitives outward to the two conversion directions:
|
|
355
386
|
|
|
356
|
-
- **`src/model/`** — thin, documents.js-specific additions on top of the sibling [`document-schema.js`](https://github.com/ExaDev/document-schema.js) package, which now owns the two pivot models themselves: `LayoutDocument` (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — `LayoutPath` is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind `writePath`, pdf-codec's own content-write.ts, turns into PDF `m`/`l`/`c`/`h` content-stream operators) and `ContentDocument` (the semantic pivot: a discriminated union of `wordprocessing`, `presentation`, `spreadsheet`, and `
|
|
387
|
+
- **`src/model/`** — thin, documents.js-specific additions on top of the sibling [`document-schema.js`](https://github.com/ExaDev/document-schema.js) package, which now owns the two pivot models themselves: `LayoutDocument` (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — `LayoutPath` is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind `writePath`, pdf-codec's own content-write.ts, turns into PDF `m`/`l`/`c`/`h` content-stream operators) and `ContentDocument` (the semantic pivot: a discriminated union of `wordprocessing`, `presentation`, `spreadsheet`, `drawing`, and `formula` variants — the first four sharing paragraph/run/table/image building blocks, `drawing`'s own `ContentVector` vocabulary — rect/ellipse/line/path — being the vector-primitive counterpart to the shared `ContentShape`, and `formula` carrying a real MathML tree rather than any of them) are both imported, not defined here — `document-schema.js` exists specifically so `ooxml.js`, `odf.js`, `pdf-codec`, and `documents.js` share one schema instead of each maintaining an independent, drift-prone copy. What remains local: `bytes.ts` (magic-byte-validated `Uint8Array` schemas for docx/pptx/PDF, plus `Odt`/`Ods`/`Odp`/`OdgBytesSchema`, which check the package's actual declared media type against `odf.js`'s `ODF_MEDIA_TYPES` table rather than only the generic ZIP signature the OOXML schemas are limited to), `units.ts` (OOXML EMU/twip/point/half-point conversions), and `geometry.ts`/`color.ts`/`style.ts`, each now mostly a thin re-export of `document-schema.js`'s `Box`/`Margins`/`PageSize`/`Color`/`Alignment`/`LayoutFont` — the one genuinely PDF-specific piece each still adds locally is `geometry.ts`'s `flipY` (the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates); `LayoutFont`/`DEFAULT_LAYOUT_FONT` moved to `document-schema.js` too (since `LayoutText`, part of the pivot, needs the field), leaving only the standard-14 font *resolution* logic that consumes it (pdf-codec's own `fonts.ts`/`font-read.ts`) as PDF-specific, now external to this package entirely. `ContentDocument`/`ContentDocumentSchema`/`CONTENT_FORMAT_VERSION` themselves have no local file at all any more — every consumer imports them directly from `document-schema.js`, which owns the envelope as well as everything it wraps. `paint-order.ts`'s `mergeByPaintOrder` merges a drawing page's two arrays (`shapes`, `vectors`) back into one true-paint-order walk through the shared `paintOrder` field both carry; it lives here rather than beside either caller because `src/layout/drawing.ts` and `src/edit/odg/content.ts` both need the identical merge and `src/layout/*` deliberately imports no `odf.js`/`edit` code. `formula.ts` holds the small helpers around document-schema.js's own `ContentFormula`: the `'formula'`-kind `ContentDocument` envelope, the `ContentEmbeddedObjectBlock` an odt/odp reader produces for an inline formula, the narrowing back out of such a block, and the plain-text stand-in (`formulaPlaceholderText`) every consumer that cannot typeset MathML writes instead. It declares no formula type of its own — the side-channel `EmbeddedFormula` it used to define is gone, replaced by the real schema type. `PositionedFormula` (the equivalent side-channel shape for a `LayoutDocument`) now lives in `pdf-codec` itself, which redeclares its own structurally-identical copy of it and of `MathBox` — see [Architecture](#architecture) below and pdf-codec's own README for why a real `MathBox` this package's `layoutFormula` produces crosses that package boundary with zero cast or wrapper.
|
|
357
388
|
- **The hand-written PDF codec, and the generic byte/image primitives it depends on, are now the external [`pdf-codec`](https://github.com/ExaDev/pdf-codec) dependency** rather than local `src/pdf/`/`src/bytes/`/`src/image/` directories — see that package's own README for its internal architecture (the object model, cross-reference handling, content-stream interpreter, standard-14 font resolution, the embedded math-font writer, and the generic byte/PNG/JPEG primitives it exports for a layout engine like this package's own `src/layout/` to build on).
|
|
358
389
|
- **`src/ports/`** — the injectable ports this package's own "identity, clock, and observability are first-class ports" convention calls for: `abort.ts`'s `throwIfAborted` (a signal-check helper called at row loop boundaries in `src/layout/sheets.ts`/`reconstruct.ts` — the codebase has no `await` point for cancellation to hook into implicitly, since the local pipeline is synchronous end to end, so every long-running loop checks explicitly instead; `pdf-codec` needed the identical helper for its own page loops and now carries its own independently-duplicated copy rather than depending on this package for it) and `clock.ts`'s `ClockPort`/`systemClock`/`fixedClock` (an injectable "now", for deterministic PDF output in tests). `ClockPort` is exported and tested in isolation but not yet consumed by any conversion path — `writePdf`'s own `/CreationDate`/`/ModDate` come directly from `LayoutDocument.metadata.createdIso`/`modifiedIso` when present, with nothing in pdf-codec's own write path calling `new Date()` to fill in a missing one, so there is currently no real call site for `ClockPort` to inject into. A real, tracked gap in wiring, not a documentation gap: a future default-timestamp write path should consume it rather than reaching for `new Date()` directly.
|
|
359
390
|
- **`src/xml/`** and **`src/opc/`** — parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built over `ooxml.js`'s `Package`/`XmlNode`, needed because `ooxml.js`'s own XML nodes have no parent pointers and `ooxml.js` never writes new parts into an existing package. `src/xml/odf-text.ts` is the one ODF-specific module in this directory: `encodeOdfText`/`decodeOdfText` convert between a plain string and ODF's own whitespace-run element sequence (`text:s` for a run of two or more literal spaces, `text:tab`, `text:line-break` — all three occupy real character positions in an ODF paragraph but are ELEMENTS, not text-node characters, unlike docx's flat `w:t` run text) — see the Gotchas entry below on why every ODF text getter in this codebase must call `decodeOdfText`, never `ooxml.js`'s own plain-text-node `textContent()`.
|
|
360
391
|
- **`src/odf-package/`** — the ODF-side counterpart to `src/opc/`: `manifest.ts` is a pure re-export of `odf.js`'s own manifest read/build/write/sync/validate functions (`odf.js` already owns `META-INF/manifest.xml` end to end — reading, deriving, writing, syncing, and validating it — unlike `ooxml.js`'s read-only OPC relationship handling), and `media.ts`'s `addImageMedia` inserts a binary image part under `Pictures/` (the real-world LibreOffice/OASIS convention, confirmed against `odf.js`'s own round-trip/manifest fixtures) and re-syncs the manifest via that same `syncManifest` re-export — one step simpler than OOXML's own `addImageMedia` (`src/opc/media.ts`) since ODF references a media part directly by its package path (`xlink:href`) rather than through a relationship-ID indirection. `OdpSlide.addImage`/`OdpShape` (`src/edit/odp/image.ts`) is `addImageMedia`'s real caller — and, through `src/edit/odg/*`'s wholesale reuse of `OdpShape` (see the `src/edit/` entry below), `OdgPage.addImage` too; `src/odb/read.ts` also reuses `manifest.ts`'s `readManifest` directly, to check `database/script`'s own manifest-declared media type before treating it as an HSQLDB script part.
|
|
361
|
-
- **`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 —
|
|
392
|
+
- **`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. `DocxParagraph.appendOfficeMath` is the one primitive here with no ODF counterpart: it appends a real OMML display equation (`m:oMathPara` > `m:oMath`) built by `src/omml/write.ts`, and is what `buildDocxPackage` uses to write an embedded formula as genuine Word math instead of a plain-text stand-in. `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. `PptxShape.rotationDeg` (`src/edit/pptx/shape.ts`) is the DrawingML analogue, a plain `a:xfrm/@rot` attribute setter (60,000ths of a degree, clockwise, ECMA-376 20.1.7.6) needing no group-composition logic of its own, since `ooxml.js`'s own `composeShapeRotationDeg` already collapses to a bare passthrough of `xfrm.rotationDeg` for a top-level, ungrouped shape. That write side now lives in `src/edit/geometry.ts` (`buildTransformAttr`/`applyOdfGeometry`), a peer of the per-format edit directories rather than inside `odp/`, because `OdgBoxVector.rotationDeg`/`OdgPathVector.rotationDeg` need the identical machinery for `draw:rect`/`draw:ellipse`/`draw:path` — odf.js resolves all four element kinds through one `resolveOdfShapeGeometry`, so there is exactly one correct inverse of it. A table INSIDE a slide shape (not a document-level table) is now writable too: `OdpSlide.addTable` builds a `draw:frame` whose only child is a `table:table` directly (no `draw:text-box` wrapper) and reuses `OdtTable`/`buildTable` WHOLESALE for it, the same content-model-is-identical-wherever-it-lives argument `OdpShape`'s own paragraph/list reuse already rests on; `PptxSlide.addTable` (`src/edit/pptx/table.ts`) is the genuinely new DrawingML-side work, since a table shape lives in its own `p:graphicFrame` — a shape kind distinct from `p:sp`/`p:pic`, with its own frame on a direct `p:xfrm` child rather than nested in a `p:spPr` — and a DrawingML table's own merge model is a THIRD distinct convention from both docx's gridSpan-collapses-the-row scheme and ODF's covered-table-cell elements: every row always carries exactly as many `a:tc` as there are grid columns, and a covered cell is marked by a plain `hMerge`/`vMerge="1"` attribute on that same element, never an omitted or a differently-tagged one. `src/edit/ods/*` has no docx/pptx/odt/odp analogue to reuse for its core concern (cell addressing) but still 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 (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).
|
|
362
393
|
- **`src/mathml/`** — a MathML presentation-layer typesetting engine, comparable in scope to pdf-codec's own standard-14 text-layout half — genuinely self-contained: no import from `model`, `pdf-codec`, or `odf.js` at all (not even `document-schema.js`), matching `src/layout/`'s own "pure conversion algorithm" isolation one tier further down. `nodes.ts` defines `MathMlNode`/`MathMlElement` as a local, structurally-compatible mirror of `odf.js`'s own `XmlNode` (the same "mirror the shape, don't import the package" trick `src/interop.test.ts` already proves holds between `ooxml.js` and `odf.js`), so `odf.js`'s `readOdfFormula`'s real return value type-checks against it with zero cast. `variant.ts` maps `mathvariant` to the Unicode Mathematical Alphanumeric Symbols block (Latin/Greek/digits, including the block's own well-known Letterlike-Symbols hole-fillers — italic small h, eleven Script/Fraktur/Double-struck capitals — generated directly from Unicode's own `UnicodeData.txt`, not transcribed by hand). `operators.ts` is a deliberately bounded operator dictionary (lspace/rspace/stretchy/largeop/movablelimits per operator), not the MathML3 spec's own multi-thousand-entry table. `layout.ts` is the recursive box-model engine itself (`mrow`/`mi`/`mn`/`mo`/`mtext`/`mspace`/`msub`/`msup`/`msubsup`/`munder`/`mover`/`munderover`/`mfrac`/`msqrt`/`mroot`/`mtable`/`mtr`/`mtd`/`mstyle`/`semantics`, plus a text-content fallback with a diagnostic for anything else), driven entirely by the injected `MathFontMetrics` port (`metrics.ts`) rather than any font-parsing code of its own — pdf-codec's own `math-font.ts` is the real implementation, consumed only through this structural port, never imported directly. `compose.ts`/`radical.ts`/`length.ts` are its own small geometry helpers (baseline-offset box placement, a hand-drawn hooked radical sign built from line segments rather than a bare glyph substitute, MathML length-unit parsing). Output is a flat `MathBox` (positioned glyph runs, rules, and strokes, box-local top-left/y-down coordinates), passed with zero cast into pdf-codec's `writePdf({ formulas })` — see pdf-codec's own README for the structural-typing mechanism that makes this work across a package boundary with no shared class or branded type.
|
|
394
|
+
- **`src/omml/`** — the MathML → OMML (Office Math Markup Language, ECMA-376 Part 1 §22.1's own `m:` vocabulary) structural translator: the write-side counterpart to `src/mathml/`'s own typesetting engine, covering the identical construct set deliberately, so a formula rendered to PDF and the same formula written into a docx degrade in exactly the same places rather than one being silently better than the other. `write.ts`'s `buildOfficeMath`/`buildOfficeMathParagraph` map each MathML construct onto its real OMML element (`mfrac` → `m:f`, `msqrt`/`mroot` → `m:rad` with `m:radPr/m:degHide` and the degree/radicand order reversed, `msub`/`msup`/`msubsup` → `m:sSub`/`m:sSup`/`m:sSubSup`, `munder`/`mover` → `m:limLow`/`m:limUpp` and `munderover` → the two nested, `mtable`/`mtr`/`mtd` → `m:m`/`m:mr`/`m:e` with per-column `m:mcs`/`m:mc` justification, and every token element → an `m:r`/`m:t` run whose `mathvariant` becomes OMML's own `m:scr` script + `m:sty` style pair). It emits no geometry, measures nothing, and loads no font — this is a vocabulary translation, not a rendering. It lives outside `src/mathml/` for that directory's own isolation rule: its whole output type is `ooxml.js`'s `XmlElement`, and `src/mathml/` imports no package at all. `buildDocxPackage` is its real caller; a construct with no OMML counterpart degrades to a literal-text run carrying its own text content, with a diagnostic, exactly as `src/mathml/layout.ts`'s own `unsupported` fallback does for the PDF path.
|
|
363
395
|
- **`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.
|
|
364
|
-
- **`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 `
|
|
396
|
+
- **`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 `readOdfFormulaDocument`, for a standalone `.odf` (the whole `'formula'`-kind `ContentDocument`) and an embedded sub-object (its bare `ContentFormula`) respectively — the latter reading the sub-object's own `content.xml` directly out of the outer package's flat `Package.parts` record, no separate unzip step needed; `formula/detect.ts`'s `collectFormulaFrames`/`collectSlideFormulaFrames` are 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 one of these as a second pass over the same package's raw `content.xml` to find and inject a formula's own embedded-object block. `collectFormulaFrames` is a deep walk (a frame directly in the container, one nested inside a `draw:g` group with that group's own `draw:transform` composed exactly as `walkDrawShapes` composes it, and one anchored inline inside a paragraph's own run content); `collectSlideFormulaFrames` replicates `odf.js`'s own `walkDrawShapes` traversal precisely so each formula's `ContentShape` index is derived rather than guessed. See the Gotchas entry below for where each detected formula's block actually lands.
|
|
365
397
|
- **`src/markdown/`** — a third, independent counterpart to `src/ooxml/`/`src/odf/`, resolving markdown text into a `ContentDocument` via the external [`markdown-codec`](https://github.com/ExaDev/markdown-codec) dependency rather than a package format: `read.ts`'s `readMarkdownContent` is a thin adapter over `markdown-codec`'s own `readMarkdown`, re-stamping `documents.js`'s own `CONTENT_FORMAT_VERSION` onto a fresh envelope (`markdown-codec`'s `readMarkdown` already produces a full `document-schema.js` `ContentDocument`, structurally identical to but nominally separate from this package's local one) — mirroring `readOdtContent`/`readDocxContent` exactly, and the concrete third proof (after odt/docx) that this pivot and layout engine are genuinely format-agnostic. `write.ts`'s `buildMarkdownText` is the reverse, a thin wrapper over `markdown-codec`'s own `writeMarkdown` — deliberately living beside `read.ts` rather than under `src/edit/`, since markdown has no `XmlElement` tree for a live-view editor to hold a mutable reference into; there is no `MarkdownEditor` the way there is a `DocxEditor`/`OdtEditor`. `text.ts`'s `decodeMarkdownText`/`encodeMarkdownText` are the byte↔text boundary neither `readMarkdown`/`writeMarkdown` nor `markdownCodec`'s own `MarkdownBytesSchema` sit on (both operate on strings, not bytes) — the step every bytes-in/bytes-out ergonomic conversion in `convert.ts` needs, using a fatal-mode `TextDecoder` so a non-UTF-8 input throws immediately rather than silently producing replacement characters.
|
|
366
|
-
- **`src/layout/`** — the pure conversion algorithms, importing `model`, (for formula placement) `mathml`, and — for line-wrapping/pagination itself — several primitives sourced from the external `pdf-codec` dependency: the injected `TextMeasurer` port and `wrapRunsToWidth` (pdf-codec's own `measure.ts`/`text-layout.ts`, since deciding where a line breaks needs to know how wide text renders in a PDF standard-14 font, regardless of which format the content came from), `loadMathFont` (pdf-codec's own `math-font.ts`, for formula placement), pdf-codec's `matrix.ts`'s `rotatePointAboutCenter` (`slides.ts`'s own shape-rotation placement), and pdf-codec's `afm-widths.ts`/`fonts.ts`'s `STANDARD_METRICS`/`resolveStandardFont` (`reconstruct.ts`'s own font-matching when reconstructing from a `LayoutDocument`) — this package's one dependency on external font/text-measurement primitives, since text layout is inherently coupled to the one font model (pdf-codec's own standard-14 resolution) every conversion direction ultimately renders through: `engine.ts` (`ContentDocument` wordprocessing → `LayoutDocument`: flow, line-breaking, pagination — fed identically by docx-, odt-, and markdown-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
|
|
367
|
-
- **`src/hsqldb/`** — the `.odb` decoders, in two tiers over two genuinely different on-disk storage shapes a HSQLDB table can use. `script.ts` (Tier 1): a small, bounded HSQLDB TEXT-script-format (`hsqldb.script_format=0`) DDL/DML text parser, not a database engine — `parseHsqldbScript(bytes)` extracts `CREATE TABLE`'s own column names/types and `INSERT INTO`'s own row values into `HsqldbTable[]`, tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwing `HsqldbScriptParseError` for anything matching neither list. `rowformat.ts`/`cache.ts` (Tier 2): a CACHED table's own binary row-store format — LibreOffice's embedded-HSQLDB default (`database.isStoredFileAccess()` switches `hsqldb.default_table_type` to `cached` specifically for storage-backed access, confirmed against the decompiled engine source) — a CACHED table's DDL still lives in `database/script` as ordinary TEXT (Tier 1 parses it unmodified) but its row *data* lives in a separate binary page-cache file, `database/data`. `rowformat.ts` decodes one column's own binary field at a time (`HsqldbDataCursor`, a big-endian `DataView` cursor; `readHsqldbColumnValue`, one branch per SQL type code); `cache.ts` walks a table's own AVL row-position tree (`readHsqldbCachedTableRows`, following each row's persisted `iLeft`/`iRight` child positions recursively, needing no key-comparison or free-list logic at all — a deleted row is already unlinked from the tree before its space can be reused, so a traversal rooted at the tree's current root only ever reaches live rows), rooted at the position `parseHsqldbIndexRoots` recovers from each table's own `SET TABLE ... INDEX'...'` script line, using `parseHsqldbProperties`'s reading of `database/properties` (cache-file scale, engine version) to resolve byte offsets; `decodeHsqldbCachedTables` is the orchestration `src/odb/read.ts` calls, splicing real rows into every table with an index-root line and leaving every other table (MEMORY/TEXT, or a genuinely empty CACHED table — HSQLDB never writes an index-root line for one) exactly as Tier 1 already produced it.
|
|
398
|
+
- **`src/layout/`** — the pure conversion algorithms, importing `model`, (for formula placement) `mathml`, and — for line-wrapping/pagination itself — several primitives sourced from the external `pdf-codec` dependency: the injected `TextMeasurer` port and `wrapRunsToWidth` (pdf-codec's own `measure.ts`/`text-layout.ts`, since deciding where a line breaks needs to know how wide text renders in a PDF standard-14 font, regardless of which format the content came from), `loadMathFont` (pdf-codec's own `math-font.ts`, for formula placement), pdf-codec's `matrix.ts`'s `rotatePointAboutCenter` (`slides.ts`'s own shape-rotation placement), and pdf-codec's `afm-widths.ts`/`fonts.ts`'s `STANDARD_METRICS`/`resolveStandardFont` (`reconstruct.ts`'s own font-matching when reconstructing from a `LayoutDocument`) — this package's one dependency on external font/text-measurement primitives, since text layout is inherently coupled to the one font model (pdf-codec's own standard-14 resolution) every conversion direction ultimately renders through: `engine.ts` (`ContentDocument` wordprocessing → `LayoutDocument`: flow, line-breaking, pagination — fed identically by docx-, odt-, and markdown-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 cell backgrounds/gridlines/cell borders/headers/cell text, honouring a cell's own `alignment`/`verticalAlignment` where it declares one and falling back to the value-kind default and bottom where it doesn't, with `###`/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts 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 — an unrotated `ContentVector` `rect`/`ellipse`/`line` maps onto the pre-existing `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds, 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, and a *rotated* rect/ellipse/path becomes a `LayoutPath` of rotated points since neither `LayoutRect` nor `LayoutEllipse` models rotation; the page's `shapes` and `vectors` are merged into one true-paint-order walk through their shared `paintOrder` field rather than painted as two sequential arrays), `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 `shapes`/`vectors` arrays with each item's walk position stamped as its `paintOrder`, so the relative order between the two arrays survives; `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).
|
|
399
|
+
- **`src/hsqldb/`** — the `.odb` decoders, in two tiers over two genuinely different on-disk storage shapes a HSQLDB table can use. `script.ts` (Tier 1): a small, bounded HSQLDB TEXT-script-format (`hsqldb.script_format=0`) DDL/DML text parser, not a database engine — `parseHsqldbScript(bytes)` extracts `CREATE TABLE`'s own column names/types and `INSERT INTO`'s own row values into `HsqldbTable[]`, tolerating (skipping) every other statement kind real HSQLDB output emits that this package has no use for (users, grants, sequences, indexes, views), and throwing `HsqldbScriptParseError` for anything matching neither list. `rowformat.ts`/`cache.ts` (Tier 2): a CACHED table's own binary row-store format — LibreOffice's embedded-HSQLDB default (`database.isStoredFileAccess()` switches `hsqldb.default_table_type` to `cached` specifically for storage-backed access, confirmed against the decompiled engine source) — a CACHED table's DDL still lives in `database/script` as ordinary TEXT (Tier 1 parses it unmodified) but its row *data* lives in a separate binary page-cache file, `database/data`. `rowformat.ts` decodes one column's own binary field at a time (`HsqldbDataCursor`, a big-endian `DataView` cursor; `readHsqldbColumnValue`, one branch per SQL type code); `cache.ts` walks a table's own AVL row-position tree (`readHsqldbCachedTableRows`, following each row's persisted `iLeft`/`iRight` child positions recursively, needing no key-comparison or free-list logic at all — a deleted row is already unlinked from the tree before its space can be reused, so a traversal rooted at the tree's current root only ever reaches live rows), rooted at the position `parseHsqldbIndexRoots` recovers from each table's own `SET TABLE ... INDEX'...'` script line, using `parseHsqldbProperties`'s reading of `database/properties` (cache-file scale, engine version) to resolve byte offsets; `decodeHsqldbCachedTables` is the orchestration `src/odb/read.ts` calls, splicing real rows into every table with an index-root line and leaving every other table (MEMORY/TEXT, or a genuinely empty CACHED table — HSQLDB never writes an index-root line for one) exactly as Tier 1 already produced it. `binary-script.ts` (Tier 4): HSQLDB's own whole-script BINARY (`hsqldb.script_format=1`) and COMPRESSED (`=3`) serialisations of `database/script` itself — `parseHsqldbBinaryScript` reads the leading `org.hsqldb.Result` record carrying the database's DDL, rejoins its statements into exactly the TEXT-format script text the same database would have written at `script_format=0`, feeds that to Tier 1, and then decodes the per-table row sections that follow through `rowformat.ts`'s existing per-column decoder; `inflateHsqldbCompressedScript` is the zlib unwrap `=3` needs first, `fflate`'s `unzlibSync`, the one place in `src/hsqldb/` with a dependency beyond `document-schema.js`. All tiers mirror pdf-codec's own isolation discipline: `script.ts` imports only `document-schema.js`'s `ContentCellValue` type; `rowformat.ts` imports the same plus nothing else; `cache.ts` imports only those two and `script.ts`'s own types — no odf.js `Package`/`XmlElement` knowledge anywhere in `src/hsqldb/` — the caller is responsible for handing every function its raw bytes/text already extracted from a real `.odb` package. `HsqldbTable`/`HsqldbColumn` are also the shared pivot shape `src/firebird/`'s own Tier 3 decoder below produces. See Gotchas for Tier 2's own version scope and verification account.
|
|
368
400
|
- **`src/firebird/`** — the Tier 3 `.odb` decoder: a reader for Firebird's own gbak logical-backup format (`database/firebird.fbk`), the artifact a real Firebird-embedded `.odb` actually contains — see the README's own Gotchas entry below for the empirical finding that this is NOT a raw on-disk ODS page dump, the single largest correction this subsystem's own design went through. `reader.ts` holds the two distinct byte-level primitives the format mixes (`FirebirdBackupReader`, the generic little-endian tag+length+value attribute framing every `rec_*`/`att_*` record uses, plus its own RLE/"PackBits"-style decompression for `att_data_data` when the backup is compressed; `XdrReader`, the big-endian, 4-byte-aligned RFC 1832 XDR decoding a row's own field values use once compression is peeled off). `blr-types.ts` maps a field's own BLR type opcode (`att_field_type`) onto its physical storage representation, sourced directly from Firebird's own `blr.h`/`align.h`. `date.ts` restates Firebird's own MJD-epoch DATE and 1/10000-second-tick TIME encoding, taken from `NoThrowTimeStamp.cpp`. `schema.ts` walks `rec_relation`/`rec_field` (column definitions gbak has ALREADY resolved from the live engine's system tables at backup time — see the Gotchas entry). `data.ts` walks `rec_relation_data`/`rec_data` (a relation's own rows, addressed by name), decoding each row's XDR-and-possibly-RLE-compressed field-value sequence into `ContentCellValue[]`. `backup.ts`'s `readFirebirdBackup` is the top-level entry point, producing the identical `HsqldbTable[]` shape `parseHsqldbScript` does.
|
|
369
|
-
- **`src/odb/`** — the decoder-selection and pivot-mapping layer sitting between odf.js's `.odb` support and `src/hsqldb/`/`src/firebird/`: `read.ts`'s `readOdbTables(pkg)` calls odf.js's own `readOdbInventory` to classify the package's connection (throwing `OdbNoEmbeddedDataSourceError` for an external-only datasource) and its embedded engine, then routes a genuine HSQLDB TEXT script to `parseHsqldbScript
|
|
401
|
+
- **`src/odb/`** — the decoder-selection and pivot-mapping layer sitting between odf.js's `.odb` support and `src/hsqldb/`/`src/firebird/`: `read.ts`'s `readOdbTables(pkg)` calls odf.js's own `readOdbInventory` to classify the package's connection (throwing `OdbNoEmbeddedDataSourceError` for an external-only datasource) and its embedded engine, then routes a genuine HSQLDB TEXT script to `parseHsqldbScript` and a BINARY/COMPRESSED one to `src/hsqldb/binary-script.ts`'s `parseHsqldbBinaryScript` (which recovers the identical TEXT-format DDL either way), then — whenever a `database/data` part is present — hands that result to `src/hsqldb/cache.ts`'s `decodeHsqldbCachedTables` to splice in every CACHED table's real rows (a `.odb` with no CACHED table at all, the common case, never even looks for `database/data`, leaving the script-derived result untouched), or routes a Firebird `database/firebird.fbk` part to `readFirebirdBackup` — throwing `OdbUnsupportedFormatError` for an embedded engine, or an engine storage shape, it has no reader for at all. `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.
|
|
370
402
|
- **`src/convert/`** — `convert.ts` (the fourteen PDF-pivot round-trip ergonomic wrappers — docx/pptx/odt/odp/ods/odg each with a genuine layout-engine edge, `xlsxToPdf`/`pdfToXlsx` composing the ods⇄xlsx bridge with the ods⇄pdf layout pair internally, and `markdownToPdf`/`pdfToMarkdown` reusing the wordprocessing layout engine directly — plus a dedicated "cross-format bridges" section, ten functions across five pairs: `odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, and `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown`, 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`/`xlsxPdfCodec`/`markdownPdfCodec` plus `odtDocxCodec`/`odpPptxCodec`/`odsXlsxCodec`/`markdownDocxCodec`/`markdownOdtCodec`, 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`/`xlsx`/`markdown` → `pdf`, `pdf` → `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`xlsx`/`markdown`, and the ten bridge functions — `DocumentFormat` includes `xlsx` even though xlsx has no PDF conversion of its own (the port composes one, see `xlsxToPdf`); `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 fourteen PDF-pivot conversions and the ten 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-schema.js`) that conversion built, not just its target bytes.
|
|
371
403
|
|
|
372
|
-
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
|
|
404
|
+
Dependency direction among this package's own local modules is downward and checkable, with one deliberate exception (`layout`, noted below): `mathml`/`ports` import nothing local (`mathml` is fully self-contained — no dependency on `model`, `document-schema.js`, or any ODF package, since it consumes only its own locally-mirrored `MathMlNode` input and its own injected `MathFontMetrics` port); `model` imports nothing local at all any more — `formula.ts`'s former type-only `MathMlNode` import from `mathml` is gone with the local `EmbeddedFormula` type it served, since document-schema.js now owns a fully-specified `MathMlNode` of its own; `ooxml/*` imports no local module at all (now a thin adapter over `ooxml.js`'s own `readDocx`/`readPptx` — see the `src/ooxml/` entry above — with no `model`/`xml/*` dependency of its own left, since `ContentDocument`/`CONTENT_FORMAT_VERSION` now come straight from `document-schema.js`; no PDF knowledge either); `odf/*` imports `model` only, and only for `formula.ts`'s block/document builders and `geometry.ts`'s `Box`/`PAGE_SIZE_A4` (its own `ContentDocument`/`CONTENT_FORMAT_VERSION` usage is `document-schema.js`-direct too now — no PDF knowledge, no `xml/*` — `odf.js` already owns its own XML query helpers); `markdown` imports `model` only, and only for `formula.ts`'s stand-in text on the write side (`write.ts` flattens a formula block markdown cannot represent), plus the external `markdown-codec` dependency directly (no PDF knowledge, no odf.js/ooxml.js knowledge at all — the one adapter package in this family whose source format is not a zip archive); `omml` imports `mathml` (its node helpers, operator dictionary, `mathvariant` type, and length parser) and `xml/*` (`fragment.ts`'s `el`/`txt`, `entities.ts`'s `encodeXmlText`) only, plus `ooxml.js` for its own `XmlElement` output type — never `model`, `layout`, or any ODF package, and never in the other direction: `mathml` still imports nothing local at all, which is exactly why this translator is a sibling of it rather than a file inside it; `hsqldb` imports `document-schema.js` only (no odf.js knowledge); `firebird` imports `document-schema.js` (its own row/schema decoding, `ContentCellValue` only) and `hsqldb` (`HsqldbTable`/`HsqldbColumn`, a type-only import for its own output shape — the deliberate pivot-sharing point between Tier 1 and Tier 3) but no odf.js knowledge at all; `layout` imports `model`+`mathml`+`ports`, plus, genuinely upward and outward, several text-measurement/font-metric/matrix primitives from the external `pdf-codec` dependency (`measure.ts`/`text-layout.ts`/`math-font.ts`/`matrix.ts`/`afm-widths.ts`/`fonts.ts` — see the `src/layout/` entry above for exactly which); `odf-package` imports odf.js only (no local dependency, mirroring `opc`'s relationship to `ooxml.js`); `odb` imports `hsqldb`+`firebird`+`model`+`odf-package`+odf.js only; `convert` composes everything else, including `pdf-codec` directly for `readPdf`/`writePdf`/`loadMathFont` and `markdown-codec` indirectly via `markdown/read.ts`/`markdown/write.ts`/`markdown/text.ts`. Beyond this package's own local modules, five external dependencies each own a distinct concern with no overlap: `ooxml.js` (docx/pptx/xlsx ⇄ JSON), `odf.js` (odt/ods/odp/odg ⇄ JSON), `document-schema.js` (the shared `ContentDocument`/`LayoutDocument` schemas), `pdf-codec` (the PDF codec itself, plus the text-layout/font-resolution/byte/image primitives built on it), and `markdown-codec` (CommonMark+GFM ⇄ `ContentDocument`). No `PdfObject`/`PdfDict`/`PdfStream` type appears anywhere in this package at all — that type is pdf-codec's own internal concern now, never exposed across the package boundary.
|
|
373
405
|
|
|
374
406
|
## Build, test, and lint
|
|
375
407
|
|
|
@@ -403,42 +435,48 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
403
435
|
- **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 five cross-format bridge pairs below (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown`) are a genuinely different case — see the [Fidelity](#fidelity) section's own paragraphs on them (the first three pairs carry no lossiness of their own at all; the two markdown pairs are a nuanced middle case — see that section for exactly why).
|
|
404
436
|
- **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-schema.js`'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-schema.js`) detects or rejects that.
|
|
405
437
|
- **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.
|
|
406
|
-
- **A table shape inside an odp slide
|
|
407
|
-
- **The `ods ⇄ xlsx` bridge
|
|
438
|
+
- **A table shape inside an odp/pptx slide (a `draw:frame`/`p:graphicFrame` whose own content is a table, not inside a text box) now survives `odpToPptx`/`pptxToOdp` both ways.** `buildOdpPackage`/`buildPptxPackage`'s own `appendShape` (`src/edit/odp/content.ts`, `src/edit/pptx/content.ts`) used to silently drop any non-paragraph block found inside a shape's own text-box loop — a scope choice whose own comment ("PDF-reconstructed shapes never mix kinds") assumed its only caller was the PDF-reconstruction path, where that was true; `odpToPptx`/`pptxToOdp` are two further, non-PDF-reconstructed callers for which it was not. Fixed by `OdpSlide.addTable`/`PptxSlide.addTable` (see the `src/edit/` Architecture entry above for the mechanics, including the third distinct merge convention DrawingML tables use) — verified by `src/convert/bridges.test.ts`'s own round trip against the `minimalOdpBytes()` fixture.
|
|
439
|
+
- **The `ods ⇄ xlsx` bridge's fidelity improved substantially with `ooxml.js` 2.6.1's full xlsx number-format engine, but it is still not perfect.** `buildXlsxPackage` now writes a real numFmt per semantic kind — a `"0.00%"`-family format for `percentage`, a `"[$CODE]#,##0.00"`-family format for `currency` (the ISO currency code embedded in the format code itself, since xlsx has no dedicated currency cell type), a date-only format for `date`, and a `"TRUE";"TRUE";"FALSE"` format for `boolean` — and `readXlsxContent` reads the format code back to recover the real kind, so an ods `percentage`/`currency` cell now survives the `odsToXlsx` hop with BOTH its value and its semantic kind intact (currency's own ISO code included), and a boolean cell now displays as `TRUE`/`FALSE` rather than a raw `1`/`0` when opened in a real spreadsheet application. xlsx still has only one combined `t="d"` wire type for both date and time, but the number-format engine can now tell a date-only format from one that also carries a time component, so an ods `date` cell round-trips as genuine `'date'` rather than a catch-all `'dateTime'`. An ods `time` cell has no numeric serial to write at all — its own `ContentCellValue` carries an ISO-8601 duration STRING, not a fractional-day number — so `buildXlsxPackage` writes it as a plain `string` cell instead of mangling it into a nonsensical date/time value; the value string still survives byte-for-byte, honestly labelled as text. Column widths survive the `odsToXlsx` hop within roughly a pixel of rounding tolerance (see `src/convert/bridges.test.ts`'s own `COLUMN_WIDTH_TOLERANCE_PT`) and now survive the return `xlsxToOds` hop too, within double that tolerance — `buildOdsPackage` (`src/edit/ods/content.ts`) writes `ContentSheetColumn.widthPt` for real via `OdsSheet.setColumnWidth`. A formula (`table:formula`/`<f>`) is still carried completely verbatim in both directions — never parsed, translated, or evaluated by either this package's own reader or writer — but a REAL spreadsheet application does evaluate a workbook's own `<f>`/`table: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. `readXlsxContent`'s own cell.value.kind never produces `'error'` from an odf.js-sourced document at all, for a structural reason rather than a bug, confirmed permanent rather than an open question: ODF's `office:value-type` enumeration simply has no `error` member — verified against real LibreOffice 26.2 output, a genuine `#DIV/0!` formula cell serializes as `office:value-type="string"` with an EMPTY `office:string-value`, the error text surviving only in the cell's own `text:p`/displayText, never in any `office:value-type`-driven wire value. The one place the string `"error"` appears anywhere in the format is LibreOffice's own `calcext:value-type="error"` extension attribute, a private, unstable vendor namespace outside the OASIS ODF 1.3 spec — the identical category of escape hatch `odf.js`'s own `typed/shared/table.ts` already declined for `loext:graphic-properties/@draw:fill-color` over the standard `fo:background-color`, and declined here for the same reason: this package's own convention is OASIS-spec-grounded, not vendor-extension-chasing, and a private namespace a future LibreOffice release can rename or drop is not a foundation to build a public API's data fidelity on. `OdsCell.value`'s own write-side choice for a `kind: 'error'` cell is consequently 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, and no mechanism inside or outside the ODF spec can preserve it. This is a permanent format-boundary limitation, not a gap either `odf.js` or this package could close by implementing something — there is nothing standards-based left to implement.
|
|
408
440
|
- **`odpToPdf`/`pdfToOdp` needed zero new layout code.** `readOdpContent` (`src/odf/odp/read.ts`) produces the identical `presentation` `ContentDocument` shape `readPptxContent` does, so it feeds `convertPresentationToLayout` unmodified — including the existing hidden-annotation speaker-notes mechanism below, which carries odp's `presentation:notes` through to the PDF with no new notes-handling code at all; `pdfToOdp` reuses `reconstructPresentation` unmodified too, the same architectural bet `pdfToOdt` already proved for `reconstructWordprocessing`. The genuinely new work for the reverse direction was the live-view editor itself (`src/edit/odp/*`) — see Architecture above.
|
|
409
|
-
- **`OdpShape.rotationDeg` writes a real `draw:transform`, built on `odf.js`'s own transform machinery.** It is the write-side inverse of `odf.js`'s `resolveOdfShapeGeometry` (`typed/shared/transform.ts`), built on that module's own exported `applyOdfTransform` rather than a hand-rolled rotation matrix, so it inherits that module's own empirically-verified rotate/translate composition order and sign convention by construction.
|
|
410
|
-
- **`readPdf`
|
|
411
|
-
- **`pdfToOds`
|
|
441
|
+
- **`OdpShape.rotationDeg` writes a real `draw:transform`, built on `odf.js`'s own transform machinery.** It is the write-side inverse of `odf.js`'s `resolveOdfShapeGeometry` (`typed/shared/transform.ts`), built on that module's own exported `applyOdfTransform` rather than a hand-rolled rotation matrix, so it inherits that module's own empirically-verified rotate/translate composition order and sign convention by construction. `buildOdpPackage` writes a rotated shape's rotation back correctly — verified both by this package's own tests and by opening a fresh, editor-built `.odp` in actual LibreOffice. `PptxShape.rotationDeg` (see the `src/edit/` Architecture entry above) is the DrawingML-side counterpart, and a rotated shape now round-trips through `odpToPptx`/`pptxToOdp` both ways too (`src/convert/bridges.test.ts`'s own dedicated rotation test).
|
|
442
|
+
- **`readPdf` recovers a rect, an ellipse, and a line as their own `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds, not merely as generic paths — pdf-codec's own shape-pattern detection, and the reason every vector kind now survives a `odgToPdf` → `pdfToOdg` round trip.** PDF has exactly one shape operator (`re`, itself defined as a four-point rectangle subpath) and no ellipse or line operator at all, so a writer has no way to record what a path *was*; pdf-codec recovers it from the geometry instead — an axis-aligned closed four-corner subpath is a `LayoutRect` (any combination of fill and stroke, and a 90°-rotated CTM as well as an unrotated one), a closed subpath of four cubic segments meeting its bounding box at the four cardinal points with kappa-ratio control points is a `LayoutEllipse`, and an open single-straight-segment stroke-only subpath is a `LayoutLine`. See pdf-codec's own README for the tolerances and the honest caveat that these are bounded heuristics: a false positive can change an item's *kind*, never its geometry. What still narrows to a generic `LayoutPath`: an off-axis rotation, a freeform curve, a multi-subpath figure. A practical consequence for `pdfToOds`: a gridline written by `sheets.ts`'s own `renderGridlines` now comes back as a real `LayoutLine`, but `reconstructSpreadsheet`'s lattice detection still accepts the stroked-single-segment `LayoutPath` shape too, so a hand-built `LayoutDocument` and one from a producer other than `readPdf` detect identically.
|
|
443
|
+
- **`pdfToOds` re-types a recovered cell heuristically, and this is explicitly PROBABILISTIC BEST-EFFORT RECOVERY, not a fidelity guarantee.** A rendered PDF genuinely never carries a spreadsheet cell's own typed value — a page holds only the string the authoring application chose to print — so every re-typed value below is an inference from that string alone, and a string that looks exactly like a number may genuinely have *been* a string in the source spreadsheet (a part number, a version, a phone extension). Nothing in this package can tell those apart with certainty, and no further heuristic would change that. What is guaranteed: `ContentSheetCell.displayText` is a required field carrying the rendered string verbatim regardless of what was inferred from it, so the printed form is never lost. `src/layout/cell-typing.ts` re-types only where the string has exactly **one** defensible reading, which resolves to four concrete requirements: the decimal must be exactly representable as a JS number (checked by round-tripping it, not by a digit-count limit — this is what keeps a 19-digit barcode a string); the separators must be unambiguous (`.` reads as the decimal separator and `,` as grouping, but a lone comma group like `"1,234"` is **declined**, since the competing European reading of the identical string is 1.234, a thousandfold error — `"1,234,567"` and `"1,234.50"` have no such competing reading and are accepted); a leading zero (`"007"`, `"01.5"`) is declined outright, since a spreadsheet never prints a numeric value with one; and a date's component roles must be stated by the text itself — ISO ordering (`"2024-01-15"`) or a named month (`"15 Jan 2024"`, `"Jan 15, 2024"`) is accepted, an all-numeric separated date (`"01/02/2024"`) is declined regardless of whether one component happens to exceed 12 in that particular cell, because resolving it per cell would type one column inconsistently. `TRUE`/`FALSE` are re-typed as booleans; `Yes`/`No`/`Y`/`N`/`On`/`Off` are **declined**, since no mainstream spreadsheet prints a boolean that way by default, so a `"Yes"` cell is far more likely genuine text. Percentages recover ODF's own fraction convention (`"15%"` → `0.15`); a currency symbol names an ISO code only where it identifies exactly one (`£`→GBP, `€`→EUR; `$` and `¥` re-type as `currency` with the code left `undefined`). `'time'`/`'dateTime'`/`'error'` are deliberately out of scope, and a formula is never claimed. Two ways to tell an inferred value from an untouched one: `value.kind !== 'string'` is itself the flag, and `ReconstructOptions.onCellTypeInference` reports every decision — both a re-typing (with the rule that fired) and a deliberate refusal (with the named ambiguity), the latter being information the output alone cannot carry, since a declined cell is indistinguishable from one that was never number-shaped at all. `inferCellValue` is exported standalone for a caller who wants to replay the same decision over their own text.
|
|
444
|
+
- **`reconstructWordprocessing`/`reconstructPresentation` now recover a page's vector primitives too, in a nested drawing document — but no OOXML/ODF wordprocessing or presentation builder can write them yet.** Both directions used to filter each page down to its text and image items and discard every stroke and fill; they now run the same `layoutItemToVector` classification `reconstructDrawing` does (one implementation, not two) and carry the result in a `ContentEmbeddedObjectBlock` whose `objectKind` is `'drawing'` and whose nested `document` is a real one-page drawing `ContentDocument` — the shared schema's own designed mechanism, since `ContentSection.blocks` and `ContentSlide.shapes` have no vector vocabulary of their own. Two honest consequences. First, a PDF does not distinguish a stroke drawn to decorate from one drawn as structure, so a rule under a heading, an underline (pdf-codec writes one as a filled rectangle), and a table cell's background fill are all recovered as vectors — that is intended, since discarding real content because it *might* be incidental is exactly the silent loss this package's conventions rule out, but it does mean a reconstructed document carries more than its text alone. A table's own gridlines are the one case deliberately not double-counted: when the table recovery above claims a lattice, the strokes that formed it are excluded from vector recovery. Second, and stated so this is not mistaken for an end-to-end feature: the recovered vectors reach the `ContentDocument` pivot **only** — exposed through `pdfToDocx`/`pdfToPptx`'s own `onDocument` callback and the `DocumentConverter` port's `ConversionResult.package` — because none of `buildDocxPackage`/`buildPptxPackage`/`buildOdtPackage`/`buildOdpPackage` can write a vector shape at all: there is no DrawingML preset/custom-geometry writer under `src/edit/docx/` or `src/edit/pptx/`, and the ODF builders have no `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path` path either (`src/edit/odg/` has those, but only for a drawing page). A caller wanting the recovered vectors as a real file today can hand the nested drawing document straight to `buildOdgPackage`. Writing them into docx/pptx/odt/odp is a genuine, separate feature — the OOXML/ODF-shape mirror of `src/edit/odg/` — not a loose end of this one. `pdfToMarkdown` drops the block entirely rather than emitting a marker for it: a rect carries no text to stand in for, unlike a formula, and CommonMark has no vector construct regardless.
|
|
445
|
+
- **`pdfToOds` recovers what was printed, not what was entered.** `reconstructSpreadsheet` (`src/layout/reconstruct.ts`) tries a real gridline lattice first: it scans the page's `LayoutLine`/stroked-single-segment-`LayoutPath` items (see the `interpret.ts` gotcha above) for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid (`MIN_GRIDLINE_COUNT_PER_AXIS = 3` per axis, i.e. at least a 2×2 grid, and a span-consistency check that rejects a scatter of unrelated short strokes — a page border or a couple of decorative rules — as not a genuine lattice), and uses those line positions DIRECTLY as cell boundaries when found. Absent a lattice, it clusters text into a grid from geometry alone instead: rows reuse `clusterIntoLines` verbatim (a spreadsheet cell's own text is never wrapped across lines, so a text line already IS a row), and columns generalise `clusterIntoParagraphs`'s own single `dominantLeftX` to several recurring x-position anchors, first merging directly-adjacent same-line fragments (`splitLineByLargeGaps`, the same >2em-gap signal `reconstructPresentation`'s own block clustering uses) so a cell whose text arrived as several run-level-split `LayoutText` items isn't scattered across spurious columns. Column widths and row heights are genuinely measured from whichever geometry was used (drawn gridline gaps, or measured text/anchor extents), never invented. Every recovered cell always carries its own extracted `displayText` verbatim, and additionally carries a **heuristically re-typed** `value` wherever `src/layout/cell-typing.ts` finds exactly one defensible reading of that string; a formula is still never claimed. See the dedicated heuristic-re-typing gotcha below and [Fidelity](#fidelity) for the full framing. `buildOdsPackage` (`src/edit/ods/content.ts`) is `pdfToOds`'s own package-building half, mirroring `buildOdtPackage`/`buildOdpPackage`/`buildOdgPackage`'s role for `pdfToOdt`/`pdfToOdp`/`pdfToOdg`.
|
|
412
446
|
- **`buildOdsPackage` now writes `printSettings` for real, via a new `OdsSheet.printSettings` getter/setter (`src/edit/ods/print-settings.ts`) — discovered as a genuine blocker while building `pdfToOds`'s own round-trip verification, not a pre-planned feature.** `OdsEditor`/`OdsSheet` previously had no width/height/print-settings API at all, so `buildOdsPackage` silently dropped `ContentSheetPrintSettings` entirely; that made a reconstructed sheet's own recovered `gridlines`/`headers`/`pageSize` unverifiable by any real write-then-reread round trip, which is exactly what `pdfToOds`'s own test needed to prove. The setter mints a fresh `style:page-layout` (`styles.xml`/`office:automatic-styles`) + `style:master-page` (`styles.xml`/`office:master-styles`) + `style:style[family="table"]` (`content.xml`/`office:automatic-styles`) triple and repoints the sheet's own `table:style-name` to it on every call, rather than mutating whatever it was pointing at before — the same append-only style-editing convention `src/edit/odg/style.ts` already documents. Scoped to the five fields `ContentSheetPrintSettingsSchema` always carries (`pageSize`/`margins`/`gridlines`/`headers`/`pageOrder`); `printRange`/`scale`/`fitToPages`/`repeatRows`/`repeatColumns`/`manualBreaks` (all optional, and never set by `reconstructSpreadsheet`) are still not read or written — resolving them needs the same table-wide repeated-column/row cursor tracking `odf.js`'s own `readTable` does before ever calling its own `readPrintSettings`, a genuinely separate, larger undertaking than this getter/setter's own scope.
|
|
413
|
-
- **`OdsSheet` now has a real column-width/row-height setter (`setColumnWidth`/`setRowHeight`, `src/edit/ods/column-row.ts`), closing a gap that escalated from cosmetic to a genuine correctness bug once `xlsxToPdf`/`pdfToXlsx` started composing through `buildOdsPackage` internally.** `OdsSheet.cell()`'s own column/row-materialisation (`address.ts`) creates a real, explicit `table:table-column`/`table:table-row` element for any position a caller ever addresses, but previously never gave it a width/height style. This is a genuinely different failure shape from a column/row with NO element at all: `sheets.ts`'s own `resolveAxis` only falls back to `DEFAULT_COLUMN_WIDTH_PT`/`DEFAULT_ROW_HEIGHT_PT` for an index with no `ContentSheetColumn`/`ContentSheetRow` entry whatsoever — an explicit-but-unstyled element reads back at `widthPt`/`heightPt` 0 (`odf.js`'s own `resolveColumnWidthPt`/`readRowLayout`), and that explicit zero wins over the fallback. While `buildOdsPackage`'s own output was only ever a terminal deliverable (`pdfToOds`, or a caller's own `readOdsContent` round trip), this was cosmetic: a real app reopening it would use its own defaults instead of the source's. `xlsxToPdf` (`xlsxToOds` then `odsToPdf`) made it a real bug instead — the intermediate ods bytes get laid out again by `convertSpreadsheetToLayout`, and a zero-size grid collapses every cell onto the same physical position rather than merely losing precision. `setColumnWidth`/`setRowHeight` mint a fresh `style:style[family="table-column"|"table-row"]` per column/row and repoint its own `table:style-name`, the same append-only style-minting convention `writeSheetPrintSettings`/`src/edit/odg/style.ts` already establish; `buildOdsPackage` now calls both for every `ContentSheetColumn`/`ContentSheetRow` a source sheet carries. Column/row HIDDEN state (`table:visibility`, a plain attribute rather than a style property) remains a separate, still-untracked write-side gap, alongside `ContentSheetImage`/`embeddedObjects` — mirroring `buildOdtPackage`'s own identical image
|
|
414
|
-
- **`reconstructDrawing` maps recovered geometry back onto ODF shapes near-1:1, with no clustering —
|
|
447
|
+
- **`OdsSheet` now has a real column-width/row-height setter (`setColumnWidth`/`setRowHeight`, `src/edit/ods/column-row.ts`), closing a gap that escalated from cosmetic to a genuine correctness bug once `xlsxToPdf`/`pdfToXlsx` started composing through `buildOdsPackage` internally.** `OdsSheet.cell()`'s own column/row-materialisation (`address.ts`) creates a real, explicit `table:table-column`/`table:table-row` element for any position a caller ever addresses, but previously never gave it a width/height style. This is a genuinely different failure shape from a column/row with NO element at all: `sheets.ts`'s own `resolveAxis` only falls back to `DEFAULT_COLUMN_WIDTH_PT`/`DEFAULT_ROW_HEIGHT_PT` for an index with no `ContentSheetColumn`/`ContentSheetRow` entry whatsoever — an explicit-but-unstyled element reads back at `widthPt`/`heightPt` 0 (`odf.js`'s own `resolveColumnWidthPt`/`readRowLayout`), and that explicit zero wins over the fallback. While `buildOdsPackage`'s own output was only ever a terminal deliverable (`pdfToOds`, or a caller's own `readOdsContent` round trip), this was cosmetic: a real app reopening it would use its own defaults instead of the source's. `xlsxToPdf` (`xlsxToOds` then `odsToPdf`) made it a real bug instead — the intermediate ods bytes get laid out again by `convertSpreadsheetToLayout`, and a zero-size grid collapses every cell onto the same physical position rather than merely losing precision. `setColumnWidth`/`setRowHeight` mint a fresh `style:style[family="table-column"|"table-row"]` per column/row and repoint its own `table:style-name`, the same append-only style-minting convention `writeSheetPrintSettings`/`src/edit/odg/style.ts` already establish; `buildOdsPackage` now calls both for every `ContentSheetColumn`/`ContentSheetRow` a source sheet carries. Column/row HIDDEN state (`table:visibility`, a plain attribute rather than a style property) remains a separate, still-untracked write-side gap, alongside `ContentSheetImage`/`embeddedObjects` — mirroring `buildOdtPackage`'s own identical image-write gap (its own colSpan/rowSpan gap is now closed, see the merged-table-cell gotcha below).
|
|
448
|
+
- **`reconstructDrawing` maps recovered geometry back onto ODF shapes near-1:1, with no clustering — and every vector kind in this package's own `.odg` fixture now survives the round trip, where a stroked rect, an ellipse, and a line used to collapse to a generic `path`.** Every painted `LayoutItem` maps onto a `ContentVector`/`ContentShape` directly, in the exact z-order it was recovered — `LayoutRect` → `rect`, `LayoutEllipse` → `ellipse`, `LayoutLine` → `line`, `LayoutPath` → `path`, `LayoutText`/`LayoutImage` → `ContentShape` — a fundamentally more tractable problem than `reconstructWordprocessing`/`reconstructPresentation`'s own paragraph/shape geometry clustering, since a drawing has no semantic structure to infer at all. How much *kind* information survives is decided upstream, by what `readPdf` can hand it: pdf-codec's own shape-pattern detection (see the gotcha above) now recovers a rect under any fill/stroke combination, a real ellipse from the four kappa-ratio cubics `writeEllipse` emits, and a real line, so `reconstructDrawing` receives — and therefore emits — the original kind in each case. What still narrows: a rotation that is not a multiple of 90° leaves no axis-aligned pattern to match, so a rect turned by 30° comes back as a `path` carrying its four rotated corners exactly. Position, size, and fill/stroke colour survive regardless of kind (within ordinary floating-point/string-formatting tolerance). A `path` vector's own reconstructed `frame` is a further, separate approximation: it is the *tight* bounding box of every recovered point, cubic control points included (a cubic curve is guaranteed to lie within their convex hull, so this never clips the curve) — which can legitimately be *larger* than whatever frame the original path's own author declared, if that frame didn't tightly bound its own control points to begin with (a real, valid ODF/SVG authoring pattern: a `viewBox`/frame is a declared coordinate window, not a guaranteed tight bounding box). A single original drawing text box that PDF's own greedy line-wrapper split across several lines does **not** reconstruct as one multi-line shape: `reconstructDrawing` maps each recovered `LayoutText` item to its own separate `ContentShape` (the same one-`LayoutItem`-to-one-shape rule every other kind follows), so a wrapped multi-line text box comes back as several small, independently-positioned text boxes, one per original line — confirmed visually against real LibreOffice (see the real-file verification note below); the full text content still survives, just redistributed. `buildOdgPackage` (`src/edit/odg/content.ts`) is `pdfToOdg`'s own package-building half, mirroring `buildOdtPackage`/`buildOdpPackage`'s role for `pdfToOdt`/`pdfToOdp`.
|
|
415
449
|
- **Two real, confirmed-against-actual-LibreOffice-rendering fill bugs were fixed as part of building `reconstructDrawing`/`pdfToOdg`, not by it.** Both are pre-existing gaps in code that `reconstructDrawing`'s own real-file verification exposed, not something the reconstruction algorithm itself introduced, and both apply to every `.odg` this package writes, not only a reconstructed one: (1) `src/edit/odg/style.ts`'s `graphicPropertyAttrs` wrote `draw:fill-color` alone, with no accompanying `draw:fill="solid"` — real LibreOffice 26.2 fills a `draw:rect`/`draw:ellipse` that way fine, but silently renders a `draw:path` with the identical omission as unfilled, even with a fill colour declared. `draw:fill="solid"` is now written explicitly whenever a fill is set, for every vector kind. (2) `writeEllipse` (pdf-codec's own `content-write.ts`) never emitted a PDF closepath (`h`) operator, even though its four Bezier arcs already return exactly to their own starting point — PDF fill operators close every subpath implicitly regardless (ISO 32000-1 8.5.3.1), but `readPdf`'s own general path tracking only marks a subpath `closed: true` when it actually sees an explicit `h`, so a PDF-round-tripped ellipse came back with `closed: false`, which correctly-behaving ODF/SVG consumers then refuse to fill even with `draw:fill="solid"` set. `writeEllipse` now emits `h` before its paint operator, drawing no additional ink (the path was already geometrically closed) but recording that closure explicitly.
|
|
416
450
|
- **A vector primitive's own fill/stroke needed a self-contained graphic-family style writer, not `odf.js`'s own `StyleRegistry`.** `'graphic'` is a recognised `StyleFamily` member (`odf.js`'s `src/styles/registry.ts`), but `StylePropertiesSchema`/`buildStylePropertyElements` (`properties.ts`/`serialize.ts`) only ever model text/paragraph formatting and never emit a `style:graphic-properties` element for any family — extending that shared package for one narrow, documents.js-local need (`draw:fill(-color)`/`draw:stroke` + `svg:stroke-color`/`svg:stroke-width`) would be scope creep into a foreign package for a two-attribute-group writer this package can express directly. `src/edit/odg/style.ts` is that writer: it still reuses `odf.js`'s general append-only style-editing invariant (a setter always mints a fresh `style:style` and repoints `draw:style-name`, never mutates an existing entry — verified by the same `assertAutomaticStylesOnlyAppended` helper `OdpEditor`'s own live-view fidelity test uses) and `src/edit/odt/automatic-styles.ts`'s `ensureAutomaticStyles`/`nextStyleName` (the "find-or-create `office:automatic-styles`, mint the next unused name" logic every other hand-rolled style helper in this package already shares), rather than a third reimplementation of either.
|
|
417
451
|
- **A path vector's own `svg:d` is cross-checked against `odf.js`'s real parser, not merely asserted to "look plausible".** `src/edit/odg/svg-path.ts`'s `buildSvgPathData` is the write-side inverse of `odf.js`'s `parseOdfPathData`; `OdgPathVector.subpaths` re-derives its value by reparsing the actual written `svg:viewBox`/`svg:d` through that exact function (plus `parseOdfViewBox`/`buildOdfSubpaths`) on every read, rather than echoing back whatever `ContentSubpath[]` the caller originally passed to `addPath` — so every read is itself a live round-trip proof, and this module's own test suite additionally feeds `buildSvgPathData`'s output straight into `parseOdfPathData` to confirm point-for-point recovery.
|
|
418
452
|
- **A newly added vector/shape's paint order is expressed purely as document order, with no `draw:z-index` ever written.** This matches `odf.js`'s own reader-side convention exactly (`typed/draw/shapes.ts`'s `paintOrderKey`: honour an explicit `draw:z-index` when present, otherwise fall back to document order — and real LibreOffice output never emits one, it reorders elements instead), so `OdgPage.addRect`/`addEllipse`/`addLine`/`addPath`/`addTextBox`/`addImage` simply append to `draw:page`'s own children in call order and nothing more is needed for a later `add*` call to paint in front of an earlier one.
|
|
419
453
|
- **`LayoutPathSchema` (`document-schema.js`) has no quadratic or elliptical-arc segment kind, deliberately — not a scope gap that happens to be unfilled.** `writePath` (pdf-codec's own `content-write.ts`) therefore has no quadratic-to-cubic elevation and no SVG-arc-to-cubic endpoint-to-centre parameterization anywhere in it: `odf.js`'s own real-LibreOffice-output-verified `svg:d` parser (`typed/shared/path.ts`) recognises `S`/`s`/`Q`/`q`/`T`/`t`/`A`/`a` as command letters (so its own token stream stays in sync) but produces no segment for any of them — real LibreOffice output for rectangles, ellipses, freeform curves, and basic custom-shape presets never emits a quadratic or an arc in the first place, only `M`/`L`/`H`/`V`/`C`/`Z`. Building unused quadratic/arc conversion code against a segment kind that can never occur would be speculative, not root-cause work.
|
|
420
|
-
- **A drawing page's `shapes` and `vectors`
|
|
421
|
-
- **A vector primitive
|
|
454
|
+
- **A drawing page's `shapes` and `vectors` are two separate arrays, but their true relative paint order is carried by a shared `paintOrder` field on both.** `ContentDrawPageSchema` (`document-schema.js`) still keeps text/image/table content (`shapes`) and vector primitives (`vectors`) apart, but `ContentVector` and `ContentShape` each carry a `paintOrder` — one monotonically increasing per-page document index `odf.js`'s own reader stamps on every element it walks (`typed/draw/shapes.ts`'s `walkDrawPageContent`/`paintOrderKey`, honouring a real `draw:z-index` where a producer wrote one, falling back to document position otherwise). `convertDrawingToLayout` merges the two arrays back into one true-paint-order walk through that field (`src/model/paint-order.ts`'s `mergeByPaintOrder`), `reconstructDrawing` stamps the same field from its own single walk over a page's recovered items, and `buildOdgPackage` appends in the same merged order (document order *is* paint order in a written `.odg` — this package never emits a `draw:z-index`). A page that genuinely interleaves the two mid-stack — a text label between two rectangles, a rectangle over a picture — consequently paints in the order its author built it, and survives `convertDrawingToLayout` → `reconstructDrawing` with that interleaving intact. The historical "every vector paints before every shape" rule survives only as the documented fallback for a page missing `paintOrder` anywhere (a hand-built `ContentDocument`, or one produced before the field existed), since an item with no value has no defensible position to be sorted into and inventing one would silently reorder content.
|
|
455
|
+
- **A rotated vector primitive renders as a `LayoutPath`, not as a rotated `LayoutRect`/`LayoutEllipse` — because neither of those carries a rotation field at all.** `ContentVectorSchema`'s `rect`/`ellipse`/`path` variants each carry a real `rotationDeg` (the `line` variant does not, and needs none — two endpoints already encode any orientation a line can have), `odf.js`'s own reader resolves one through the same `resolveOdfShapeGeometry` a `draw:frame` uses, and `OdgBoxVector.rotationDeg`/`OdgPathVector.rotationDeg` write one back through the same shared `applyOdfGeometry` (`src/edit/geometry.ts`) `OdpShape.rotationDeg` uses. `convertDrawingToLayout` resolves a rotated vector into a `LayoutPath` whose own points are the shape's corners/curve controls after rotation — a rotated rect becomes a genuine four-point closed subpath, a rotated ellipse its own four cubics rotated — since `LayoutRectSchema`/`LayoutEllipseSchema` model no rotation and only `LayoutText`/`LayoutImage` do (pdf-codec rotates those two through a text/image transformation matrix, which a path-painting operator sequence has no equivalent of). Nothing is approximated by this: an affine rotation maps a straight edge to a straight edge and a cubic Bézier to a cubic Bézier exactly. What a PDF round trip cannot preserve is the `rotationDeg` *field* — a recovered path records where the corners ended up, never that a right-angled box was turned to get there — so `pdfToOdg` returns a rotated rect as an unrotated `path` vector whose geometry is genuinely rotated, the same kind-narrowing every other vector already documents below.
|
|
422
456
|
- **`ContentVector`'s `path` variant's `fillRule` is never populated by the reader — always `undefined`, which `writePath` treats as nonzero.** `odf.js`'s `readDrawPathVector` does not currently resolve an evenodd fill rule from real ODF output, so every path this pipeline reads paints with PDF's default nonzero winding rule. `LayoutPathSchema`/`writePath` fully support `fillRule: 'evenodd'` regardless — a caller constructing a `LayoutPath` (or a future `ContentVector` producer) directly can still set it; it just never arrives via `odgToPdf` today.
|
|
423
|
-
-
|
|
457
|
+
- **A cell's declared border renders solid whatever its `style` says, because the layout schema has nowhere to carry a dash pattern.** `ContentSheetCellSchema` and `ContentTableCellSchema` both carry real per-cell `background`/`borders` (and `ContentSheetCellSchema` also `alignment`/`verticalAlignment`), `odf.js`'s own reader populates every one of them from a cell's resolved style chain, and `sheets.ts`/`engine.ts` render all of them: a background becomes a real `LayoutRect`, each declared border edge a real `LayoutLine`, and a cell's own alignment/vertical alignment override the value-kind default and the bottom default respectively. What does not survive is `ContentBorder.style` (`solid`/`dashed`/`dotted`/`double`) — `LayoutLineSchema` is kind/x1/y1/x2/y2/color/widthPt and `LayoutPathSchema`'s own stroke is color/widthPt, so there is no dash array to write and nothing in pdf-codec to read one from. Rendering `double` as two hand-offset parallel lines was considered and rejected: the offset distance is nowhere in the model, so it would be an invented constant standing in for information the source never carried.
|
|
424
458
|
- **Ordinary text in PDF output uses the standard 14 fonts only — no font embedding.** Helvetica/Times-Roman are genuinely metric-compatible substitutes for Arial/Times New Roman, but Word's actual current defaults (Calibri, Aptos) are not, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation, not a line-identical reproduction. The one exception is MathML formula rendering (`odfToPdf`, and formulas embedded inside odt/odp): those genuinely embed the real STIX Two Math font — see the CFF-embedding gotcha below, and pdf-codec's own README, for the exact scope of that embedding (the whole `CFF ` table, not glyph-subsetted).
|
|
425
|
-
- **Justified paragraphs
|
|
459
|
+
- **Justified docx/odt paragraphs now stretch real inter-word gaps — but only in the flow engine (`engine.ts`), not in the direct-placement one (`slides.ts`) or the spreadsheet one (`sheets.ts`).** `justifyLineGapsPt` (`src/layout/shared.ts`) recovers each wrapped line's own word-gap positions from `WrappedLine`'s per-fragment `xOffsetPt` (a genuine gap wider than floating-point noise means a space stood there; two touching fragments are one word split across a run boundary, and stay touching), divides the line's slack evenly across every detected gap, and returns an all-zero shift whenever there is nothing to stretch (fewer than two fragments, no detected gap, or a line already at or past its target width — this function only ever adds space, never compresses). `layoutParagraphFlow` and `layoutParagraphInCell` (both `engine.ts`, so this covers docx/odt paragraphs, docx/odt tables, and odm-assembled chapters, since all three route through this one engine) call it for every wrapped, non-final line of a `'justify'`-aligned paragraph; the paragraph's own final line (or a paragraph that never wraps at all) stays left-aligned, matching Word/LibreOffice's own convention. `alignmentOffsetPt` itself still returns `0` for `'justify'`, unchanged — the whole-line offset it computes is the wrong shape for inter-word stretching, which is why the stretch lives in a second, sibling function the caller applies on top, not a new branch inside it. `slides.ts` (pptx/odp shape text and slide-table cells) and `sheets.ts` (spreadsheet cell text) still call only `alignmentOffsetPt`, with no `justifyLineGapsPt` call anywhere in either file — a justified paragraph inside a pptx/odp shape, or a `'justify'`-aligned spreadsheet cell, still renders left-aligned. A real, tracked, bounded gap, not a silent one: closing it needs the identical per-fragment stretch wired into two more call sites, not new algorithmic work.
|
|
426
460
|
- **Reading arbitrary real-world PDFs, encrypted-PDF support, and unsupported image filters (`CCITTFaxDecode`/`JBIG2Decode`/`JPXDecode`) are all pdf-codec's own scope boundaries now, not this package's.** In short: the parser targets cleanly-generated output from mainstream producers rather than adversarial-input robustness; `/Encrypt` in the trailer throws rather than attempting decryption, even for the common empty-user-password case; scanned-fax and JPEG2000 images are skipped with a diagnostic while the rest of the page still reads (JPEG and PNG both pass through losslessly). See pdf-codec's own README for the full statement of each.
|
|
427
|
-
- **PDF → docx/pptx reconstruction
|
|
428
|
-
- **
|
|
429
|
-
- **docx headers/footers
|
|
461
|
+
- **PDF → docx/pptx/odt/odp reconstruction recovers a table only from a real drawn gridline lattice, and never from text alignment.** `reconstructWordprocessing`/`reconstructPresentation` run the identical detector, thresholds, and span-consistency check `reconstructSpreadsheet` gates its own cell boundaries on (`src/layout/lattice.ts`), and synthesize a real `ContentTable` when — and only when — one fires. Aligned columns of text with wide gaps are deliberately **not** accepted as evidence: several left-aligned lines separated by a tab-sized gap are indistinguishable, from geometry alone, from a genuinely tabbed paragraph, an indented code sample, or a two-column page layout, so building a table out of one would be inventing structure the source never had rather than recovering structure it did. A wide horizontal gap on a line still becomes a tab character, exactly as before. A lattice with no text inside it is rejected too (a grid of empty boxes is far more likely a decorative frame, a chart's plot area, or a form's field outlines than a table). Where a table IS recovered it reaches the output bytes for real — `buildDocxPackage`/`buildOdtPackage` write a real table, `buildPptxPackage`/`buildOdpPackage` a real slide table — with column widths and row heights measured directly from the drawn boundaries, and the lattice's own strokes reported once, as the table's structure, rather than also as loose vectors alongside it. Gradients and shadings are still not recovered at all.
|
|
462
|
+
- **A merged table cell (`colSpan`/`rowSpan`) now round-trips as merged, not as an ordinary unmerged one, through `buildDocxPackage`/`buildOdtPackage`** — and docx and ODF express a merge through two genuinely different conventions, so the two writers (`src/edit/docx/content.ts`, `src/edit/odt/content.ts`) are not mirror images of each other. docx collapses a horizontal merge into ONE real `w:tc` carrying `w:tcPr/w:gridSpan` — no element at all for the columns it consumes — while a vertical merge still needs one real `w:tc` per covered row, marked `w:tcPr/w:vMerge` (`w:val="restart"` on the top cell, a bare `<w:vMerge/>` on each covered row below); `ContentTable.rows[].cells` therefore has exactly one array entry per REAL `w:tc`, which can be fewer than the table's own column count. ODF, by contrast, always writes one array entry per grid position regardless of merge direction: a covered column in the SAME row gets a real `table:covered-table-cell` placeholder element (not just an attribute), and so does a covered row below a `rowSpan` — `table:number-columns-spanned`/`table:number-rows-spanned` mark only the master `table:table-cell`. Both writers track active merges by grid-column index as they walk each row (`DocxTableCell.colSpan`/`.verticalMerge`, `OdtTableCell.colSpan`/`.rowSpan` plus `OdtTableRow.appendCell`/`.appendCoveredCell`), and both are proven by a real build-then-read round trip in `src/edit/docx/content.test.ts`/`src/edit/odt/content.test.ts`, not merely by construction.
|
|
463
|
+
- **docx headers/footers and live `PAGE`/`NUMPAGES` field substitution are still not read** by `readDocxContent` — a deliberate, tracked scope narrowing from the original design, not an oversight. Inline images, however, now ARE read: `ooxml.js` 2.6.1's `readDocx` gained real `w:drawing` support, and `readDocxContent` (a thin adapter over it) inherited that for free, with zero code change on this package's side — see the docx-image round-trip entry directly below for the one thing that DID need a code change.
|
|
464
|
+
- **A docx inline image now reads as a real `ContentImageBlock`, and — since `buildDocxPackage` was taught to recognise the exact shape `readDocx` produces for one — round-trips back to docx without the extra blank paragraph a naive per-block write would otherwise insert.** `readDocx` (`ooxml.js` 2.6.1+) always represents an inline image as TWO adjacent `ContentBlock`s sourced from the one physical `<w:p>`: a paragraph block carrying that paragraph's own (often all-empty) text runs, immediately followed by an image block for the `w:drawing` found inside it — there is no field anywhere in `ContentDocument` distinguishing that pairing from a genuinely separate, intentionally-blank paragraph that happens to sit immediately before an unrelated image; both produce the identical two-block shape. `buildDocxPackage`'s `appendBlocks` (`src/edit/docx/content.ts`) special-cases the pattern `readDocx` actually produces — a paragraph whose runs are all empty text, directly followed by an image block — and writes it back as the single physical paragraph it came from (paragraph properties applied, then `insertImageAfter` called on that SAME paragraph) rather than as two separate paragraphs. This is what makes a full `readDocxContent`/`buildDocxPackage` read → build → read cycle equal byte-for-byte again once an image is involved, rather than accumulating one spurious empty paragraph before every image on every round trip. The one honestly-scoped residual: a paragraph that genuinely is separate and blank, immediately followed by an unrelated image in its own paragraph, is indistinguishable from the common inline-image case and gets merged the same way — an edge case, not the common one this fix targets.
|
|
430
465
|
- **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.
|
|
431
466
|
- **`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 fourteen round-trip conversions or ten 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.
|
|
432
|
-
- **`.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
|
|
433
|
-
-
|
|
467
|
+
- **`.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*; `readOdbForms`/`readOdbReports` (below) extract form/report *structure*; neither renders a Report with live data, and neither ever will.
|
|
468
|
+
- **`odf.js` 2.0.0 turned `OdbInventory.forms`/`.reports` from `string[]` (names only) into `OdbComponentInfo[]` (name + href), and made `readOdbForm`/`readOdbReport` real** — a form's own bound controls (`form:text`/`form:data-field`/etc, plus its content read as an ordinary ODT document via `odf.js`'s own `readOdt`) and a report's own bands/groups/functions (`rpt:report-header`/`rpt:group`/`rpt:detail`/etc, with each control's data-bound field name resolved from its `rpt:formula`) are now real, readable structures rather than bare names. Neither was wired into `readOdbTables` (scoped to table DATA, not form/report STRUCTURE), so `readOdbForms`/`readOdbReports` (`src/odb/components.ts`) are this package's own "read every declared one at once" convenience — calling `odf.js`'s own `readOdbForm`/`readOdbReport` once per name discovered via `readOdbInventory`, the same `readOdbTables`-shaped one-call ergonomic this data did not have before. Both single-name functions are also re-exported unmodified for a caller that wants exactly one named form/report, matching the "each pipeline stage independently usable" convention `readOdbTables`/`decodeHsqldbCachedTables`/`readFirebirdBackup` already follow.
|
|
469
|
+
- **All four `.odb` decoder tiers are implemented: HSQLDB TEXT-script rows (MEMORY/TEXT tables, Tier 1), HSQLDB's own binary CACHED-table row-store rows (Tier 2), a Firebird-backed embedded database's own gbak logical-backup format (Tier 3, see the dedicated Tier 3 entries below), and HSQLDB's own whole-script BINARY (`hsqldb.script_format=1`) and COMPRESSED (`=3`) serialisations (Tier 4).** Tier 4 turned out to be far closer to a sibling of Tier 2 than the earlier, unimplemented-tier framing suggested, and needed no new value decoding at all: `ScriptWriterBinary` writes the database's DDL as one `org.hsqldb.Result` record — the identical `Result` `DatabaseScript.getScript` builds for the TEXT writer, serialised through `Result.write`/`RowOutputBinary` rather than printed — followed by a per-table section carrying each MEMORY/TEXT table's rows in exactly the per-column binary encoding `src/hsqldb/rowformat.ts` already decodes for a CACHED table's row store. So `parseHsqldbBinaryScript` recovers the DDL statements, rejoins them into ordinary TEXT-format script text, hands that to Tier 1's own `parseHsqldbScript` for the table/column definitions, and splices in the rows the binary section carried; because that recovered text still contains the same `SET TABLE ... INDEX'...'` lines, a BINARY-format script belonging to a database with CACHED tables composes with Tier 2 exactly as a TEXT one does. `hsqldb.script_format=3` is that identical byte stream wrapped in ordinary zlib `DEFLATE` (RFC 1950 — `ScriptWriterZipped`'s own `DeflaterOutputStream`, whose default framing is zlib, never gzip), inflated through `fflate`'s `unzlibSync` and then parsed by the same reader; `classifyScriptBytes` detects the real zlib header rather than gzip's, which a real HSQLDB-produced COMPRESSED file never carries. Verified against two real databases generated by the bundled HSQLDB 1.8.0.10 jar itself — the same content written at `script_format=1` and `=3`, each re-opened by that same engine and dumped back through JDBC as the ground-truth oracle, both oracles byte-identical to each other and to what this reader decodes. An external-only connection (no embedded engine at all — MySQL/PostgreSQL/JDBC/ODBC) is the one *permanent* scope boundary, not a missing tier: `readOdbTables` throws `OdbNoEmbeddedDataSourceError` rather than attempting anything network-facing.
|
|
434
470
|
- **The CACHED-table row-store decoder (Tier 2, `src/hsqldb/cache.ts`/`rowformat.ts`) is scoped to the specific HSQLDB 1.8.x-branch on-disk layout LibreOffice's embedded driver actually ships, not "any HSQLDB version ever" — the same bounding principle the PDF codec applies to "mainstream producer output" rather than every PDF ever created.** There is no ISO/ratified specification for this binary format at all (unlike ODF or OOXML); ground truth is the actual HSQLDB 1.8.0.10 engine source, decompiled from the real `hsqldb.jar` LibreOffice 26.2 bundles (`Specification-Version: 1.8.0.10` in that jar's own `META-INF/MANIFEST.MF` — the exact engine version LibreOffice's embedded HSQLDB JDBC driver loads), cross-checked against a real database that exact jar produced: created, populated, and checkpointed via `java.sql` directly against the bundled jar, then read back — as this decoder's own ground-truth oracle — by a second, independent Java program using the identical jar. Every field of every row of all four CACHED tables in the checked-in fixture (`src/test-support/odb.ts`'s `embeddedHsqldbCachedOdbBytes`) matched that oracle exactly; `parseHsqldbProperties` throws for a `hsqldb.compatible_version` outside the `1.7.x`/`1.8.x` family rather than guessing at an unverified layout. A genuine attempt was also made to cross-check the same fixture against actual LibreOffice itself via a headless UNO Basic macro driving its own SDBC API — this task's own strictest verification bar — but headless `soffice` macro invocation hung indefinitely in this sandbox regardless of profile isolation, macro-security configuration, or a five-minute timeout budget, independently corroborated by a concurrent, unrelated agent's own headless-LibreOffice attempt stalling identically in the same session; the JDBC oracle above is a materially stronger substitute than a fallback of convenience, though, since LibreOffice's own SDBC-to-HSQLDB path is itself a thin wrapper around calling this exact same bundled jar's own JDBC driver methods.
|
|
435
|
-
- **
|
|
436
|
-
- **DATE/TIME/TIMESTAMP columns decoded from a CACHED table's binary row store
|
|
471
|
+
- **A CACHED table's own index count comes from its `SET TABLE ... INDEX'...'` line's own token count, which is what makes a multi-index table decodable at all.** A row's on-disk record carries one 16-byte `org.hsqldb.DiskNode` per table index ahead of its column data (`CachedRow.getRealSize()`: `getIndexCount() * 16 + rowOutput.getSize(row)`), so the column data's byte offset depends entirely on that count. The count is recorded, positionally, in the index-roots line itself: `Table.setIndexRoots(String)` — the engine's own reader for that exact line — reads precisely `getIndexCount()` integers and then one trailing identity-sequence bigint, so `tokens.length - 1` is the index count, and the first token is always index 0's root (the primary key, or HSQLDB's own internal row-position index for a table with none declared). Traversing index 0's tree suffices whatever the count, since every index's tree spans the identical live row set. An earlier revision rejected any multi-index table outright, on the premise that the count could only come from counting `CREATE INDEX` statements in the DDL — where a `UNIQUE` constraint's own auto-generated index genuinely is invisible; that premise was wrong about where the count is recorded. Verified against a real HSQLDB 1.8.0.10 fixture generated and read back by the bundled jar itself: a three-index table (`PRIMARY KEY` + `UNIQUE(CODE)` + an explicit `CREATE INDEX` → `INDEX'136 32 240 0'`), a two-index table with no primary key at all (`INDEX'664 664 0'`), and an ordinary single-index one (`INDEX'528 0'`), every row of each matching the JDBC oracle field-for-field. The row-store's own AVL tree is walked purely by following each row's persisted child *positions*, never by comparing key values, so this decoder never needed HSQLDB's own free-block list at all: a deleted row is unlinked from its table's tree before its space is ever added to that list, so a traversal rooted at the tree's current root only ever reaches genuinely live rows.
|
|
472
|
+
- **DATE/TIME/TIMESTAMP columns decoded from a CACHED table's binary row store need to know which timezone the database was written in, and the file does not record it — so it is a caller option (`timeZone`), defaulting to the reading process's own local zone.** `org.hsqldb.HsqlDateTime` resolves every date/time value through a `java.util.Calendar` carrying no explicit `TimeZone` (i.e. the writing JVM's own default), and the row store's own encoding is a bare epoch-millisecond `long` with no timezone or offset recorded anywhere alongside it — confirmed empirically: the checked-in fixture's own `DATE` values straddle both GMT and BST, and decoding via UTC (rather than local-timezone) `Date` methods recovers the *wrong calendar day* for every summer date. `readOdbTables`, `odbToXlsx`, `odbToCsv`, `decodeHsqldbCachedTables`, `readHsqldbCachedTableRows`, and `readHsqldbColumnValue` therefore all accept `{ timeZone }` (an IANA name, e.g. `'Europe/London'`), resolving the instant's calendar fields through `Intl.DateTimeFormat` in that zone; omitting it keeps the original local-timezone behaviour exactly — correct whenever a `.odb` is read on the same machine/region that created it, the overwhelmingly common case, and the only sensible default given the file itself is silent on the question. It affects Tier 2 and Tier 4 only: Tier 1's TEXT script carries date/time values as already-formatted literal text, and Tier 3's Firebird backup carries a genuine timezone-free day count, so neither has an epoch instant to reinterpret. `src/hsqldb/cache.test.ts` pins `process.env.TZ` to `'Europe/London'` to exercise the default path against the fixture's own real generation environment, and separately reads the identical bytes back under an explicit `'America/New_York'`/`'UTC'` override to prove the option genuinely shifts the recovered calendar day.
|
|
437
473
|
- **A BIGINT value decoded from a CACHED table loses precision beyond `Number.MAX_SAFE_INTEGER`, the same class of limitation every `'number'`-kind `ContentCellValue` in this package already has (DECIMAL/NUMERIC included).** `ContentCellValue` has no arbitrary-precision integer kind to offer instead; `readHsqldbColumnValue` converts a decoded BIGINT through a `bigint` and only casts to a JS `number` at the very end via `Number()`, matching how `src/hsqldb/script.ts`'s own Tier 1 numeric-literal parsing already stores every SQL numeric value as a plain JS number. A real, documented format-boundary gap, not a silent guess: the checked-in fixture's own BIGINT test values are deliberately kept within the safe range so the test suite demonstrates clean, exact round-tripping rather than exercising this already-understood, orthogonal ceiling.
|
|
438
474
|
- **`.odb` Tier 3 (Firebird) is the single subsystem in this whole package with no ratified spec foundation at all — not ISO 32000-1 (PDF), not the OASIS ODF 1.3 RelaxNG schema, nothing.** Firebird's own on-disk page format (ODS) has no public specification; the only ground truth is Firebird's own open-source engine implementation (the [firebirdsql/firebird](https://github.com/FirebirdSQL/firebird) repository) and real fixtures generated and cross-verified by hand. Building this reader surfaced a genuine, load-bearing correction to the design plan it was built against, discovered only by extracting and hex-inspecting a real LibreOffice-generated fixture: **a Firebird-embedded `.odb`'s own `database/firebird.fbk` part is a gbak logical BACKUP stream, not a raw ODS page dump.** LibreOffice's embedded-Firebird SDBC driver backs up the live database (via the identical mechanism the standalone `gbak` command-line tool uses) into the `.odb` package on save, and restores it into a throwaway temp `.fdb` file only when a document is actually opened for live editing — confirmed directly from the backup stream's own embedded temp-file path attribute (`att_backup_file`), which names a `.../lu*.tmp/firebird.fdb` path under LibreOffice's own temp directory, never the `.odb`'s own location. This means the page-level reader (header page, Page Inventory Page, Pointer Page → Data Page chains, RLE-style record compression, MVCC back-pointer chains) the original design plan called for has **no real file to ever operate on** — no `.odb` this reader was tested against, or could plausibly be tested against, ever contains one. `src/firebird/` is consequently a gbak-backup-format reader instead, built against the exact same "no ratified spec, read the engine's own source, verify against real fixtures" discipline, just aimed at a different (and, as it turns out, more tractable) real artifact: `src/burp/burp.h`/`backup.epp`/`restore.epp`/`canonical.cpp`/`mvol.cpp` and `src/common/xdr.cpp`/`src/common/classes/NoThrowTimeStamp.cpp` in the Firebird engine repository, cross-checked line-for-line against real fixture bytes throughout construction (several real off-by-one attribute-index errors and one real high/low-word ordering bug in the initial pass were caught exactly this way, not by inspection alone). One genuine, welcome simplification falls out of this finding for free: because gbak's own backup process ALREADY resolves table/column definitions from the live engine's `RDB$RELATIONS`/`RDB$RELATION_FIELDS`/`RDB$FIELDS` system tables before writing anything, `src/firebird/schema.ts` never bootstraps those system tables itself — a real `rec_relation`/`rec_field` record pair, already fully resolved, is simply *there* in the stream for every user table.
|
|
439
475
|
- **The exact Firebird gbak backup format version this reader targets, and how that was determined: format version 10, per a real fixture's own `att_backup_format` attribute — burp.h's own version-history comment identifies format 10 as "FB2.5 → FB3.0" output.** `readFirebirdBackup` checks this explicitly and throws `FirebirdBackupFormatError` naming the actual version found for anything else, rather than guessing at a different version's own attribute/record shape. Two real, LibreOffice 26.2-generated fixtures (`src/test-support/firebird.ts`) both report this same format version and both set `att_backup_compress=true` (gbak's own default, not something either fixture-generation session opted into) — a genuine surprise this reader's own construction caught only by testing against real bytes: the naive assumption that a `.odb`'s own embedded backup would be uncompressed was wrong on the very first real file tested, and `src/firebird/reader.ts`'s `readCompressedPayload` (a signed-run-length/"PackBits"-style codec, restated from `backup.epp`'s own `compress`/`restore.epp`'s own `decompress`) exists specifically because of that correction.
|
|
440
|
-
- **
|
|
476
|
+
- **Three genuine real fixtures back this reader's own tests, each generated via a headless LibreOffice 26.2 UNO automation session and never hand-edited afterward** (`src/test-support/firebird.ts` documents each in full): a rich one (two tables, varied column types — `INTEGER`/`VARCHAR`/`DOUBLE PRECISION`/`DATE`/`BOOLEAN`/`DECIMAL`/`NUMERIC` — four and three rows respectively, including deliberate `NULL`s in every nullable column, an apostrophe-escaped string, and a zero value distinct from `NULL`), a blob-bearing one (see the BLOB entry below), and the `ExaDev/odf.js` repository's own pre-existing fixture (two empty tables, no row data, a second independently-generated real data point proving the schema-only path). The richer fixture's own construction surfaced a genuine UNO API ordering requirement, not obvious from the API surface alone: `getConnection()` on a freshly `createInstance()`'d `DatabaseContext` entry fails with `SQLException: No storage or URL was given` unless `.DatabaseDocument.storeAsURL()` is called FIRST to give the embedded engine real backing storage to connect to — setting `.URL` alone is not enough.
|
|
441
477
|
- **Cross-verified field-by-field against real LibreOffice itself, not merely against this reader's own output.** A second headless UNO macro (`VerifyFirebirdFixture` — see `src/test-support/firebird.ts`'s own doc comment) reopens the saved fixture completely fresh from disk (a genuine new document load, not the same in-memory session that created it), reconnects via `getConnection`, and runs a real `SELECT * FROM <table> ORDER BY <pk>` through LibreOffice's own SDBC API — every value it returned matched this reader's own decoded output exactly, row for row, field for field, across both tables.
|
|
478
|
+
- **BLOB column content is genuinely decoded, and pinning down the record's own framing corrected two real mistakes in this reader's first implementation — one of which made a blob-bearing `.odb` fail outright rather than merely lose its blob.** `backup.epp`'s own `put_blob` writes `rec_blob att_blob_field_number <int32> att_blob_max_segment <int32> att_blob_number_segments <int32> att_blob_type <int32> att_blob_data (<2-byte little-endian segment length> <that many raw bytes>)*` and then simply returns: there is **no `att_end` terminator**, and `restore.epp`'s own reader correspondingly loops only `while (get_attribute(&attribute, tdgbl) != att_blob_data)`. Reading on in search of an `att_end` consumed the next record's own tag and desynchronised the whole stream. Second, a NULL blob writes **no `rec_blob` record at all** ("If the blob is null, don't store it. It will be restored as null." — `put_blob`'s own comment), so a field with no record is genuinely null rather than missing data. A blob is matched to its column by `att_blob_field_number` against the field's own `att_field_number` — exactly what the engine itself does (`field->fld_number == field_number`), deliberately not by positional index, which coincides in some real files and not others. One further asymmetry is real rather than an omission: a blob's segments are **always raw, never RLE-compressed, even in a compressed backup**, because `backup.epp` calls its own `compress()` at exactly one site (`put_data`'s row payload) while `put_blob` writes every segment through plain `put_block`. A `ContentCellValue` has no binary kind at all, so a TEXT blob (`att_field_sub_type` 1) arrives as an ordinary UTF-8 string and a BINARY blob as a base64 `data:` URI — self-describing and losslessly decodable, and distinguishable from a string the column could genuinely have held. That is a real, tracked **schema** gap belonging in `document-schema.js` (a variant able to say "these are bytes"), not a decoding limit: the bytes are fully recovered either way. Verified against a third real fixture built for exactly this (`BLOB_FIXTURE_FBK_BASE64`): a text blob, a 256-byte binary blob holding every byte value `0x00`..`0xFF` in order (so no byte range can be mangled unnoticed), NULL blobs in both columns, and a second blob-free table *after* the blob-bearing one to prove the stream stays aligned across it — cross-checked field-by-field against real LibreOffice's own SDBC `SELECT *` (`getString` for the text blob, `getBytes` for the binary one) in a separate process that reopened the saved file fresh from disk.
|
|
479
|
+
- **FB4+-only types (`INT128`/`DECFLOAT`) are not a deferred decoding task but an environmental hard stop: LibreOffice cannot produce a `.odb` containing one, so there is no real fixture to verify a decoder against, and this package does not guess a wire format from documentation alone.** LibreOffice 26.2.5.2 bundles `libfbclient.dylib.3.0.7` and `security3.fdb`, and its embedded engine reports itself as `Firebird (engine12) / 3.0.7` through SDBC's own `getDatabaseProductVersion`. Confirmed empirically rather than inferred from those version numbers, by attempting each type's own DDL against that engine through the same headless UNO route the fixtures are generated with: `INT128` fails with `SQL error code = -607 ... Specified domain or source column INT128 does not exist`; `DECFLOAT(16)` and `DECFLOAT(34)` fail with `-104 Token unknown`; `TIMESTAMP WITH TIME ZONE` fails with `-104 Token unknown - WITH` (the same reason this reader's `unsupported-tz` physical type is unreachable in practice); `NUMERIC(38,2)` fails with `-842 Precision must be from 1 to 18`, which is precisely the ceiling above which Firebird would need `INT128` storage; and `NUMERIC(18,2)` — the last width FB3 supports — succeeds. `decodeRowValues` accordingly throws a named error for these physical types rather than decoding them speculatively. A Firebird 4/5 server could be run separately to produce such a backup, but the result would be a standalone `.fbk` at a different `att_backup_format` version than the 10 this reader pins, and no `.odb` could ever contain it — widening the reader for a file its only caller cannot encounter would be speculation, not root-cause work.
|
|
442
480
|
- **Headless LibreOffice command-line macro dispatch (`soffice {file} {macro:///Library.Module.Name}`) needed two real, non-obvious environment fixes to run at all in this sandbox, beyond the ones already documented for HSQLDB/odm fixture generation.** (1) A prior session's forcefully-killed `soffice` process leaves macOS's own native "reopen windows after a crash" alert showing on every subsequent launch — invisible in headless/`--invisible` mode (no window to click), so `soffice` hangs indefinitely in `-[NSAlert runModal]` waiting for a response that can never arrive; `defaults write org.libreoffice.script ApplePersistenceIgnoreState -bool true` (plus removing `~/Library/Saved Application State/org.libreoffice.script.savedState`) disables it. (2) `soffice "macro:///Library.Module.Name"` with no document argument silently does nothing at all — per `soffice --help`'s own usage text, the `{file}` argument is not optional (`{file} {macro:///Library.Module.MacroName}`); a session invoking a macro with no real work to do on a document still needs a real (even trivial) file argument for the macro to actually dispatch.
|
|
443
481
|
- **A Firebird gbak backup stream's own wire format mixes two genuinely different byte-level encodings, confirmed only by testing against real bytes, not solely from reading the engine's source.** Every `rec_*`/`att_*` tag-and-attribute structure is little-endian ("VAX order", `isc_vax_integer`), one length-prefix byte per value; a row's own field-value sequence (once any RLE compression is peeled off) is standard RFC 1832 XDR — big-endian, every value (even a nominally 16-bit `SSHORT`) widened to a 4-byte-aligned unit, opaque byte runs zero-padded to the next 4-byte boundary. A genuine 64-bit-integer word-order bug (high 32 bits transmitted first, not low-first as `xdr_hyper`'s own in-memory `temp_long` array layout suggests on first reading) was caught exactly this way: a `DECIMAL(10,2)` column decoded to a nonsense value on the first real-fixture test run, not from a source-reading mistake that was obvious in advance.
|
|
444
482
|
- **STIX Two Math is embedded as a whole, unmodified `CFF ` table rather than glyph-subsetted, and its `MathVariants` (stretchy glyph assembly) subtable is deliberately not parsed at all — both pdf-codec's own font-embedding scope decisions, not this package's.** The practical consequence for this package's own MathML rendering: a stretchy fence or a stretchy `<mo>` wrapping a tall construct (a large fraction, a tall matrix) always renders at its own base glyph's fixed size, not dynamically resized to its content's own height. See pdf-codec's own README for the full CFF-embedding and `MathVariants` scope statement.
|
|
@@ -446,8 +484,10 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
446
484
|
- **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.
|
|
447
485
|
- **`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.
|
|
448
486
|
- **Greek `mathvariant` mapping covers the plain alphabet, nabla (∇), partial differential (∂), and the six OpenType/Unicode Greek "symbol variant" glyphs** (lunate epsilon/theta/kappa/phi/rho/pi symbols — U+03F5/U+03D1/U+03F0/U+03D5/U+03F1/U+03D6 — styled to bold, italic, bold-italic, bold-sans-serif, and sans-serif-bold-italic; Unicode never assigned symbol-variant glyphs for plain sans-serif, script, fraktur, or double-struck). Every entry is generated directly from Unicode's own `UnicodeData.txt` (see `src/mathml/variant.ts`'s own generation note) rather than transcribed by hand.
|
|
449
|
-
- **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
|
|
450
|
-
- **A formula
|
|
487
|
+
- **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 embedded-formula detection is still not implemented, and the blocker is upstream, re-verified against the installed `odf.js` 2.0.0 rather than assumed**: its own `readSheet` returns `{ name, cells, columns, rows, images: [], printSettings }` — `images` hardcoded empty, `embeddedObjects` never set — and `readOds` walks only the `table:table` children of `office:spreadsheet`, so a floating `draw:frame` on a sheet is never visited at all. There is consequently no anchor-resolution result for a detection pass to derive a position from, which is precisely what odt's block counting and odp's shape indices give their own passes; `src/layout/sheets.ts` accordingly has no formula-handling branch, with a comment stating exactly this.
|
|
488
|
+
- **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. Still genuinely stand-in-only: `buildOdtPackage`, which would need to write a real embedded formula sub-package (a nested `Object N/content.xml` plus its own `draw:frame`/`draw:object` reference and manifest entries — a real feature, not a small extension of a block writer), and the markdown writer, since CommonMark/GFM has no math construct at all. **`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.
|
|
489
|
+
- **OMML is written but not read: `readDocxContent` does not recover an equation from a docx, so a formula this package writes into one does not come back out of it.** `ooxml.js`'s own `readDocx` has no `m:oMath` handling at all — a `w:p` whose only content is an equation reads as a paragraph with zero runs — and `readDocxContent` is a thin adapter over it, so a docx equation (whether written by this package or authored in Word) becomes an empty paragraph rather than a `ContentEmbeddedObjectBlock`. Two concrete consequences, both real and tracked: `docxToPdf` renders nothing where a Word-authored equation sits (a pre-existing gap this package never claimed to cover, now worth naming explicitly since the write direction exists), and the `odt → docx → odt` chain drops the formula entirely rather than carrying the `[formula]` text stand-in it used to — the docx in the middle genuinely holds real, Word-renderable math, but nothing reads it back. Closing this means a reverse OMML → MathML translator plus a second pass over `word/document.xml` that maps each equation onto the paragraph block `readDocx` produced for its own `w:p` (exact, since `readDocx` emits exactly one `kind: 'paragraph'` block per `w:p` in document order — image/table/page-break blocks are distinguishable by kind), and a synthesized `ContentEmbeddedObjectBlock.frame`, since OMML carries no geometry for the layout engine's own `frameHeightPt / 2` size estimate to read. A genuinely separate feature of comparable size to the writer, not a small extension of it.
|
|
490
|
+
- **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 renders at its own fixed size rather than becoming an auto-growing `m:d` delimiter (matching, not diverging from, the PDF path, where `MathVariants` is likewise not parsed — see the STIX embedding gotcha above), `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.
|
|
451
491
|
- **`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.
|
|
452
492
|
- **`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.
|
|
453
493
|
- **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:**
|
|
@@ -476,21 +516,21 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
476
516
|
|
|
477
517
|
**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.
|
|
478
518
|
|
|
479
|
-
**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.
|
|
519
|
+
**PDF → docx/pptx/odt/odp** is necessarily a **best-effort reconstruction** from geometry: a PDF page is just positioned glyphs and images, with no semantic paragraph or shape structure to recover. Reading order, bold/italic/colour/font-size, and page/slide count are preserved; paragraph and text-block boundaries are inferred from baseline spacing and left-margin indentation, not recovered exactly. Two further kinds of content are recovered on top of that text, each on its own explicit terms: a real `ContentTable`, but **only** where a genuine drawn gridline lattice is detected, never from text alignment (which would be inventing structure, not recovering it); and a page's vector primitives, into a nested drawing document that currently reaches the `ContentDocument` pivot but not the output bytes. Both are covered in full by their own [Gotchas](#gotchas-and-quirks) entries.
|
|
480
520
|
|
|
481
521
|
**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.
|
|
482
522
|
|
|
483
|
-
**PDF → ods (`pdfToOds`, `reconstructSpreadsheet`) recovers what was printed, not what was entered.** This is a harder, categorically different limit than any other reconstruction direction above, not merely a looser version of the same one: docx/pptx/odp/odg reconstruction can at least recover real text, formatting, and (for odg) exact vector geometry from a PDF's own positioned glyphs and paths. A spreadsheet cell's own *value* — a formula, a real typed number, a date serial, a currency code — never exists anywhere in a rendered PDF at all; a PDF only ever carries the **rendered string** Calc or Excel chose to print for that cell. `reconstructSpreadsheet`
|
|
523
|
+
**PDF → ods (`pdfToOds`, `reconstructSpreadsheet`) recovers what was printed, not what was entered.** This is a harder, categorically different limit than any other reconstruction direction above, not merely a looser version of the same one: docx/pptx/odp/odg reconstruction can at least recover real text, formatting, and (for odg) exact vector geometry from a PDF's own positioned glyphs and paths. A spreadsheet cell's own *value* — a formula, a real typed number, a date serial, a currency code — never exists anywhere in a rendered PDF at all; a PDF only ever carries the **rendered string** Calc or Excel chose to print for that cell. `reconstructSpreadsheet` handles that in two layers. The printed string is always preserved exactly, in the required `displayText` field, whatever else happens. On top of that, a **heuristic, explicitly probabilistic** re-typing step (`src/layout/cell-typing.ts`) sets a typed `value` — number, percentage, currency, date, or boolean — wherever the rendered string has exactly one defensible reading, and deliberately leaves it a string wherever it does not, reporting both outcomes through `ReconstructOptions.onCellTypeInference`. **This is inference, not recovery: a re-typed value is a best-effort guess about what the source cell held, and a numeric-looking string may genuinely have been a string.** The confidence bar is stated in full in the `pdfToOds` re-typing gotcha above (lossless representation, unambiguous separators, no leading zeros, role-unambiguous dates) — `"42.5"` and `"2024-01-15"` are re-typed, `"1,234"`, `"007"`, `"01/02/2024"` and `"Yes"` are all declined and reported. A formula is still never claimed, since nothing about a rendered value implies one was computed. See the same gotcha for the detection algorithm that finds the cells in the first place (a real gridline lattice used directly as cell boundaries when a printed sheet had gridlines enabled, text-position clustering otherwise). Column widths, row heights, and page size are genuinely measured from whichever geometry was used; no print range, scale, fit-to-page, repeat-rows/columns, or manual breaks are ever inferred, since a rendered page carries no trace of print *intent*, only what was visually printed. Verified against real LibreOffice 26.2, not merely against this package's own reader: a genuine, gridline-and-headers-enabled four-column, four-row employee-record `.ods` (mixed string/date/boolean-looking cell content) round-tripped through `odsToPdf` then `pdfToOds` opens as a valid spreadsheet with every cell's text recovered in its correct row/column position, via the gridline-lattice path specifically (confirmed by inspecting the recovered `printSettings.gridlines`), and every recovered cell carrying its own printed text verbatim.
|
|
484
524
|
|
|
485
525
|
**`markdownToPdf`/`pdfToMarkdown` is the single lossiest round trip in the whole package, and deliberately so.** `markdownToPdf` itself is a genuine, faithful layout render — `readMarkdownContent` produces the identical `WordprocessingContentDocument` shape `readDocxContent`/`readOdtContent` do, so it feeds `convertWordprocessingToLayout` completely unmodified, the same engine every other wordprocessing conversion in this package shares — but `pdfToMarkdown` stacks TWO independent layers of lossiness on the way back, not one. The first layer is the same one every PDF → docx/pptx/odt/odp direction already carries: `reconstructWordprocessing` recovers paragraph and text-block structure from nothing but positioned glyphs, a best-effort geometric approximation, never an exact recovery (see above). The second layer is new, and unique to markdown: `buildMarkdownText` then has to fit whatever `reconstructWordprocessing` recovered into CommonMark/GFM's own, much coarser vocabulary — no colour, no font family, no font size, no explicit paragraph alignment, no page geometry at all. A round-tripped bold run survives as real `**bold**` markdown syntax; a round-tripped coloured, specifically-sized run does not survive as anything — there is no markdown construct for either to become. This is a categorically worse case than `pdfToOds`'s own "recovers what was printed, not what was entered" limit: `pdfToOds` at least recovers a bare string faithfully into a real spreadsheet cell; `pdfToMarkdown` recovers a bare string too, but into a format that then discards most of whatever formatting the reconstruction step itself already approximated.
|
|
486
526
|
|
|
487
527
|
Neither direction is round-trip-lossless, and no conversion is the exact inverse of its own reverse direction — `pdfToDocx(docxToPdf(x))` will not reproduce `x` exactly, and neither will `pdfToOdg(odgToPdf(x))` or `pdfToOds(odsToPdf(x))`; neither is intended to. This is a deliberate, permanent contrast with `ooxml.js`'s own `packageCodec`, which genuinely is a lossless round trip. `docxPdfCodec`/`pptxPdfCodec`/`odtPdfCodec`/`odpPdfCodec`/`odsPdfCodec`/`odgPdfCodec`/`xlsxPdfCodec`/`markdownPdfCodec`/`pdfCodec` share `packageCodec`'s *mechanism* (`z.codec()`, schema-validated both ways) but not its *guarantee* — wrapping a lossy conversion in `z.codec()` validates the shape of what comes out, not its fidelity to what went in.
|
|
488
528
|
|
|
489
|
-
**The first three cross-format bridge pairs (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`) are a categorically different case from every conversion above: they bypass the PDF pivot entirely, so the "not round-trip-lossless" caveat that applies to every PDF-pivot conversion in this section does not carry over to them.** There is no layout engine (no flow, no line-wrapping, no pagination) and no geometry-based reconstruction (no baseline clustering, no gridline-lattice detection) anywhere in a bridge's own call path — each is nothing more than `buildYPackage(readXContent(decodePackage(bytes)))`, composing the identical reader/builder pair the PDF-pivot conversions on either side of the bridge already use, because both formats in each pair read into and build from the exact same `ContentDocument` variant. Concretely, for `odt ⇄ docx` and `odp ⇄ pptx`: text, run styling (bold/italic/underline/colour/font/size), paragraph `styleId`, list membership and nesting level, table structure and cell content, and (for `odp ⇄ pptx`) speaker notes all survive completely — proven by `src/convert/bridges.test.ts`'s own dedicated round-trip suite, exercised in both directions from both starting formats, and cross-checked by opening genuinely LibreOffice-produced source files and their bridged output in real LibreOffice (see that test file and this repo's own verification notes).
|
|
529
|
+
**The first three cross-format bridge pairs (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`) are a categorically different case from every conversion above: they bypass the PDF pivot entirely, so the "not round-trip-lossless" caveat that applies to every PDF-pivot conversion in this section does not carry over to them.** There is no layout engine (no flow, no line-wrapping, no pagination) and no geometry-based reconstruction (no baseline clustering, no gridline-lattice detection) anywhere in a bridge's own call path — each is nothing more than `buildYPackage(readXContent(decodePackage(bytes)))`, composing the identical reader/builder pair the PDF-pivot conversions on either side of the bridge already use, because both formats in each pair read into and build from the exact same `ContentDocument` variant. Concretely, for `odt ⇄ docx` and `odp ⇄ pptx`: text, run styling (bold/italic/underline/colour/font/size), paragraph `styleId`, list membership and nesting level, table structure and cell content (merged cells included), a rotated shape, and (for `odp ⇄ pptx`) speaker notes and a table shape (a slide shape whose own content IS a table, not a text box) all survive completely — proven by `src/convert/bridges.test.ts`'s own dedicated round-trip suite, exercised in both directions from both starting formats, and cross-checked by opening genuinely LibreOffice-produced source files and their bridged output in real LibreOffice (see that test file and this repo's own verification notes). `ods ⇄ xlsx` preserves cell values, semantic kinds (percentage/currency/date/boolean, since `ooxml.js` 2.6.1's number-format engine), formulas (verbatim), merged ranges, and column widths (both hops, within tolerance) completely — but still carries a small number of real, honestly-documented format-boundary limits of its own (a `time` cell has no xlsx serial to write and downgrades to a plain string, and a formula written in one dialect can show as a genuine formula error in a REAL spreadsheet application expecting the other) — see the `ods ⇄ xlsx` gotcha above for the full, specific list. None of this is layout drift or reconstruction guesswork; every gap listed is a genuine format-boundary limit (a cell type or value kind with no counterpart on the other side), not an approximation introduced by the bridge itself. An embedded formula now survives `odtToDocx` as **real, editable OOXML math** rather than as plain-text: `buildDocxPackage` translates the block's own MathML into genuine OMML (`src/omml/write.ts`), covering the identical construct set the PDF path typesets — only a construct OMML itself has no counterpart for degrades, individually and with a diagnostic. The reverse hop, `docxToOdt`, is still stand-in-only, for the separate reason its own gotcha states: `buildOdtPackage` would need to write a whole embedded formula sub-package, not merely a different vocabulary.
|
|
490
530
|
|
|
491
531
|
**The two markdown cross-format bridge pairs (`markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown`) bypass the PDF pivot entirely too, exactly like the three pairs above — but "no PDF-pivot lossiness" is not the same claim as "no lossiness at all", and conflating the two here would misdescribe what these specifically preserve.** There is genuinely no layout engine and no geometry-based reconstruction anywhere in either bridge's own call path (proven the same way the three pairs above are, by `src/convert/bridges.test.ts`'s own spy-based "the layout engine was never called" assertions) — `markdownToDocx`/`markdownToOdt` carry a heading's `Heading1`-style `styleId`, a bold/italic run, list membership and nesting level, and GFM table structure through to a real docx/odt `ContentDocument` with zero approximation, and `docxToMarkdown`/`odtToMarkdown` carry the reverse just as faithfully for whatever markdown itself can represent. The asymmetry is upstream of the bridge mechanism, in what CommonMark/GFM's own grammar has room for at all: a docx/odt run's colour, explicit font family/size, and paragraph alignment have no markdown source construct to survive as, so `docxToMarkdown`/`odtToMarkdown` drop them — not because the bridge approximates anything, but because there is nothing to carry them in. Going the other way, `markdownToDocx`/`markdownToOdt` never invent formatting markdown never expressed, so nothing is lost on that hop that wasn't already absent from the source. This is real, permanent, format-boundary lossiness, on exactly one side of the pair — a different shape from `ods ⇄ xlsx`'s own several small, independent format-boundary gaps (percentage/currency, time/date, formula dialect), but a real loss all the same, not the "categorically different, no round-trip-lossless caveat at all" case the three original bridge pairs are.
|
|
492
532
|
|
|
493
|
-
**`.odb` table extraction (`readOdbTables`, all
|
|
533
|
+
**`.odb` table extraction (`readOdbTables`, all four tiers) is a genuine, verified data extraction, not an approximation — but it recovers only what a `.odb`'s own embedded database storage actually carries, which differs by tier.** Tier 1 (HSQLDB TEXT script) parses real DDL/DML text, so a table's own declared column types survive as the literal SQL clause they were declared with, and row values are the literal `INSERT` statement literals. Tier 4 (HSQLDB whole-script BINARY/COMPRESSED) is Tier 1's own equal in fidelity, not a degraded variant of it: the DDL it recovers is the identical statement text a TEXT-format script would have carried, and the row values it decodes come from the same per-column binary encoding Tier 2 reads, verified against the engine's own JDBC read-back of both real fixtures. Tier 2 (HSQLDB CACHED-table binary row store) shares Tier 1's own DDL-derived column types — a CACHED table's DDL still lives in `database/script` as ordinary TEXT — but decodes its actual row *values* from a separate binary page-cache file, `database/data`, cross-verified field-by-field against a real HSQLDB JDBC oracle on the identical fixture (see the Gotchas entry above). Tier 3 (Firebird) decodes a real gbak backup stream — every cell value, `NULL`, and column name is genuinely read from the file, cross-verified field-by-field against real LibreOffice's own SDBC query on the identical fixture (see the Gotchas entry above for the full verification transcript) — but a column's own `HsqldbColumn.type` label is *synthesised* from the field's binary metadata (BLR type + length + scale), not lifted from source SQL text the way Tier 1/2's is, since a gbak backup carries no DDL text at all. No tier recovers a database's own forms, reports, or queries (names only, never content — see the gotcha above), and none has a reverse (xlsx/CSV → `.odb`) direction. BLOB column content is genuinely recovered too, byte-for-byte — see the dedicated Gotchas entry above for the record shape and the base64 `data:` URI a binary blob arrives as, which is a `ContentCellValue` schema gap rather than a decoding one. Tier 3 retains two real, bounded, honestly-scoped gaps, both documented in code comments at the exact spot each applies: no FB4+-only types (`INT128`/`DECFLOAT`, i.e. a `NUMERIC`/`DECIMAL` column wider than 18 digits of precision), which is a hard environmental limit rather than a decoding shortcut — see the Gotchas entry above for the empirical confirmation that LibreOffice's own bundled engine cannot declare such a column at all, so no `.odb` exists to verify a decoder against; and a blob-VALUED metadata *attribute* (a relation/field/index/trigger's own description, default value, or BLR body) uses a different, compound wire encoding this reader's generic attribute-skip does not yet handle — never encountered by any real fixture this reader was verified against, but a real gap on a `.odb` whose tables carry comments or computed columns.
|
|
494
534
|
|
|
495
535
|
**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.
|
|
496
536
|
|