js.documents 1.62.0 → 1.64.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.
Files changed (196) hide show
  1. package/README.md +150 -24
  2. package/dist/convert/convert.cjs +72 -20
  3. package/dist/convert/convert.d.cts +6 -2
  4. package/dist/convert/convert.d.ts +6 -2
  5. package/dist/convert/convert.js +73 -21
  6. package/dist/convert/local.cjs +15 -2
  7. package/dist/convert/local.js +15 -2
  8. package/dist/convert/port.d.cts +8 -4
  9. package/dist/convert/port.d.ts +8 -4
  10. package/dist/edit/docx/content.d.cts +1 -1
  11. package/dist/edit/docx/content.d.ts +1 -1
  12. package/dist/edit/docx/paragraph.d.cts +3 -3
  13. package/dist/edit/docx/paragraph.d.ts +3 -3
  14. package/dist/edit/ods/address.cjs +42 -24
  15. package/dist/edit/ods/address.d.cts +12 -2
  16. package/dist/edit/ods/address.d.ts +12 -2
  17. package/dist/edit/ods/address.js +39 -25
  18. package/dist/edit/ods/column-row.cjs +68 -6
  19. package/dist/edit/ods/column-row.d.cts +5 -1
  20. package/dist/edit/ods/column-row.d.ts +5 -1
  21. package/dist/edit/ods/column-row.js +68 -10
  22. package/dist/edit/ods/content.cjs +10 -2
  23. package/dist/edit/ods/content.js +10 -2
  24. package/dist/edit/ods/floating.cjs +90 -0
  25. package/dist/edit/ods/floating.d.cts +7 -0
  26. package/dist/edit/ods/floating.d.ts +7 -0
  27. package/dist/edit/ods/floating.js +88 -0
  28. package/dist/edit/ods/print-settings.cjs +158 -2
  29. package/dist/edit/ods/print-settings.js +159 -3
  30. package/dist/edit/ods/sheet.cjs +13 -0
  31. package/dist/edit/ods/sheet.d.cts +5 -1
  32. package/dist/edit/ods/sheet.d.ts +5 -1
  33. package/dist/edit/ods/sheet.js +14 -1
  34. package/dist/edit/odt/content.cjs +8 -4
  35. package/dist/edit/odt/content.js +8 -4
  36. package/dist/edit/odt/editor.cjs +7 -0
  37. package/dist/edit/odt/editor.d.cts +3 -0
  38. package/dist/edit/odt/editor.d.ts +3 -0
  39. package/dist/edit/odt/editor.js +7 -0
  40. package/dist/edit/odt/formula.cjs +19 -0
  41. package/dist/edit/odt/formula.d.cts +8 -0
  42. package/dist/edit/odt/formula.d.ts +8 -0
  43. package/dist/edit/odt/formula.js +17 -0
  44. package/dist/evaluate-BU5N1Jk1.d.ts +11 -0
  45. package/dist/evaluate-BhMXY6DM.d.cts +11 -0
  46. package/dist/firebird/backup.d.cts +1 -1
  47. package/dist/firebird/backup.d.ts +1 -1
  48. package/dist/firebird/data.cjs +30 -16
  49. package/dist/firebird/data.d.cts +3 -2
  50. package/dist/firebird/data.d.ts +3 -2
  51. package/dist/firebird/data.js +30 -17
  52. package/dist/fonts/obfuscation.cjs +50 -0
  53. package/dist/fonts/obfuscation.d.cts +9 -0
  54. package/dist/fonts/obfuscation.d.ts +9 -0
  55. package/dist/fonts/obfuscation.js +46 -0
  56. package/dist/fonts/odf.cjs +86 -0
  57. package/dist/fonts/odf.d.cts +9 -0
  58. package/dist/fonts/odf.d.ts +9 -0
  59. package/dist/fonts/odf.js +84 -0
  60. package/dist/fonts/ooxml.cjs +129 -0
  61. package/dist/fonts/ooxml.d.cts +9 -0
  62. package/dist/fonts/ooxml.d.ts +9 -0
  63. package/dist/fonts/ooxml.js +127 -0
  64. package/dist/fonts/registry.cjs +18 -0
  65. package/dist/fonts/registry.d.cts +2 -0
  66. package/dist/fonts/registry.d.ts +2 -0
  67. package/dist/fonts/registry.js +16 -0
  68. package/dist/formula-CgydMiTt.d.ts +10 -0
  69. package/dist/formula-CiwPw8nY.d.cts +10 -0
  70. package/dist/hsqldb/binary-script.d.cts +1 -1
  71. package/dist/hsqldb/binary-script.d.ts +1 -1
  72. package/dist/hsqldb/cache.d.cts +1 -1
  73. package/dist/hsqldb/cache.d.ts +1 -1
  74. package/dist/hsqldb/rowformat.cjs +22 -13
  75. package/dist/hsqldb/rowformat.js +22 -13
  76. package/dist/hsqldb/script.d.cts +1 -18
  77. package/dist/hsqldb/script.d.ts +1 -18
  78. package/dist/index.cjs +88 -0
  79. package/dist/index.d.cts +27 -9
  80. package/dist/index.d.ts +27 -9
  81. package/dist/index.js +20 -3
  82. package/dist/layout/sheets.cjs +30 -24
  83. package/dist/layout/sheets.js +31 -25
  84. package/dist/layout/slides.cjs +6 -4
  85. package/dist/layout/slides.js +7 -5
  86. package/dist/lexer-DGPWx_f2.d.cts +42 -0
  87. package/dist/lexer-DGPWx_f2.d.ts +42 -0
  88. package/dist/mathml/layout.d.cts +1 -1
  89. package/dist/mathml/layout.d.ts +1 -1
  90. package/dist/mathml/variant.d.cts +1 -6
  91. package/dist/mathml/variant.d.ts +1 -6
  92. package/dist/odb/csv.d.cts +1 -1
  93. package/dist/odb/csv.d.ts +1 -1
  94. package/dist/odb/formula/definition.cjs +49 -0
  95. package/dist/odb/formula/definition.d.cts +8 -0
  96. package/dist/odb/formula/definition.d.ts +8 -0
  97. package/dist/odb/formula/definition.js +46 -0
  98. package/dist/odb/formula/errors.cjs +47 -0
  99. package/dist/odb/formula/errors.d.cts +21 -0
  100. package/dist/odb/formula/errors.d.ts +21 -0
  101. package/dist/odb/formula/errors.js +43 -0
  102. package/dist/odb/formula/evaluate.cjs +283 -0
  103. package/dist/odb/formula/evaluate.d.cts +43 -0
  104. package/dist/odb/formula/evaluate.d.ts +43 -0
  105. package/dist/odb/formula/evaluate.js +281 -0
  106. package/dist/odb/formula/parser.cjs +198 -0
  107. package/dist/odb/formula/parser.d.cts +29 -0
  108. package/dist/odb/formula/parser.d.ts +29 -0
  109. package/dist/odb/formula/parser.js +197 -0
  110. package/dist/odb/read.d.cts +1 -1
  111. package/dist/odb/read.d.ts +1 -1
  112. package/dist/odb/report/content.cjs +27 -0
  113. package/dist/odb/report/content.d.cts +14 -0
  114. package/dist/odb/report/content.d.ts +14 -0
  115. package/dist/odb/report/content.js +25 -0
  116. package/dist/odb/report/render.cjs +106 -0
  117. package/dist/odb/report/render.d.cts +7 -0
  118. package/dist/odb/report/render.d.ts +7 -0
  119. package/dist/odb/report/render.js +106 -0
  120. package/dist/odb/report/source.cjs +45 -0
  121. package/dist/odb/report/source.d.cts +14 -0
  122. package/dist/odb/report/source.d.ts +14 -0
  123. package/dist/odb/report/source.js +42 -0
  124. package/dist/odb/spreadsheet.d.cts +1 -1
  125. package/dist/odb/spreadsheet.d.ts +1 -1
  126. package/dist/odb/sql/errors.cjs +38 -0
  127. package/dist/odb/sql/errors.d.cts +17 -0
  128. package/dist/odb/sql/errors.d.ts +17 -0
  129. package/dist/odb/sql/errors.js +35 -0
  130. package/dist/odb/sql/evaluate.cjs +312 -0
  131. package/dist/odb/sql/evaluate.d.cts +2 -0
  132. package/dist/odb/sql/evaluate.d.ts +2 -0
  133. package/dist/odb/sql/evaluate.js +311 -0
  134. package/dist/odb/sql/lexer.cjs +282 -0
  135. package/dist/odb/sql/lexer.d.cts +2 -0
  136. package/dist/odb/sql/lexer.d.ts +2 -0
  137. package/dist/odb/sql/lexer.js +281 -0
  138. package/dist/odb/sql/parser.cjs +479 -0
  139. package/dist/odb/sql/parser.d.cts +2 -0
  140. package/dist/odb/sql/parser.d.ts +2 -0
  141. package/dist/odb/sql/parser.js +478 -0
  142. package/dist/odb/values.cjs +75 -0
  143. package/dist/odb/values.d.cts +2 -0
  144. package/dist/odb/values.d.ts +2 -0
  145. package/dist/odb/values.js +69 -0
  146. package/dist/odf/odt/read.cjs +43 -21
  147. package/dist/odf/odt/read.js +43 -21
  148. package/dist/odf-package/formula.cjs +59 -0
  149. package/dist/odf-package/formula.d.cts +11 -0
  150. package/dist/odf-package/formula.d.ts +11 -0
  151. package/dist/odf-package/formula.js +58 -0
  152. package/dist/odf-package/manifest.cjs +29 -0
  153. package/dist/odf-package/manifest.d.cts +5 -2
  154. package/dist/odf-package/manifest.d.ts +5 -2
  155. package/dist/odf-package/manifest.js +30 -2
  156. package/dist/odf-package/media.cjs +2 -1
  157. package/dist/odf-package/media.js +2 -2
  158. package/dist/omml/read.cjs +265 -0
  159. package/dist/omml/read.d.cts +12 -0
  160. package/dist/omml/read.d.ts +12 -0
  161. package/dist/omml/read.js +263 -0
  162. package/dist/omml/shared.cjs +67 -0
  163. package/dist/omml/shared.d.cts +2 -0
  164. package/dist/omml/shared.d.ts +2 -0
  165. package/dist/omml/shared.js +64 -0
  166. package/dist/omml/write.cjs +3 -51
  167. package/dist/omml/write.d.cts +2 -2
  168. package/dist/omml/write.d.ts +2 -2
  169. package/dist/omml/write.js +1 -49
  170. package/dist/ooxml/docx/extras.cjs +15 -0
  171. package/dist/ooxml/docx/extras.d.cts +12 -0
  172. package/dist/ooxml/docx/extras.d.ts +12 -0
  173. package/dist/ooxml/docx/extras.js +14 -0
  174. package/dist/ooxml/docx/formula.cjs +109 -0
  175. package/dist/ooxml/docx/formula.d.cts +2 -0
  176. package/dist/ooxml/docx/formula.d.ts +2 -0
  177. package/dist/ooxml/docx/formula.js +108 -0
  178. package/dist/ooxml/docx/read.cjs +10 -2
  179. package/dist/ooxml/docx/read.d.cts +6 -2
  180. package/dist/ooxml/docx/read.d.ts +6 -2
  181. package/dist/ooxml/docx/read.js +11 -3
  182. package/dist/parser-B9vC1T6T.d.cts +101 -0
  183. package/dist/parser-DQSgYJzQ.d.ts +101 -0
  184. package/dist/registry-CrSqLIcn.d.ts +19 -0
  185. package/dist/registry-lqJej0Jr.d.cts +19 -0
  186. package/dist/script-DsGGIzGU.d.cts +19 -0
  187. package/dist/script-DsGGIzGU.d.ts +19 -0
  188. package/dist/shared-DLZ3IQUl.d.cts +15 -0
  189. package/dist/shared-DUOzjwcL.d.ts +15 -0
  190. package/dist/values-BetetbaH.d.cts +23 -0
  191. package/dist/values-BetetbaH.d.ts +23 -0
  192. package/dist/variant-BMrebjMw.d.cts +7 -0
  193. package/dist/variant-BMrebjMw.d.ts +7 -0
  194. package/dist/{write-DuyfnbL_.d.cts → write-BQ6SK8r8.d.cts} +2 -6
  195. package/dist/{write-Zqwx6pka.d.ts → write-THs7ipBq.d.ts} +2 -6
  196. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/documents.js) [![Release](https://img.shields.io/github/v/release/ExaDev/documents.js)](https://github.com/ExaDev/documents.js/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)
4
4
 
5
- > 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).
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 Form/Report *structure* reading (bound controls, bands/groups/functions), a bounded single-table SQL `SELECT` engine that runs a `.odb`'s own saved queries over that extracted data, a Report Builder rpt formula engine that evaluates a report's group breaks and footer totals over the result, and a structural report renderer that turns the printed bands into a real `ContentDocument`, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, docx comment/footnote/header-footer/numbering-definition exposure via `readDocxExtras`, real font resolution for ordinary text (a source document's own embedded faces extracted and rendered through, ahead of caller-supplied faces, metric-compatible vendored substitutes, and finally the standard 14), 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
 
@@ -95,6 +95,8 @@ const markdownBytes2 = pdfToMarkdown(pdfFromMarkdown); // the lossiest conversio
95
95
 
96
96
  Each accepts an optional `signal` (`AbortSignal`) and either a `onSubstitution` callback (docx/pptx/odt/odp/ods/odg/xlsx/markdown → PDF, called once per character not representable in a standard-14 font) or a `sink` (PDF → docx/pptx/odt/odp/ods/odg/xlsx/markdown, called once per recoverable parse diagnostic).
97
97
 
98
+ Every X → PDF conversion additionally accepts `fonts` (extra `ProvidedFont` faces to make available) and `onFontSubstitution` (called once per requested family+weight+style that resolved to something else). Neither is needed for the common case: the conversion already extracts the **source document's own embedded fonts** and renders through them, so a docx or odt saved with font embedding turned on comes out in its real typeface at its real metrics with no caller involvement at all — see [Fonts](#fonts) below for the full resolution order.
99
+
98
100
  Ten further conversions, five pairs, bypass PDF entirely: `odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, and `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown` each compose a direct `readXContent` → `buildYPackage` pivot copy, since both sides of each pair already read into and build from the identical `ContentDocument` variant — no layout engine, no font measurement, and no geometry-based reconstruction in between. See [Fidelity](#fidelity) for what that means in practice, and for markdown specifically, why "no layout/reconstruction lossiness" is not the same claim as "no lossiness at all".
99
101
 
100
102
  ```ts
@@ -107,7 +109,7 @@ const docxFromMarkdown = markdownToDocx(markdownBytes);
107
109
  const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct -- dropped on this hop, not merely approximated
108
110
  ```
109
111
 
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.
112
+ 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`/`docxToOdt`/`docxToMarkdown` additionally take `onMathDiagnostic`, called once per formula construct that degraded or was approximated as an embedded formula crossed the bridge — into OOXML math when building a docx, back out of it when reading one (see [Architecture](#architecture)'s `src/omml/` entry). Either way it reports only what the target vocabulary genuinely has no counterpart for, never the whole formula. `docxToPdf` takes it too, for the read direction.
111
113
 
112
114
  The same conversions behind a swappable port, for a caller that wants to inject a different implementation later without changing call sites:
113
115
 
@@ -175,9 +177,20 @@ const fresh = createDocx();
175
177
  fresh.body.appendParagraph().appendRun({ text: 'New document' });
176
178
  ```
177
179
 
180
+ A docx's own comments, footnotes, headers/footers, and numbering (`w:abstractNum`/`w:num`) definitions never fit `ContentDocument`'s section/block shape, so `readDocxContent` never carried them — `readDocxExtras` is a second, independent read of the same package that returns exactly that data as its own real type, for a caller that wants it without reaching for `ooxml.js`'s own `readDocx` directly:
181
+
182
+ ```ts
183
+ import { readDocxExtras } from 'documents.js';
184
+ import { decodePackage } from 'ooxml.js';
185
+
186
+ const { comments, footnotes, headers, footers, numbering } = readDocxExtras(decodePackage(docxBytes));
187
+ console.log(comments[0]?.author, comments[0]?.text, footnotes[0]?.text, headers[0], footers[0]);
188
+ console.log(Object.values(numbering)[0]?.levels['0']?.format); // numbering is keyed by abstractNumId, each level by its own level index
189
+ ```
190
+
178
191
  `openPptx`/`createPptx` and `PptxSlide`/`PptxShape` are the pptx equivalent (`slide.addTextBox`, `slide.addImage`, `shape.setParagraphs` for multi-paragraph styled text).
179
192
 
180
- `openOdt`/`createOdt` and `OdtParagraph`/`OdtRun`/`OdtTable`/`OdtList` are the odt equivalent, built on ODF's own style-name-referencing model (`run.bold = true` interns or reuses a named `style:style` in `office:automatic-styles`, rather than writing an inline attribute — see [Conventions](#conventions) below). `openOdp`/`createOdp` and `OdpSlide`/`OdpShape` are the odp equivalent of `PptxSlide`/`PptxShape` (`slide.addTextBox`, `slide.addImage`, `slide.notes`), and reuse `OdtParagraph`/`OdtRun`/`OdtList` directly for a shape's own text content — a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` model `office:text` does, interned into the same `content.xml` style registry:
193
+ `openOdt`/`createOdt` and `OdtParagraph`/`OdtRun`/`OdtTable`/`OdtList` are the odt equivalent, built on ODF's own style-name-referencing model (`run.bold = true` interns or reuses a named `style:style` in `office:automatic-styles`, rather than writing an inline attribute — see [Conventions](#conventions) below). `editor.body.appendFormula(formula, frame)` writes a real embedded formula: a whole nested ODF formula sub-document inside the same package, referenced from a `draw:frame`/`draw:object`, which is how ODF embeds a formula at all (see [Architecture](#architecture)'s `src/odf-package/` entry) — the odt counterpart to `DocxParagraph.appendOfficeMath`. `openOdp`/`createOdp` and `OdpSlide`/`OdpShape` are the odp equivalent of `PptxSlide`/`PptxShape` (`slide.addTextBox`, `slide.addImage`, `slide.notes`), and reuse `OdtParagraph`/`OdtRun`/`OdtList` directly for a shape's own text content — a `draw:frame`'s `draw:text-box` holds the identical `text:p`/`text:span` model `office:text` does, interned into the same `content.xml` style registry:
181
194
 
182
195
  ```ts
183
196
  import { createOdp } from 'documents.js';
@@ -301,9 +314,9 @@ const reports = readOdbReports(decodePackage(odbBytes)); // OdbReport[] -- each
301
314
  // 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
315
  ```
303
316
 
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).
317
+ This is *structure*, not *rendering* but rendering is now a real thing this package does with it, and `readOdbReportContent` below is the whole chain in one call: it resolves the report's own query against the data, evaluates its bands' formulas over the result, and lays the printed bands out as a real `ContentDocument`. What is *not* offered is a pixel-faithful reproduction of Report Builder's own page output; see [Fidelity](#fidelity) for exactly where that line falls.
305
318
 
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.
319
+ `readOdbTables` takes a decoded `Package` (matching `readOdtContent`/`readOdsContent`/etc.'s own convention), while `odbToXlsx`/`odbToCsv` take raw bytes and decode them internally, matching every other ergonomic conversion in this package. `.odb` has no `odbToPdf` ergonomic conversion and no reverse (xlsx/CSV → `.odb`) direction, and — like `odmToPdf` — is not wired into the `DocumentConverter` port below: the write direction would need a real embedded SQL engine this package deliberately does not implement, and `.odb` has no single natural target format, since a database front-end's tables, its saved queries, and its reports are three unrelated output shapes rather than one. A rendered report is nevertheless an ordinary wordprocessing `ContentDocument`, so a caller wanting one as a PDF, a docx, or an odt feeds `readOdbReportContent`'s own output to `convertWordprocessingToLayout`/`buildDocxPackage`/`buildOdtPackage` exactly as for any other document of that variant.
307
320
 
308
321
  `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:
309
322
 
@@ -313,6 +326,55 @@ import { readFirebirdBackup } from 'documents.js';
313
326
  const { summary, tables } = readFirebirdBackup(firebirdBackupBytes); // summary: backupFormatVersion/transportable/compressed/pageSizeBytes; tables: the same HsqldbTable[] shape
314
327
  ```
315
328
 
329
+ A `.odb`'s own saved queries arrive as SQL *text* (`OdbQueryInfo.command`, via `readOdbInventory`), which on its own answers nothing about the data. `parseSelect`/`evaluateSelect` (`src/odb/sql/`) close that gap: a bounded single-table `SELECT` engine that runs directly over the `HsqldbTable[]` `readOdbTables` produces, in memory, with no database engine anywhere in the path:
330
+
331
+ ```ts
332
+ import { decodePackage, readOdbInventory } from 'odf.js';
333
+ import { evaluateSelect, parseSelect, readOdbTables } from 'documents.js';
334
+
335
+ const pkg = decodePackage(odbBytes);
336
+ const [query] = readOdbInventory(pkg).queries; // e.g. { name: 'HighValueSales', command: 'SELECT "SALES"."REGION", ... ORDER BY "SALES"."AMOUNT" DESC' }
337
+ const { columns, rows } = evaluateSelect(parseSelect(query.command), readOdbTables(pkg)); // columns: string[]; rows: ContentCellValue[][]
338
+
339
+ // Or write the query yourself, against whatever readOdbTables returned:
340
+ const byRegion = evaluateSelect(parseSelect('SELECT REGION, COUNT(*), SUM(AMOUNT) FROM SALES GROUP BY REGION ORDER BY REGION ASC'), readOdbTables(pkg));
341
+ ```
342
+
343
+ The grammar is a closed allowlist: `SELECT` a column list or `*` (or `COUNT`/`SUM`/`AVG`/`MIN`/`MAX`) `FROM` one table, with optional `WHERE` (comparisons, `AND`/`OR`/`NOT` with parentheses, `IS [NOT] NULL`, `[NOT] LIKE`, `[NOT] IN`, `[NOT] BETWEEN`), `GROUP BY`, and a multi-column `ORDER BY`. JOINs, subqueries, `UNION`, `DISTINCT`, `HAVING`, row limits, aliases, and every scalar function beyond those five aggregates throw `HsqldbSqlUnsupportedError` naming the construct — never a silently partial or wrong result set. `tokenizeSql` is exported too, for a caller that wants the token stream without the grammar. See [Gotchas](#gotchas-and-quirks) for the full boundary, and [Fidelity](#fidelity) for the semantics (three-valued `NULL` logic, NULL ordering, group ordering).
344
+
345
+ A Report's own bands go one step further than a query: each bound control carries an `rpt:formula` attribute, and a report declares nested groups whose break tests and per-group totals are written in that same little language. `runRptReport` (`src/odb/formula/`) evaluates it over the result set the query engine just produced, turning a report's static structure into the band instances a renderer would lay out — each carrying its own evaluated values:
346
+
347
+ ```ts
348
+ import { decodePackage, readOdbInventory } from 'odf.js';
349
+ import { evaluateSelect, parseSelect, readOdbReports, readOdbTables, rptDefinitionFromReport, runRptReport } from 'documents.js';
350
+
351
+ const pkg = decodePackage(odbBytes);
352
+ const [report] = readOdbReports(pkg); // e.g. { name: 'SalesByRegion', command: 'HighValueSales', commandType: 'query', groups: [...], functions: [...] }
353
+ const query = readOdbInventory(pkg).queries.find((candidate) => candidate.name === report.command);
354
+ const rows = evaluateSelect(parseSelect(query.command), readOdbTables(pkg));
355
+
356
+ const { bands } = runRptReport(rptDefinitionFromReport(report), rows);
357
+ // bands: one entry per printed band, in print order -- 'report-header', then per row the 'group-header's that open at it, the
358
+ // 'detail' band, and the 'group-footer's that close after it, then 'report-footer'. Each carries `values`, one evaluated
359
+ // ContentCellValue per band element (undefined for an element with no formula of its own, e.g. a fixed-content label).
360
+ ```
361
+
362
+ The function set is a closed allowlist here too: `rpt:HASCHANGED(X)` (the group-break test — true when `X` differs from its value on the preceding row), `rpt:LEFT(X;n)` (note the **semicolon** separator, LibreOffice's own formula-language convention), and `rpt:SUM`/`COUNT`/`AVG`/`MIN`/`MAX`, plus the separate `field:[COLUMN]` bound-field form, which is a plain value passthrough rather than a computation. Every other rpt function — and Report Builder ships many — throws `RptFormulaUnsupportedError` naming it. `parseRptFormula` is exported too, for a caller that wants one formula's AST without running a report. See [Gotchas](#gotchas-and-quirks) for the group-scoping rule, which is the substance of this engine.
363
+
364
+ `readOdbReportContent` (`src/odb/report/`) is all of the above in one call — the report's data binding resolved, its query run, its formulas evaluated, and its printed bands rendered as a real `ContentDocument`:
365
+
366
+ ```ts
367
+ import { decodePackage } from 'odf.js';
368
+ import { readOdbReportContent } from 'documents.js';
369
+
370
+ const document = readOdbReportContent(decodePackage(odbBytes)); // a 'wordprocessing' ContentDocument -- one section, one block per printed band
371
+ const another = readOdbReportContent(decodePackage(odbBytes), { report: 'SalesByRegion' }); // required whenever the .odb declares more than one
372
+ ```
373
+
374
+ Resolving the report's own `rpt:command`/`rpt:command-type` binding is the one part the formula engine never saw: `"table"` means the command names a table and the report reads all of it (turned into a real `SELECT * FROM "<table>"` and run through the same engine, rather than a second resolution rule that could disagree with it), `"query"` means it names a saved query in the `.odb`'s own `db:queries` whose `db:command` holds the SQL, and `"command"` means the command *is* the SQL. Rows arrive in that command's own `ORDER BY` order, and the report's `rpt:sort-expression` is deliberately *not* applied on top — a group's sort expression is a bare column name, so re-sorting by it would discard whatever finer ordering the command already asked for (the real fixture's saved query orders `REGION`, `QUARTER`, then `AMOUNT` **descending**, and the two group sort expressions name only the first two).
375
+
376
+ Each printed band becomes one single-row `ContentTable`, one cell per control, in document order — the same shape the band has in the report file itself, where every band *is* a `table:table` whose cells hold its controls. Every cell's paragraph carries the band's own name as its `styleId` (`Report Header`, `Page Header`, `Group Header 1`, `Detail`, `Group Footer 1`, `Report Footer`, …), so which band a block printed from survives into the document rather than having to be inferred from its position. Its three stages stay independently usable like every other `.odb` stage: `odbReportCommandSql` (a report → the SQL it issues), `resolveOdbReportRows` (a package + a report → those rows), and `renderOdbReportContent` (a report + any equivalently-shaped rows → the document — useful for rendering the same report over an unfiltered table, say). See [Fidelity](#fidelity) for what "structural, not pixel-faithful" means here in detail.
377
+
316
378
  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:
317
379
 
318
380
  ```ts
@@ -371,6 +433,18 @@ const { diagnostics } = editor.body.appendParagraph().appendOfficeMath(mathml);
371
433
  const { element } = buildOfficeMathParagraph(mathml); // or build the fragment directly, for a caller placing it itself
372
434
  ```
373
435
 
436
+ `readOfficeMath`/`collectOfficeMathElements` are the read-side inverse — an OOXML equation back to real MathML. `readDocxContent` runs them over every paragraph itself (see [Architecture](#architecture)'s `src/omml/` entry), so an equation in a docx arrives as an ordinary formula-carrying `ContentEmbeddedObjectBlock` with no caller involvement; these are exported for a caller mining equations out of a docx directly:
437
+
438
+ ```ts
439
+ import { collectOfficeMathElements, readOfficeMath } from 'documents.js';
440
+
441
+ for (const equation of collectOfficeMathElements(paragraphElement.children)) {
442
+ const { mathml, diagnostics } = readOfficeMath(equation);
443
+ // mathml: the children of a <math> root -- exactly what ContentFormula.mathml holds, and what layoutFormula above consumes
444
+ // diagnostics: an 'unsupported-element' or 'approximated-element' entry per OMML construct MathML has no faithful counterpart for -- see Gotchas
445
+ }
446
+ ```
447
+
374
448
  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:
375
449
 
376
450
  ```ts
@@ -380,6 +454,36 @@ import { buildOdtPackage } from 'documents.js/edit/odt/content';
380
454
 
381
455
  This works via a `"./*"` wildcard entry in `package.json`'s `exports` map, resolving any subpath to the correspondingly-named file under `dist/` — the same directory structure `src/` has, one output file per source module, so `src/edit/odt/content.ts` becomes `dist/edit/odt/content.js`/`.cjs`/`.d.ts`/`.d.cts`.
382
456
 
457
+ ## Fonts
458
+
459
+ Every X → PDF conversion (`docxToPdf`, `pptxToPdf`, `odtToPdf`, `odpToPdf`, `odsToPdf`, `odgToPdf`, plus `markdownToPdf`/`xlsxToPdf`/`odmToPdf`) resolves each requested typeface through a real [`FontRegistry`](https://github.com/ExaDev/pdf-codec), in this order:
460
+
461
+ 1. **The source document's own embedded faces.** A docx that was saved with font embedding on carries the exact bytes it was authored against, in `word/fontTable.xml`'s `w:embed*` parts (obfuscated per ECMA-376 Part 4, 2.8.1 — the first 32 bytes XORed against a key derived from the accompanying `w:fontKey` GUID); a pptx carries them in `p:embeddedFontLst` (unobfuscated `.fntdata` parts); an ODF package carries them under `Fonts/`, declared by `office:font-face-decls`'s `svg:font-face-uri` (also unobfuscated). All three are extracted automatically — the caller does nothing.
462
+ 2. **Faces the caller supplied** through `options.fonts`, for a family the source document did not embed.
463
+ 3. **pdf-codec's vendored Carlito and Caladea faces**, genuinely metric-compatible with Calibri and Cambria, embedded as real subsetted TrueType font programs.
464
+ 4. **The standard 14**, for everything else — where Helvetica/Times-Roman remain metric-compatible with Arial/Times New Roman and a width-correction factor approximates the rest.
465
+
466
+ The same registry drives both halves of a conversion: the `TextMeasurer` that decides where lines break and the writer that emits the glyphs. That is load-bearing rather than tidy — measuring against Helvetica's metrics and then drawing through a real Carlito face would wrap text at positions that do not match what was painted.
467
+
468
+ ```ts
469
+ import { docxToPdf } from 'documents.js';
470
+
471
+ // Nothing to configure: a docx that embedded its fonts renders in its real typeface.
472
+ const pdfBytes = docxToPdf(docxBytes);
473
+
474
+ // A face for a family the document didn't embed, plus a report of anything that still fell back.
475
+ const withFallbackFace = docxToPdf(docxBytes, {
476
+ fonts: [{ family: 'Brand Sans', bold: false, italic: false, bytes: brandSansTtfBytes }],
477
+ onFontSubstitution: (substitution) => console.warn(substitution.requestedFamily, '->', substitution.resolvedFamily),
478
+ });
479
+ ```
480
+
481
+ A document that embeds nothing and asks for no family a vendored substitute covers writes **byte-identical** output to the standard-14-only pipeline this package had before font resolution existed — proven by a real before/after byte comparison across all six conversions in `src/convert/convert-fonts.test.ts`, against a reference that reproduces the old pipeline exactly.
482
+
483
+ Two honest limits, both structural rather than provisional. An embedded face is normally **subsetted** by the application that saved it, so it can legitimately lack a character this package synthesises rather than reads (a list bullet, `sheets.ts`'s `###` column-overflow marker); pdf-codec reports that per character through `onMissingGlyph` and falls back for that one character, never for the run or the document. And `odfToPdf` accepts both font options and consults neither — a standalone formula document emits no positioned text at all, only the embedded STIX Two Math font's own glyphs, which are not registry-resolvable.
484
+
485
+ `extractOoxmlEmbeddedFonts`/`extractOdfEmbeddedFonts`, `extractSourceFonts`, and `createDocumentFontRegistry` are exported for a caller composing `readXContent` → `convertXToLayout` → `writePdf` themselves rather than going through an ergonomic conversion.
486
+
383
487
  ## Architecture
384
488
 
385
489
  The package is layered from generic primitives outward to the two conversion directions:
@@ -388,20 +492,26 @@ The package is layered from generic primitives outward to the two conversion dir
388
492
  - **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).
389
493
  - **`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.
390
494
  - **`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()`.
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.
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).
495
+ - **`src/odf-package/`** — the ODF-side counterpart to `src/opc/`: `manifest.ts` re-exports `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 adds exactly one thing of its own, `syncOdfManifest`: `odf.js`'s `buildManifest` synthesises a `manifest:file-entry` for every embedded sub-document directory it finds (any `"<dir>/content.xml"` prefix) but resolves that entry's media type by file EXTENSION, which a directory has none of, so it comes out empty unless a caller supplies an override. `syncOdfManifest` derives each one from what the sub-document actually is — the single element inside its own `office:body`, the same discriminant every `odf.js` reader keys on — and every part-mutating helper here syncs through it, so adding an image to a document that already embeds a formula cannot blank the formula object's own entry on the way past. `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) — 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. `formula.ts`'s `addFormulaObject` is the newer sibling and a genuinely different shape of insertion: an embedded ODF formula is not a markup vocabulary inside the host `content.xml` the way OOXML's own OMML is, it is a WHOLE NESTED DOCUMENT stored under its own directory prefix in the same zip (`Object 1/content.xml`, an `office:document-content` > `office:body` > `office:math` > `math:math` tree), referenced from the host by a `draw:frame`/`draw:object` naming that directory — precisely what `odf.js`'s own `readOdfFormula` reads back, and what `readOdfEmbeddedFormula` (`src/odf/formula/read.ts`) resolves out of the outer package's flat parts record. The formula's own MathML nodes are written straight through with no translation and no re-serialisation, since `document-schema.js`'s `MathMlNode` and `odf.js`'s `XmlNode` are structurally identical; the `math:math` element declares the MathML namespace both as the `math:` prefix and as the default, so a prefixed tree (real LibreOffice output) and a bare one (what `src/omml/read.ts` recovers from an OOXML equation) are each genuinely namespaced. `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; `OdtBody.appendFormula` (via `src/edit/odt/formula.ts`) is `addFormulaObject`'s; `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.
496
+ - **`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` and `OdtBody.appendFormula` are the formula-writing primitives, and they are deliberately shaped differently because the two formats embed a formula in genuinely different ways: `appendOfficeMath` appends a real OMML display equation (`m:oMathPara` > `m:oMath`) built by `src/omml/write.ts` INLINE in the paragraph, while `appendFormula` writes a whole nested formula sub-document into the package (`src/odf-package/formula.ts`) and appends a `draw:frame`/`draw:object` referencing it. `buildDocxPackage`/`buildOdtPackage` use them to write an embedded formula as genuine, editable math in each format 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).
497
+ - **`src/fonts/`** — source-embedded font extraction, and the registry composition every X → PDF conversion builds from it (see [Fonts](#fonts) above for the resolution order this produces). `obfuscation.ts` implements ECMA-376 Part 4, 2.8.1: `deriveFontKey` turns a `w:fontKey` GUID into the 16-byte XOR key — reading its 32 hex digits as byte pairs in REVERSE order, so `key[0]` is the GUID's LAST pair, verified against the specification's own worked example — and `deobfuscateEmbeddedFont` applies it twice across the part's first 32 bytes. One function covers docx and pptx both, by sniffing the leading sfnt signature FIRST and only deobfuscating bytes that are not already a recognisable font, rather than branching on source format: pptx's own `.fntdata` parts are stored clear and carry no font key at all, and a docx producer that stored a clear part stays readable too. `ooxml.ts` resolves `word/fontTable.xml` (or `ppt/presentation.xml`) through the package's own relationship graph rather than assuming a conventional path, reads each `w:embedRegular`/`w:embedBold`/`w:embedItalic`/`w:embedBoldItalic` (or `p:regular`/`p:bold`/`p:italic`/`p:boldItalic`) reference, and produces pdf-codec's `ProvidedFont` shape. `odf.ts` does the same for `style:font-face`'s `svg:font-face-src`/`svg:font-face-uri` — no relationship indirection, no obfuscation, and a face's weight/style taken from `loext:font-weight`/`loext:font-style` where a producer wrote them and from the font's OWN `OS/2` `fsSelection` bits where it did not (the better signal of the two: a `loext` attribute is a producer's claim about a file, `fsSelection` is that file's own declaration about itself). `registry.ts`'s `createDocumentFontRegistry` composes a source package plus any caller-supplied faces into a real `FontRegistry`, expressing the whole precedence chain as data (`sourceFonts` ahead of `fonts` ahead of the vendored substitutes) rather than as a branch. A face is deliberately never filtered by what the document actually uses: an embedded face is normally subsetted, so a character this package synthesises rather than reads can legitimately be absent from a face that is otherwise exactly right, and that is resolved per character by pdf-codec's own `onMissingGlyph`, not by dropping the whole face.
393
498
  - **`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.
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.
499
+ - **`src/omml/`** — the MathML OMML (Office Math Markup Language, ECMA-376 Part 1 §22.1's own `m:` vocabulary) structural translator, both directions. `write.ts`'s `buildOfficeMath`/`buildOfficeMathParagraph` are the write side, the 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: each MathML construct maps 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). `read.ts`'s `readOfficeMath`/`collectOfficeMathElements` are the read side, the structural inverse of every one of those mappings, and read STRICTLY MORE than the writer writes — deliberately, since the writer only ever has to express what MathML can say while the reader has to cope with whatever Word itself authored: `m:d` (Word's representation of every parenthesised sub-expression), `m:nary` (a sum/product/integral with limits AND its own operand), `m:acc`, `m:bar`, `m:func`, and `m:sPre` each have one exact MathML inverse and no writer counterpart at all. Both directions emit no geometry, measure nothing, and load no font — this is a vocabulary translation, not a rendering. The directory lives outside `src/mathml/` for that directory's own isolation rule: `write.ts`'s whole output type (and `read.ts`'s whole input type) is `ooxml.js`'s `XmlElement`, and `src/mathml/` imports no package at all. `shared.ts` holds what neither direction owns: the `OmmlDiagnostic` shape both report through, the one `mathvariant` ⇄ `m:scr`/`m:sty` table each reads in its own direction, and `mi`'s own intrinsic-variant default. `buildDocxPackage` and `readDocxContent` are their real callers; a construct with no counterpart in the target vocabulary degrades on its own, with a diagnostic, exactly as `src/mathml/layout.ts`'s own `unsupported` fallback does for the PDF path.
500
+ - **`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. `docx/formula.ts` is the one piece of genuinely local reading work left: a second, independent pass over the same `word/document.xml`, splicing every OOXML math equation `src/omml/read.ts` recovers into the sections `readDocx` produced — needed because `readDocx` has no `m:oMath` handling at all, exactly the way `src/odf/odt/read.ts` needs its own pass for a formula `odf.js`'s `readOdt` likewise does not read. Positioning is derived rather than approximated, and by a shorter route than the ODF side's own block-counting mirror needs: every `w:p` produces exactly one top-level `ContentParagraph` block and nothing else produces one, so the Nth `w:p` in the body IS the Nth paragraph-kind block. A `w:p` carrying nothing but its equation is CONSUMED by the formula block rather than emitted alongside it, which is what keeps a `docx → odt → docx` round trip from accumulating one blank paragraph per formula per hop. `docx/extras.ts`'s `readDocxExtras` is a second, independent re-projection of that same `readDocx` call, for the data `readDocxContent` genuinely cannot carry through `ContentDocument`'s section/block shape at all: comments, footnotes, headers/footers, and numbering (`abstractNum`/`num`) definitions. It calls `readDocx` a second time rather than being fused onto `readDocxContent`'s own return value — an accepted cost matching every other "each pipeline stage independently exported" pair in this codebase — and reuses `ooxml.js`'s own `Comment`/`Footnote`/`NumberingDefinitions` types directly rather than mirroring them locally.
396
501
  - **`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.
397
502
  - **`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.
398
503
  - **`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
504
  - **`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.
400
505
  - **`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.
401
506
  - **`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.
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.
403
507
 
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 typenever `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.
508
+ - **`src/odb/sql/`** a bounded single-table SQL `SELECT` engine over the `HsqldbTable[]` `src/odb/read.ts` produces, in four modules with a strictly downward dependency chain: `errors.ts` (the three failure classes `HsqldbSqlUnsupportedError` for real SQL this engine recognises and deliberately does not implement, `HsqldbSqlParseError` for input that is not well-formed SQL under this grammar, `HsqldbSqlEvaluationError` for a statement that parsed but cannot be executed faithfully against the dataeach carrying the offending SQL text), `lexer.ts` (`tokenizeSql`: identifiers with SQL's own quoted/unquoted case rule, `''`- and `""`-escaped literals, numeric literals including a leading-dot and exponent form, the six comparison operators, and a symbol-level rejection list naming arithmetic, `||`, comments, parameter placeholders, and `!=` individually), `parser.ts` (`parseSelect`: a real recursive-descent grammar see that module's own top-of-file production listpreceded by a single-pass scan that rejects every recognised out-of-scope construct by name, so a JOIN is reported as a JOIN rather than as a baffling unexpected keyword; an identifier immediately followed by `(` is necessarily a scalar function, since the five aggregates lex as keywords), and `evaluate.ts` (`evaluateSelect`: WHERE filtering under genuine three-valued NULL logic, projection, GROUP BY partitioning with the five aggregates, and a stable multi-column ORDER BY). It imports `document-schema.js`'s `ContentCellValue`, `src/hsqldb/script.ts`'s `HsqldbTable` type, and `src/odb/values.ts` (below) and nothing else no odf.js `Package` knowledge, no PDF knowledge, mirroring `src/hsqldb/`'s own isolation discipline. There is no write direction: this engine reads SQL, it never generates it.
509
+ - **`src/odb/values.ts`** — the `ContentCellValue` comparison and aggregation semantics `src/odb/sql/` and `src/odb/formula/` share: `cellComparisonKey`/`compareCellKeys`/`compareCellValues` (values compare within three classes — numeric, boolean, text — and never across them), `cellValuesEqual` (the *total* counterpart, since a cross-class pair is unambiguously unequal where an ordering comparison has to throw; this is what `rpt:HASCHANGED` needs), and `aggregateCellValues` (the five aggregates over SQL's own NULL-skipping rules). Both engines implement the identical five aggregates over identical inputs, so the semantics live here once rather than in each — a fix to one would otherwise silently leave the other wrong. What it deliberately does *not* own is which error a violation raises: every function takes a `fail` factory and throws what the caller builds, so the same comparison failure surfaces as an `HsqldbSqlEvaluationError` quoting the statement or an `RptFormulaEvaluationError` quoting the formula.
510
+ - **`src/odb/formula/`** — a LibreOffice Report Builder rpt formula engine over the result set `src/odb/sql/` produces, in four modules: `errors.ts` (the same three-class policy as the SQL engine — `RptFormulaUnsupportedError` naming a genuine Report Builder function outside the implemented set, `RptFormulaParseError` for text that is not a well-formed formula, `RptFormulaEvaluationError` for one that parsed but cannot run against the report's own data — plus `RptReportStructureError` for a failure about the report rather than any one formula), `parser.ts` (`parseRptFormula`: a self-contained recursive-descent scanner with no separate lexer, since this language has no keyword vocabulary or operator precedence to keep out of the grammar — `field:[X]` and `rpt:NAME(arg{;arg})`, with `[NAME]` and `"NAME"` as one reference concept and a **semicolon** argument separator), `evaluate.ts` (`runRptReport`: the group-break cascade, group instance ranges, and per-band formula evaluation, the substance of the engine — see the Gotchas entry below), and `definition.ts` (`rptDefinitionFromReport`: the only file here that knows odf.js's own `OdbReport` shape, flattening its nested `rpt:group` tree into the outermost-first chain the evaluator's level-indexed scoping assumes). `evaluate.ts`/`parser.ts` import `document-schema.js`'s `ContentCellValue`, `src/odb/sql/`'s `SqlResultSet` type, and `src/odb/values.ts` only — the same isolation discipline `src/odb/sql/` follows, with odf.js knowledge quarantined in `definition.ts` exactly as `src/odb/read.ts` quarantines it for the decoders. There is no write direction here either: this engine reads formulas, it never generates them.
511
+ - **`src/odb/report/`** — the renderer that turns everything above into a document, in three modules matching the three questions rendering a report actually poses: `source.ts` (`odbReportCommandSql`/`resolveOdbReportRows`: what data does this report bind to? — the `rpt:command`/`rpt:command-type` triple of table name, saved-query name, and inline SQL, all three resolved to one statement run through `src/odb/sql/`, so an unknown table fails with that engine's own message naming every table the `.odb` really has rather than through a second resolution rule that could disagree with it), `render.ts` (`renderOdbReportContent`: what does a printed band look like as content? — one single-row `ContentTable` per band instance, one cell per control, the same shape the band has in the file itself, plus the two page bands the formula engine deliberately never emits, evaluated here through `evaluateRptBandOutsideData` under this renderer's own single-logical-page model), and `content.ts` (`readOdbReportContent`: the composition, plus `OdbReportNotSpecifiedError` for a package declaring no report or more than one with none named — mirroring `csv.ts`'s own table-selection convention). `render.ts` is the only module here that knows what a `ContentDocument` is, and `source.ts` the only one that reads a `Package`; both flattening the report's `rpt:group` tree and evaluating a band's formulas are `src/odb/formula/`'s (via that module's exported `odbReportGroupChain`, so a band instance's own group level and the `OdbReportGroup` its controls come from can never index different chains). There is no reverse direction: a `ContentDocument` holds a report's *output*, not the band/group/formula design that produced it.
512
+ - **`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. `ConversionOptions` carries `fonts`/`onFontSubstitution` alongside `signal` for the same reason `DocumentToPdfOptions` does (see [Fonts](#fonts)), reaching only the `toPdf` edges — a PDF-to-X reconstruction reads a page's already-positioned glyphs and a bridge runs no layout engine, so neither resolves a face at all — and the local implementation reports every substitution as a `font/substituted` diagnostic as well as through the caller's own callback.
513
+
514
+ 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`); `fonts` imports no local module at all either — only `ooxml.js`/`odf.js` for the two package shapes it reads and `pdf-codec` for the `ProvidedFont`/`FontRegistry` shapes it produces, so it sits beside `layout` rather than under it despite both feeding the same conversion; `odb` imports `hsqldb`+`firebird`+`model`+`odf-package`+odf.js only, and its own `odb/sql` and `odb/formula` subtrees import strictly less than that — `odb/values.ts` plus `document-schema.js`'s `ContentCellValue` plus `hsqldb`'s `HsqldbTable` type for the former, and `odb/values.ts` plus `ContentCellValue` plus `odb/sql`'s `SqlResultSet` type for the latter, with odf.js reaching `odb/formula` only through its one `definition.ts` adapter; `odb/report` is the one subtree that imports *more* than `odb` itself rather than less, since rendering is where the two halves finally meet — `odb/sql`, `odb/formula`, `odb/read.ts`, `hsqldb`'s `displayTextFor`, `model`'s `PAGE_SIZE_A4`, `document-schema.js`'s content vocabulary, and odf.js's `OdbReport` shape — and it still keeps each of those to one module: `Package` reaches only `source.ts`/`content.ts`, and `ContentDocument` only `render.ts`; `convert` composes everything else, including `fonts` and `pdf-codec` directly for `readPdf`/`writePdf`/`loadMathFont`/`createFontMeasurer`/`createFontRegistry` and `markdown-codec` indirectly via `markdown/read.ts`/`markdown/write.ts`/`markdown/text.ts`. Beyond this package's own local modules, 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.
405
515
 
406
516
  ## Build, test, and lint
407
517
 
@@ -411,7 +521,7 @@ pnpm typecheck # tsc --noEmit
411
521
  pnpm lint # eslint . --max-warnings 0
412
522
  pnpm test # vitest run --project unit
413
523
  pnpm test:watch # vitest --project unit
414
- pnpm test:smoke # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise, a real createOdg/odgToPdf/pdfToOdg round trip (a curved path, a filled rect, and text, built entirely through the odg live-view editor, converted to PDF and reconstructed back to odg via reconstructDrawing), a real odfToPdf conversion (a fraction, rendered via the embedded STIX Two Math font -- checked by confirming the built PDF contains a real /Type0/Identity-H/CIDFontType0C font resource, proving the base64-embedded font asset itself survived the tsdown build), and a real markdownToPdf/pdfToMarkdown round trip plus a markdownToDocx bridge exercise, from the built CJS bundle
524
+ pnpm test:smoke # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise, a real createOdg/odgToPdf/pdfToOdg round trip (a curved path, a filled rect, and text, built entirely through the odg live-view editor, converted to PDF and reconstructed back to odg via reconstructDrawing), a real odfToPdf conversion (a fraction, rendered via the embedded STIX Two Math font -- checked by confirming the built PDF contains a real /Type0/Identity-H/CIDFontType0C font resource, proving the base64-embedded font asset itself survived the tsdown build), a real markdownToPdf/pdfToMarkdown round trip plus a markdownToDocx bridge exercise, and real font resolution in docxToPdf (a Calibri run producing a genuine /Type0/Identity-H/CIDFontType2/FontFile2 Carlito font program, alongside an Arial control run that embeds nothing at all), from the built CJS bundle
415
525
  ```
416
526
 
417
527
  The optional real-world PDF conformance corpus (`test:corpus` in the family's earlier layout) now lives in `pdf-codec`'s own repository, since it exercises the PDF codec directly rather than anything docx/pptx/odt/odp/ods/odg-specific — see that package's own README.
@@ -430,7 +540,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
430
540
 
431
541
  ## Gotchas and quirks
432
542
 
433
- - **`ooxml.js`'s typed readers (`readDocx`/`readPptx`) are now the actual basis for conversion** — `readDocxContent`/`readPptxContent` are thin wrappers around them, not an independent walk of `word/document.xml`/`ppt/slides/slideN.xml`. They are still deliberately not re-exported from this package's own public surface: `readDocx`/`readPptx` also carry `comments`/`footnotes`/`headers`/`footers` (docx) that `ContentDocument` doesn't model, so exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models.
543
+ - **`ooxml.js`'s typed readers (`readDocx`/`readPptx`) are now the actual basis for conversion** — `readDocxContent`/`readPptxContent` are thin wrappers around them, not an independent walk of `word/document.xml`/`ppt/slides/slideN.xml`. They are still deliberately not re-exported from this package's own public surface: exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models. `readDocx`'s own `comments`/`footnotes`/`headers`/`footers`/`numbering` the fields `ContentDocument` doesn't model at all — are not lost, though: `readDocxExtras` (`src/ooxml/docx/extras.ts`) exposes that exact data as its own real `DocxExtras` return type, re-exported from this package's own surface alongside `Comment`/`Footnote`/`NumberingDefinitions` (`ooxml.js`'s own types, reused directly). `readPptx` has no equivalent extras reader yet — pptx's own comments/notes-master data was not in scope for this pass.
434
544
  - **ODF paragraph/heading text content is not a plain string the way a docx run's `w:t` is, and reading it wrong fails silently.** Real whitespace collapses HTML-style when an ODF consumer renders XML text-node content, so the format represents a run of two or more literal spaces as `<text:s text:c="N"/>` (an ELEMENT, not a text node), a tab as `<text:tab/>`, and a hard line break as `<text:line-break/>` — all three occupy real character positions in a paragraph's flat content model but carry no text-node value at all. Every ODF text getter in this codebase's editor layer (`src/edit/odt/*`, `src/edit/odp/*`, `src/edit/ods/*`) MUST call `decodeOdfText` (`src/xml/odf-text.ts`) — never `ooxml.js`'s own `textContent()`, a plain text-node concatenation with no idea `text:s`/`text:tab`/`text:line-break` exist. `textContent()` would silently DROP every one of them: the file still parses as valid XML, so this produces no error and no warning, just silently shorter text. `decodeOdfText` delegates the real work entirely to `odf.js`'s own `decodeOdfText` (wrapped in a synthetic container element, since `odf.js`'s version operates on a whole `XmlElement`'s children rather than a bare node array); the encode direction, `encodeOdfText` (plain string → the same element sequence, coalescing adjacent literal characters into as few text nodes as practical), is local to this package, since `odf.js` is a read-and-manifest package with no write-side text builder of its own.
435
545
  - **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).
436
546
  - **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.
@@ -443,8 +553,8 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
443
553
  - **`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
554
  - **`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
555
  - **`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`.
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.
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).
556
+ - **`OdsSheet.printSettings` (`src/edit/ods/print-settings.ts`) now round-trips every field `ContentSheetPrintSettingsSchema` carries, not just the five it started with.** `pageSize`/`margins`/`gridlines`/`headers`/`pageOrder` resolve through the `table:style-name` `style:style[family="table"]` `style:master-page-name` `style:master-page` `style:page-layout` `style:page-layout-properties` chain (`odf.js`'s own exported `findStyleElement`/`resolvePageLayoutProperties`/`parsePageSize`/`parseMargins`); the setter mints a fresh `style:page-layout` + `style:master-page` + `style:style[family="table"]` triple and repoints the sheet's own `table:style-name` to it on every call, the same append-only style-editing convention `src/edit/odg/style.ts` already documents. The remaining, previously-unimplemented fields are now implemented too: `printRange` reads/writes `table:print-ranges` directly on `table:table`; `scalePercent`/`fitToPages` read/write `style:scale-to` and `style:scale-to-X`/`style:scale-to-Y` on the page-layout-properties element; `repeatColumns`/`repeatRows` are read via `scanTableStructure`, a scoped-down mirror of `odf.js`'s own private `readTable`'s table-wide column/row cursor tracking (the same walk that function performs before ever calling its own `readPrintSettings`), and written by moving the real `table:table-column`/`table:table-row` elements covering the given range into a fresh `table:table-header-columns`/`table:table-header-rows` wrapper; `manualBreaks` read/write `fo:break-before="page"` on the named row/column's own style. Writing `repeatRows`/`repeatColumns` required teaching `address.ts`'s row/column addressing that a row/column may now live nested one level inside a header wrapper rather than as a direct `table:table` child, so a subsequent cell/column/row write against a wrapped index finds the real element instead of creating a spurious duplicate outside it; the width/height and manual-break writers all target the same `style:table-column-properties`/`style:table-row-properties` element, so each reads the column/row's current style first and mints a fresh style carrying the merged result, rather than a naive single-property mint clobbering whatever an earlier call had already set. No known gap remains in `ContentSheetPrintSettingsSchema` coverage.
557
+ - **`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 and `ContentSheetImage`/formula `embeddedObjects` are no longer gaps either, closed in the same phase: `OdsSheet.setColumnHidden`/`setRowHidden` set or clear `table:visibility="collapse"` directly on the `table:table-column`/`table:table-row` element — a plain attribute, not a style property, so it never interacts with the width/height setters above — and `buildOdsPackage` calls one of these for every column/row whose `hidden` field is `true`. `OdsSheet.addImage` (`src/edit/ods/floating.ts`) writes a real floating `draw:frame`/`draw:image` into `table:shapes` (the ODF 1.3 content-model container for spreadsheet floating shapes, always `table:table`'s own first child in a package this editor builds), resolving a `ContentSheetImage`'s `anchorRow`/`anchorColumn` plus `offsetXPt`/`offsetYPt` to an absolute `svg:x`/`svg:y` by summing the real, currently-declared width/height of every column/row strictly before the anchor (header-wrapper-aware, hidden columns/rows contributing zero, falling back to the same default column/row size the layout engine assumes once the walk runs past what the sheet has declared) reusing `addImageMedia` for the binary part and manifest entry, the same mechanism `src/edit/odp/image.ts` already uses for a slide. `OdsSheet.addEmbeddedObject` writes a real embedded ODF formula sub-document for `objectKind === 'formula'` (reusing `addFormulaObject`, the same mechanism `OdtBody.appendFormula` already uses); every other `objectKind` (`wordprocessing`/`presentation`/`spreadsheet`/`drawing`) is left unwritten, a documented, bounded gap mirroring `buildOdtPackage`'s identical narrowing for a `'drawing'` embedded object, since embedding one would mean writing that document's own package as a nested OLE sub-object, which no writer in this codebase implements. `buildOdsPackage` calls both for every sheet's images/embedded objects, after every column/row width/height/hidden call, so an image's own anchor resolves against the sheet's final, real column/row sizing. This is write-only for now: `odf.js`'s own `readOds` does not read ods floating shapes or embedded objects back at all (`images` hardcoded to `[]`, `embeddedObjects` never set — see the embedded-formula-detection gotcha below), so there is no `ContentDocument` re-read to verify a round trip against; every test verifies the real written package/XML structure directly instead, via `odf.js`'s own query/manifest/formula-reading primitives and a genuine zip encode/decode round trip.
448
558
  - **`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`.
449
559
  - **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.
450
560
  - **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.
@@ -455,22 +565,28 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
455
565
  - **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.
456
566
  - **`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.
457
567
  - **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.
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).
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 filea 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.
568
+ - **Ordinary text in PDF output now resolves through a real font registry rather than the standard 14 alone, and the standard 14 are only the last resort in that chain.** In order: the source document's own embedded faces (docx's `word/fontTable.xml`, pptx's `p:embeddedFontLst`, ODF's `office:font-face-decls` — see [Fonts](#fonts)), then any face the caller supplied through `options.fonts`, then pdf-codec's vendored Carlito/Caladea faces (genuinely metric-compatible with Calibri/Cambria, and embedded as real subsetted TrueType programs), then the standard 14. Helvetica/Times-Roman remain metric-compatible substitutes for Arial/Times New Roman, so a document asking for either still resolves to a standard font and embeds nothing. What is still not covered: a family with no embedded face, no caller-supplied face, and no vendored substitute — Aptos, say, or any third-party typeface — still renders through the nearest standard-14 face with a width-correction factor, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation there, not a line-identical reproduction. MathML formula rendering (`odfToPdf`, and formulas embedded inside odt/odp) is separate from all of this and always was: it embeds the real STIX Two Math font, which is not a registry-resolvable face and cannot be overridden by `options.fonts` — 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).
569
+ - **Justified paragraphs now stretch real inter-word gaps in all three layout engines — the flow one (`engine.ts`), the direct-placement one (`slides.ts`), and the spreadsheet one (`sheets.ts`).** `justifyLineGapsPt` (`src/layout/shared.ts`) recovers each wrapped line's own word-gap positions from a line'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`/`layoutParagraphInCell` (`engine.ts`, covering docx/odt paragraphs, docx/odt tables, and odm-assembled chapters) and `layoutParagraph` (`slides.ts`, covering pptx/odp shape text and slide-table cells) 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/Impress's own convention. `sheets.ts`'s `renderCellText` calls it too, but a spreadsheet cell only ever renders one line by this module's own documented scope, so the "non-final line" case only arises when a cell's source text carries an explicit line break — `wrapRunsToWidth` then produces more than one line, of which only the first is ever rendered, and that first line is the genuinely non-final one a justified cell stretches; justification is skipped outright when that line already overflowed its cell (the numeric-`###`/string-spill-or-truncate fragments no longer reflect the natural layout the stretch needs) or for an ordinary single-line cell, matching every real spreadsheet application's own "justify only wraps, never a single line" behaviour. `alignmentOffsetPt` itself still returns `0` for `'justify'` in all three files, 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 each caller applies on top, not a new branch inside it. No known gap remains in inter-word justification across any layout engine this package has.
460
570
  - **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.
461
571
  - **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
572
  - **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.
573
+ - **docx headers/footers, comments, footnotes, and numbering definitions are now readable — but not through `readDocxContent`, and live `PAGE`/`NUMPAGES` field substitution still isn't read at all.** `readDocxContent` still carries none of the first four through: `ContentDocument`'s section/block shape has nowhere to put a comment, a footnote, a header/footer, or a numbering definition, so it deliberately keeps dropping them, exactly as before. What changed is that they are no longer lost outright: `readDocxExtras` (see the `src/ooxml/` Architecture entry and the Usage example above) is a second, independent read of the same package that returns them as their own `DocxExtras` value. `PAGE`/`NUMPAGES` field substitution has no equivalent — neither function reads it, since it isn't static content at all but a value Word computes at render time from the document's own live layout, which this package has no path to reproduce. Inline images, meanwhile, now ARE read by `readDocxContent` itself: `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
574
  - **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.
465
575
  - **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.
466
576
  - **`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.
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.
577
+ - **`.odb` has no `odbToPdf` of its own, and does not need one.** All three parts of rendering a Report are now real — `src/odb/sql/`'s `parseSelect`/`evaluateSelect` run the report's own query over `readOdbTables`' output, `src/odb/formula/`'s `runRptReport` evaluates its rpt formulas and group breaks over the result, and `src/odb/report/`'s `readOdbReportContent` renders the printed bands into a `ContentDocument` and because that document is an ordinary `wordprocessing` one, every consumer of that variant already accepts it: `convertWordprocessingToLayout` lays it out, `writePdf` writes it, `buildDocxPackage`/`buildOdtPackage` build a docx or odt from it. Adding an `odbToPdf` wrapper would pick one of those targets arbitrarily and imply `.odb` had a single natural output format, which it does not: a database front-end's tables, its saved queries, and its reports are three unrelated output shapes, which is also why `.odb` stays out of `DocumentFormat` and the `DocumentConverter` port entirely. What no part of this chain does is reproduce Report Builder's own *page* output — see [Fidelity](#fidelity) for exactly what "structural, not pixel-faithful" excludes.
578
+ - **The rpt formula engine's group scoping cascades an enclosing break inward, and that is the one part of it most easily got subtly wrong.** An aggregate is scoped to the band it appears in — a `rpt:SUM([AMOUNT])` in an inner group's footer totals only that group instance's rows, one in the outer group's footer totals that whole instance, one in the report footer totals every row. The catch is when an instance *ends*: a group at level L starts a new instance when its own group-expression breaks **or when any enclosing group breaks**, unconditionally. The real fixture demonstrates exactly why. Its inner group breaks on `rpt:HASCHANGED("LEFT_QUARTER")` and its outer on `rpt:HASCHANGED("REGION")`; between the rows `(North, Q2)` and `(South, Q2)` the quarter does *not* change, so the inner expression is false there — yet the region does, and a "Q2" subtotal spanning North's Q2 rows and South's Q2 rows would be a number no reader asked for. The cascade lives in the report structure, **not** in `HASCHANGED`: that function is implemented exactly as its name says (the referenced value differs from its value on the immediately preceding row, and true on the first row), with no knowledge of groups at all, and `src/odb/formula/report.test.ts` proves both halves separately against the same real rows — the two-group report splits South's and West's Q2 rows, and the identical expression as the *only* group merges them. Two further consequences worth stating: aggregates are computed over a group instance's complete row range rather than accumulated row by row (the result set is already fully in memory, so a `SUM` in a group *header* is the true total for the group about to print, not a running total of its first row), and a group expression may not transitively depend on an aggregate — that is genuinely circular, since group expressions decide the very boundaries an aggregate's range is defined by, so it throws `RptFormulaEvaluationError` from a static walk of the named-function graph before a single row is read.
579
+ - **The rpt formula engine's function set is a closed allowlist, and its argument separator is a semicolon.** `rpt:HASCHANGED(X)`, `rpt:LEFT(X;n)`, and `rpt:SUM`/`COUNT`/`AVG`/`MIN`/`MAX`, plus the separate `field:[COLUMN]` bound-field form — every other rpt function throws `RptFormulaUnsupportedError` carrying the function name and the offending formula, the same policy `src/odb/sql/` and `src/hsqldb/script.ts` follow. The separator is `;`, not `,` (LibreOffice's formula languages use the Basic/Calc convention throughout, and the real fixture's `rpt:LEFT([QUARTER];2)` is the confirmation); a comma-separated argument list is rejected outright rather than accepted as a second convention. The two reference spellings, `[NAME]` and `"NAME"`, are treated as one concept and resolve by one rule, since the real fixture writes `rpt:HASCHANGED("REGION")` with quotes and `rpt:SUM([AMOUNT])` with brackets to no observable difference; a name matching *both* a declared `rpt:function` and a data column is ambiguous and throws rather than letting one shadow the other. Three further bounded refusals, each a place where guessing would produce a plausible wrong value rather than a visible failure: a group expression that does not evaluate to a boolean break test (real Report Builder writes `rpt:HASCHANGED(...)` and nothing else there, so a "group by this value's changes" reinterpretation has no real output to verify against); `rpt:LEFT` over a non-text value (a report's own number format lives in its band styles, which this engine does not read, so formatting a number to text here would mean inventing one); and a per-row formula in the report header or footer, which print outside the data and so belong to no row.
580
+ - **The rpt formula engine emits no page headers or footers, deliberately — the renderer places them, under a single-logical-page model it states rather than hides.** Which rows land on which page is a layout decision the formula engine has no basis for making, so `RptReportDefinition` carries no page bands and `rptDefinitionFromReport` drops odf.js's own `pageHeader`/`pageFooter` explicitly rather than silently. `src/odb/report/render.ts` is the renderer that decides: having no pagination engine, it declares the whole report one logical page, prints each page band once (the page header below the report header and above the body, matching the banded-report convention where a report's title sits above the column labels that then repeat on every page; the page footer above the report footer), and evaluates their formulas through `evaluateRptBandOutsideData` at **report** scope — which for a single page is not an approximation but exactly the right scope, since that page's rows are every row. Two failure modes need no special-casing because both already fail correctly: a per-row formula in a page band (`field:[X]`, `rpt:HASCHANGED`) throws for belonging to no row, exactly as it does in the report header, and `rpt:PAGENUMBER` or any other genuinely page-dependent function throws from the parser as an unsupported function rather than being rendered as a plausible-looking wrong value. In the real fixture the page header carries only `rpt:fixed-content` labels and the page footer declares no controls at all — a band with no controls prints no block, which is why nothing sits between the last region total and the grand total in the rendered output.
581
+ - **The SQL engine is a closed allowlist, not a partial SQL implementation, and every gap in it is a thrown error rather than an ignored clause.** `src/odb/sql/parser.ts`'s grammar covers exactly one statement shape (its own top-of-file production list is the full statement of it): `SELECT` a column list or `*` or an aggregate `FROM` one table, optional `WHERE` (the six comparison operators, `AND`/`OR`/`NOT` with parentheses, `IS [NOT] NULL`, `[NOT] LIKE` with `%`/`_`, `[NOT] IN` over a literal list, `[NOT] BETWEEN`), optional `GROUP BY` with `COUNT`/`SUM`/`AVG`/`MIN`/`MAX`, optional multi-column `ORDER BY` with per-column `ASC`/`DESC`. Everything else — JOINs (including the comma form), subqueries anywhere, `UNION`/`INTERSECT`/`EXCEPT`, `DISTINCT`, `HAVING`, `LIMIT`/`OFFSET`/`FETCH`/`TOP`, `AS` aliases and bare table aliases, `CASE`, `EXISTS`, `WITH`, schema-qualified table names, `ORDER BY` ordinals or aggregates, `LIKE ... ESCAPE`, `NULLS FIRST/LAST`, arithmetic, string concatenation, SQL comments, parameter placeholders, `!=`, and any scalar function at all — throws `HsqldbSqlUnsupportedError` with a `construct` field naming which one. This is `src/hsqldb/script.ts`'s own closed-allowlist policy (quoted in full at the top of `src/odb/sql/errors.ts` as the precedent) applied to a grammar: silently dropping a `HAVING` or a `DISTINCT` would return rows that look plausible and are wrong, which is strictly worse than returning nothing.
582
+ - **Four SQL semantics decisions the engine makes explicitly, each of which a caller can otherwise get wrong by assumption.** (1) `NULL` is `ContentCellValue`'s own `{ kind: 'empty' }`, and `WHERE` uses genuine three-valued logic — a comparison with a NULL operand is UNKNOWN, `NOT UNKNOWN` is still UNKNOWN, and a row survives only on TRUE; a non-match against an `IN` list containing NULL is UNKNOWN too, which is why `x NOT IN (1, NULL)` correctly keeps nothing. (2) Values compare within three classes (numeric, boolean, text) and a comparison ACROSS classes throws rather than coercing — coercion is exactly how a query engine silently returns wrong rows; text comparison is UTF-16 code-unit order, correct for the ISO-8601 date/time strings this package's readers produce but deliberately not an implementation of any database's own collation. (3) `GROUP BY` puts all NULLs in one group and returns groups in first-appearance order (SQL defines no order without `ORDER BY`, and first-appearance is the one deterministic choice); `COUNT(*)` counts rows, `COUNT(column)` counts non-NULL values, `SUM`/`AVG`/`MIN`/`MAX` ignore NULLs and return NULL for a group with no non-NULL value; an aggregate with no `GROUP BY` treats the whole post-`WHERE` row set as one group and still returns exactly one row when that set is empty. (4) `ORDER BY` sorts NULLs last under `ASC` and therefore first under `DESC`, and the sort is stable, so rows tied on every term keep their original order.
583
+ - **An unquoted SQL identifier folds to upper case and may match a real column case-insensitively; a double-quoted one matches only exactly.** That is SQL's own rule, and both HSQLDB and Firebird implement it — real LibreOffice-generated `.odb` queries quote every name, so they resolve exactly. Where an unquoted name matches more than one real column case-insensitively, resolution throws rather than picking one. A table qualifier (`"SALES"."REGION"`) is checked against the single table in `FROM` and rejected if it names anything else, since there is no second table it could legitimately refer to.
468
584
  - **`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
585
  - **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.
470
586
  - **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.
471
587
  - **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
588
  - **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.
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.
589
+ - **A BIGINT/DECIMAL/NUMERIC value beyond what a double can represent exactly no longer silently loses precision HSQLDB's CACHED-table decoder and Firebird's row decoder both now carry the exact value alongside the approximation.** `document-schema.js`'s `ContentCellValue` number/percentage/currency variants have long accepted an optional `exactValue` decimal-string sidecar for exactly this case; `readHsqldbColumnValue`'s BIGINT/DECIMAL/NUMERIC cases and Firebird `data.ts`'s `decodeRowValues` (short/long/int64 physical types) previously cast straight through `Number()` regardless, discarding it. Both now build the exact digit string via `BigInt` digit manipulation — never a floating multiply/divide, which would risk rounding for a large magnitude — and attach it as `exactValue` only when `String(Number(exactValue))` would not round-trip back to that exact string, `document-schema.js`'s own documented contract for the field; trailing fractional zeros are trimmed first, since a fixed-scale value like `"250.00"` carries no more precision than `"250"` and would otherwise spuriously gain a sidecar even though `Number()` already represents it exactly. Firebird's short/long/int64 cases previously scaled via `raw * 10 ** field.scale`, a floating multiplication carrying the identical precision risk for a large stored integer; they now decode through the same exact-digit-string path. `ContentCellValue` still has no arbitrary-precision kind of its own — `exactValue` is an optional sidecar a consumer may read for the full value, not a replacement for the `number` field — but the information is no longer discarded at the format boundary.
474
590
  - **`.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.
475
591
  - **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.
476
592
  - **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.
@@ -485,8 +601,8 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
485
601
  - **`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.
486
602
  - **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.
487
603
  - **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 entriesa 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.
604
+ - **A formula crossing a boundary that cannot typeset it degrades to its own plain-text stand-in — its StarMath annotation, or the literal `[formula]` — never to nothing. The docx bridges are no longer part of that list.** `buildDocxPackage` now writes a genuine OMML display equation (`m:oMathPara` > `m:oMath`, structurally translated by `src/omml/write.ts` — see the architecture entry above), so a formula crossing `odtToDocx`, or reaching a docx through any other `buildDocxPackage` caller, arrives as real, editable Word math rather than text. The stand-in survives there for exactly one case: a formula whose MathML produces no OMML content at all (an empty `mathml` array). An individual MathML construct with no OMML counterpart degrades on its own, *inside* the equation, as a literal-text run with an `unsupported-element` diagnostic reported through `buildDocxPackage`'s own `onMathDiagnostic` (threaded from `odtToDocx`/`markdownToDocx`'s `DocumentBridgeOptions`) — it never drags the whole formula down to text. `buildOdtPackage` is no longer on that list either: it writes a real embedded formula sub-document (a nested `Object N/content.xml` with its own `draw:frame`/`draw:object` reference and manifest entrysee the `src/odf-package/` architecture entry), with the identical single-case fallback, a formula carrying no MathML nodes at all. The markdown writer is the only genuinely stand-in-only path left, since CommonMark/GFM has no math construct whatsoever. **`odmToPdf` is not part of this list either**: a chapter's formula is an ordinary block inside that chapter's own `ContentDocument`, so it survives concatenation into the combined document exactly as a paragraph does and renders as genuine typeset MathML. That used to be a documented gap — the formulas travelled in a side-channel map keyed by `sourcePath`, and re-keying every entry against the combined document's own renumbered block indices was intractable — which moving a formula's content *into* the `ContentDocument` removed outright rather than solved.
605
+ - **OMML is read as well as written, but the two directions are deliberately not symmetric in coverage.** `readDocxContent` recovers a docx equation as a real `ContentEmbeddedObjectBlock` carrying its own MathML the identical shape `readOdtContent` produces for an ODF embedded formula so `docxToPdf` typesets a Word-authored equation, and `odt docx odt` carries a formula through as a formula. The reader covers strictly more than the writer emits, because it has to read what Word wrote rather than only what this package wrote: `m:d`, `m:nary`, `m:acc`, `m:bar`, `m:func`, and `m:sPre` have exact MathML inverses and no writer counterpart at all (see the `src/omml/` architecture entry). What that asymmetry costs in practice: a `docx → odt → docx` round trip of a Word-authored `m:d` comes back as explicit `mo` fence tokens inside an `mrow` rather than as an auto-growing `m:d` delimiter again, an `m:nary` comes back as a scripted operator followed by its operand rather than as an `m:nary`, and an `m:sPre` degrades outright on the way back out, since `mmultiscripts` is one of the constructs `src/omml/write.ts` has no OMML expression for. The mathematics survives every one of those hops; only the specific OMML construct that expressed it does not. Three further real, tracked read-side boundaries: an equation inside a TABLE CELL is not recovered (a cell's paragraphs are blocks of a `ContentTableCell`, not top-level blocks, so they neither participate in the `w:p`-ordinal correspondence nor have a top-level position to splice into the same scope line `buildDocxPackage`'s own `appendCellBlock` draws on the write side); OMML records no geometry whatsoever, so a recovered block's `frame` is a stand-in whose only meaningful field is `heightPt`, taken from the equation's own `w:rPr/w:sz` when it states one and from Word's own 11pt body default otherwise, stated as the exact inverse of `src/layout/engine.ts`'s `frameHeightPt / 2` size estimate; and an `mtext` that carried an explicit `mathvariant` was written as an ordinary styled math run, which OMML gives no way to distinguish from a styled `mi`, so it reads back as `mi`/`mn`/`mo` rather than as `mtext`.
490
606
  - **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.
491
607
  - **`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.
492
608
  - **`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.
@@ -512,7 +628,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
512
628
 
513
629
  ## Fidelity
514
630
 
515
- **docx/pptx/odt/odp/ods/odg → PDF** is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, `basedOn` chains, placeholder inheritance for docx/pptx; `style:default-style`/`style:parent-style-name` chains for odt/odp). `odg` renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF `m`/`l`/`c`/`h` content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful **visual approximation**, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — see the standard-14 font substitution gotcha above.
631
+ **docx/pptx/odt/odp/ods/odg → PDF** is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, `basedOn` chains, placeholder inheritance for docx/pptx; `style:default-style`/`style:parent-style-name` chains for odt/odp). `odg` renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF `m`/`l`/`c`/`h` content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful **visual approximation**, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — how close depends on which typeface the document asks for and whether it embedded one, see the font-resolution gotcha above.
516
632
 
517
633
  **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.
518
634
 
@@ -526,12 +642,22 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
526
642
 
527
643
  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.
528
644
 
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.
645
+ **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 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`, reads that equation back as real MathML (`src/omml/read.ts`) and writes it into the odt as a genuine embedded formula sub-document (`src/odf-package/formula.ts`), so `odt → docx → odt` keeps a formula as a formula — proven by `src/convert/formula.test.ts`'s own chain test, which compares the MathML recovered at the far end against the MathML the source carried and repeats the whole cycle three times to confirm nothing accumulates or erodes. What the two hops do NOT guarantee is that the same OMML construct comes back out, since the reader covers more of Word's own vocabulary than the writer can express — see that gotcha for exactly which constructs change shape.
530
646
 
531
647
  **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.
532
648
 
533
649
  **`.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.
534
650
 
651
+ **Running a `.odb`'s own saved query (`parseSelect`/`evaluateSelect`) is exact within its grammar, and a hard failure outside it — never an approximation.** Unlike every conversion above, there is no fidelity spectrum here: a statement either falls inside `src/odb/sql/parser.ts`'s closed grammar, in which case the rows it returns are the rows SQL defines for it (three-valued NULL logic, NULL-aware aggregates, stable multi-column ordering — see the Gotchas entries above for each decision spelled out), or it falls outside, in which case it throws with the construct named. Nothing in between: the engine never drops a clause it cannot handle and returns the rest. What it is *not* is a database — there is no query planner, no index, no transaction, no cursor, and every row of the table is materialised in memory by `readOdbTables` before a single predicate runs. Verified end to end against a real saved query in a real LibreOffice-generated `.odb`: `src/odb/sql/query.test.ts` reads `form-and-report.odb`'s own `HighValueSales` command out of the package via `readOdbInventory` (rather than restating it), runs it against the same package's real six-row `SALES` table decoded by the Tier 3 Firebird reader, and asserts the exact four surviving rows in the exact order its three-term mixed-direction `ORDER BY` demands.
652
+
653
+ **Evaluating a `.odb` Report's own rpt formulas (`runRptReport`) is exact within its function set, on the same terms.** There is no fidelity spectrum here either: a formula either falls inside `src/odb/formula/parser.ts`'s closed set, in which case its value is the value that function defines, or it falls outside, in which case it throws naming the function. The group scoping is likewise defined rather than approximated — instance boundaries follow one stated recurrence, and each aggregate covers exactly its own instance's row range (see the Gotchas entry above). What this engine on its own is *not* is a renderer: it produces evaluated band instances, not content, which is `src/odb/report/`'s job below. Verified end to end against a real report in the same real LibreOffice-generated `.odb`: `src/odb/formula/report.test.ts` reads `form-and-report.odb`'s own `SalesByRegion` report structure out of the package via `readOdbReport` (rather than restating any of it), resolves its `rpt:command` to that package's own `HighValueSales` saved query, runs the query through `src/odb/sql/`, and then asserts the whole band stream — the exact print order, the exact row each band was emitted against, the two-character prefixes the report's own `LEFT_QUARTER` function computes, and the exact `AMOUNT` total in each of the three real `rpt:SUM([AMOUNT])` scopes. The same real report definition is also run over all six `SALES` rows rather than the four the query keeps, which exercises the enclosing-break cascade a second time at a transition (`South`/`Q2` → `West`/`Q2`) where the inner group's own expression is false; the per-region totals it reaches that way are cross-checked against the ones `src/odb/sql/`'s `GROUP BY REGION` reaches by a completely different route over the same data.
654
+
655
+ **Rendering a `.odb` Report (`readOdbReportContent`) is structurally faithful, not pixel-faithful, and the line between those is exactly where odf.js's own report reader stops.** What is exact: which bands print, in what order, against which rows, with which group instances open, and what every formula in them evaluates to — all of that is the two engines above, which are exact within their own closed sets. What is *structural*: each printed band becomes one single-row `ContentTable`, one cell per control in document order, which is the shape the band genuinely has in the report file (every band there *is* a `table:table` whose cells hold its controls) rather than a guess at one. The alternative shape — a paragraph per field — was rejected, not merely not chosen: it would stack a detail row's Customer and Amount vertically, destroying the one relationship a banded report's layout grid exists to express.
656
+
657
+ What is *not* reproduced is presentation, because it is not read in the first place: a control's own font, colour, alignment, number format, and grid position live in its style, which odf.js's report reader deliberately does not resolve (that reader's own finding 3 states it — a control's grid position is presentation, not structure). So a numeric value renders as its own plain display text (`1200.5`, not the `1,200.50` the report's own format might produce), no band carries a font or a border, and column widths divide the section's content width equally between a band's cells, which is a stated fallback rather than a recovered measurement. Pagination is not reproduced either: this renderer declares one logical page rather than guessing where breaks fall (see the Gotchas entry for what that means for the two page bands). The bands' own identity does survive, as each cell's paragraph `styleId` (`Group Footer 1`, `Detail`, …), so a consumer can restyle by band without having to infer which band a block came from.
658
+
659
+ Verified end to end against the real report in the real LibreOffice-generated `.odb`: `src/odb/report/content.test.ts` renders `form-and-report.odb`'s own `SalesByRegion` — its binding resolved from `rpt:command-type="query"` to that package's own `HighValueSales` command, its rows decoded by the Tier 3 Firebird reader, its formulas evaluated by `src/odb/formula/` — and asserts the entire block sequence exactly: every band in print order, both `REGION` groups each containing its own `QUARTER` sub-groups, every detail row in the query's own order, and the `SUM(AMOUNT)` total in all three scopes, each computed by hand from the real six-row `SALES` data and asserted as both its rendered text and its exact number (`1540.50`/`2750.25`/`1810.00` per quarter, `4290.75`/`1810.00` per region, `6100.75` overall; and over all six rows rather than the four the query keeps, `1540.50`/`2750.25`/`95.75`/`1810.00`/`60.00`, `4290.75`/`1905.75`/`60.00`, and `6256.50`). The rendered document is also parsed against `ContentDocumentSchema` and pushed through `convertWordprocessingToLayout`/`writePdf`, so the claim that it needs no `odbToPdf` of its own is proven rather than asserted.
660
+
535
661
  **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.
536
662
 
537
663
  ## Release and publishing