js.documents 1.95.2 → 1.95.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/documents.js) [](https://www.npmjs.com/package/documents.js) [](https://github.com/ExaDev/documents.js/releases/latest) [](https://github.com/ExaDev/documents.js/actions)
|
|
4
4
|
|
|
5
|
-
> Converts between any two compatible document formats through a shared content/layout pivot — docx, pptx, odt, odp, ods, odg, xlsx, and markdown all read into and build from the same `ContentDocument`/`LayoutDocument` model, with PDF simply the one format every variant can reach (docx/pptx/odt/odp/ods/odg/xlsx/markdown ⇄ PDF, fourteen pairs, all round-tripping both ways), plus
|
|
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 sixteen further cross-format bridges, eight pairs (odt⇄docx, odp⇄pptx, ods⇄xlsx, markdown⇄docx, markdown⇄odt — five same-variant pairs sharing a `ContentDocument` pivot directly; docx⇄pptx, odt⇄odp — two cross-variant pairs through a semantic transform; and xlsx⇄markdown — one pdf-composed pair routing through the PDF pivot internally) that bypass an explicit layout/reconstruction pass for the 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
|
|
|
@@ -106,7 +106,7 @@ Each accepts an optional `signal` (`AbortSignal`) and either a `onSubstitution`
|
|
|
106
106
|
|
|
107
107
|
Every X → PDF conversion additionally accepts `fonts` (extra `ProvidedFont` faces to make available) and `onFontSubstitution` (called once per requested family+weight+style that resolved to something else). Neither is needed for the common case: the conversion already extracts the **source document's own embedded fonts** and renders through them, so a docx or odt saved with font embedding turned on comes out in its real typeface at its real metrics with no caller involvement at all — see [Fonts](#fonts) below for the full resolution order.
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
Sixteen further cross-format bridges across eight pairs bypass an explicit layout/reconstruction pass. Five of those pairs are same-variant direct copies: `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".
|
|
110
110
|
|
|
111
111
|
A further pair, `xlsxToMarkdown`/`markdownToXlsx`, is the one exception to "both sides share a variant": xlsx (spreadsheet) and markdown (wordprocessing) share no `ContentDocument` variant, so this pair routes through PDF internally (`xlsxToPdf` + `pdfToMarkdown`; `markdownToPdf` + `pdfToXlsx`) rather than copying a pivot directly. It is consequently the single lossiest conversion in the package — two stacked lossy hops (a spreadsheet rendered to a PDF page, then that page reconstructed as wordprocessing text) — and exists as a last resort for a caller with xlsx bytes who wants text and cannot read the cells directly via `readXlsxContent`. The `DocumentConverter` port routes it like any other bridge, and `xlsxMarkdownCodec` is its no-options `z.codec()` pair.
|
|
112
112
|
|
|
@@ -134,7 +134,7 @@ const { document, diagnostics } = await converter.convert(
|
|
|
134
134
|
);
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
`DocumentFormat` includes `xlsx` and `markdown` alongside `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`odf`/`pdf` — ten members in total — xlsx because `createLocalDocumentConverter`'s `{ source, targetFormat }` contract already generalises past "targetFormat always means pdf" (xlsx has no PDF conversion of its own; markdown genuinely does, see `markdownToPdf`/`pdfToMarkdown` above). `odt`→`docx`, `docx`→`odt`, `odp`→`pptx`, `pptx`→`odp`, `ods`→`xlsx`, `xlsx`→`ods`, `markdown`→`docx`, `docx`→`markdown`, `markdown`→`odt`,
|
|
137
|
+
`DocumentFormat` includes `xlsx` and `markdown` alongside `docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`odf`/`pdf` — ten members in total — xlsx because `createLocalDocumentConverter`'s `{ source, targetFormat }` contract already generalises past "targetFormat always means pdf" (xlsx has no PDF conversion of its own; markdown genuinely does, see `markdownToPdf`/`pdfToMarkdown` above). `odt`→`docx`, `docx`→`odt`, `odp`→`pptx`, `pptx`→`odp`, `ods`→`xlsx`, `xlsx`→`ods`, `markdown`→`docx`, `docx`→`markdown`, `markdown`→`odt`, `odt`→`markdown`, `docx`→`pptx`, `pptx`→`docx`, `odt`→`odp`, `odp`→`odt`, `xlsx`→`markdown`, and `markdown`→`xlsx` are sixteen further entries in the same `conversions` list (eight pairs in total — ten same-variant direct copies, four cross-variant semantic transforms, two pdf-composed), routed to the sixteen bridge functions above with an empty `diagnostics` array. `DocumentFormat` itself is inferred from a real Zod schema, `DocumentFormatSchema`, rather than hand-written — both it and `DOCUMENT_FORMATS` (every member as a plain `readonly DocumentFormat[]`, derived from that same schema so it cannot drift out of sync) are exported, for a caller that wants to enumerate or validate against the full format set without constructing its own schema — a CLI's own usage-error text, or an MCP tool's JSON-schema `enum` input:
|
|
138
138
|
|
|
139
139
|
```ts
|
|
140
140
|
import { DOCUMENT_FORMATS, DocumentFormatSchema } from 'documents.js';
|
|
@@ -343,7 +343,7 @@ try {
|
|
|
343
343
|
}
|
|
344
344
|
```
|
|
345
345
|
|
|
346
|
-
`odmToPdf` is not one of the fourteen round-trip conversions or the
|
|
346
|
+
`odmToPdf` is not one of the fourteen round-trip conversions or the sixteen bridges above, has no `z.codec()` pair, and is not wired into the `DocumentConverter` port below — see Gotchas for why.
|
|
347
347
|
|
|
348
348
|
`.odb` (ODF database front-end) support: `readOdbTables` extracts every table an embedded database declares, and `odbToXlsx`/`odbToCsv` turn that straight into xlsx or CSV bytes. Every embedded storage shape LibreOffice's own two embedded engines can produce is supported, dispatched automatically from the package's own connection URL and, for HSQLDB, its own per-table storage shape and script format: a MEMORY/TEXT table's rows inline in `database/script` as ordinary TEXT-format SQL (Tier 1, `src/hsqldb/script.ts`), a CACHED table's rows in a separate binary page-cache file, `database/data` (Tier 2, `src/hsqldb/cache.ts`/`rowformat.ts` — LibreOffice's own embedded-HSQLDB default, see Architecture/Gotchas for the exact scope and version pinning), a Firebird database's own `database/firebird.fbk` part — LibreOffice's modern default embedded engine since 4.1, a genuine gbak logical-backup stream rather than a raw on-disk database file (Tier 3; see the Gotchas entry below for the empirical finding this rests on) — and HSQLDB's own whole-script BINARY (`hsqldb.script_format=1`) and COMPRESSED (`=3`) serialisations of `database/script` itself (Tier 4, `src/hsqldb/binary-script.ts`). A caller never needs to know which shape, engine, or script format a given `.odb` used:
|
|
349
349
|
|
|
@@ -559,6 +559,14 @@ import { extractSourceFontsForFormat } from 'documents.js';
|
|
|
559
559
|
const faces = extractSourceFontsForFormat('docx', docxBytes); // -> readonly ProvidedFont[], the same shape createDocumentFontRegistry consumes
|
|
560
560
|
```
|
|
561
561
|
|
|
562
|
+
`describeFontFace` is the standalone-file counterpart to `extractSourceFonts`/`extractSourceFontsForFormat` above: where those extract the faces a document already embeds, `describeFontFace` inspects an arbitrary standalone `.ttf`/`.otf` font file the caller holds and reports its `family`, `bold`, and `italic` — the same `FontFace` shape (owned by `document-schema.js`) `ProvidedFont` builds on. It is a re-export of pdf-codec's own `readFontFace`, throws `FontFaceParseError` (also re-exported) for bytes that are not a parseable sfnt font, and takes a `source` string used only in diagnostics:
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
import { describeFontFace } from 'documents.js';
|
|
566
|
+
|
|
567
|
+
const { family, bold, italic } = describeFontFace(fontBytes, 'BrandSans-Regular.ttf'); // -> FontFace (from document-schema.js), reused directly as a ProvidedFont's identity
|
|
568
|
+
```
|
|
569
|
+
|
|
562
570
|
## Architecture
|
|
563
571
|
|
|
564
572
|
The package is layered from generic primitives outward to the two conversion directions:
|
|
@@ -584,7 +592,7 @@ The package is layered from generic primitives outward to the two conversion dir
|
|
|
584
592
|
- **`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.
|
|
585
593
|
- **`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.
|
|
586
594
|
- **`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.
|
|
587
|
-
- **`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,
|
|
595
|
+
- **`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, sixteen functions across eight pairs: `odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown` (five same-variant direct-copy pairs), `docxToPptx`/`pptxToDocx`, `odtToOdp`/`odpToOdt` (two cross-variant semantic-transform pairs via `src/convert/variant-bridges.ts`), and `xlsxToMarkdown`/`markdownToXlsx` (one pdf-composed pair routing through the PDF pivot internally) — 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, `odbReportToDocx`/`odbReportToOdt`/`odbReportToPdf`, the last step dispatching `readOdbReportContent`'s own rendered `ContentDocument` to real bytes via `buildDocxPackage`/`buildOdtPackage`/`convertWordprocessingToLayout`+`writePdf` respectively — taking a `ContentDocument` rather than a `Package`, since a rendered report has no source package left to round-trip through — 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 sixteen 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 sixteen 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. `from-package.ts`'s `buildDocumentBytes` is the reverse of every conversion's own `onDocument`/`package` output above: a `DocumentPackage` → any `DocumentFormat`'s own bytes, dispatched through `src/codecs/registry.ts`'s `DOCUMENT_FORMAT_CODECS` (see the `src/codecs/` entry below) for every target except `'pdf'` (writes the package's own `LayoutDocument` half directly) and `'odf'` (rejected outright — no `ContentDocument`-to-odf builder exists).
|
|
588
596
|
- **`src/codecs/`** — `registry.ts`'s `DOCUMENT_FORMAT_CODECS`, every `DocumentFormat`'s own read/build capability expressed as data (a `ContentCodec`/`LayoutCodec` pair per format, both types imported from `document-schema.js`) rather than three independent per-function switch statements re-deriving the same "given a format, which reader/builder do I call" dispatch. A format's `content` entry wraps the identical `readXContent`/`buildXPackage` pair every ergonomic conversion in this package already uses for it (via `decodeDocumentPackage`/`encodeDocumentPackage`, below, for the raw-package half); `pdf`'s `layout` entry wraps `readPdf`/`writePdf` directly. xlsx has a real `content` entry too, wrapping `ooxml.js`'s own `readXlsxContent`/`buildXlsxPackage` — this does not contradict this README's own "not re-exported from this package's public surface" statement elsewhere (that is about `src/index.ts`'s own export list, still true: neither name is exported from there), only that this internal registry may call them directly. `odf` (a standalone formula document) has `content.read` but no `content.write`, since `odf.js` has no write path for a formula document at all. `readDocumentMetadata`/`setDocumentMetadata` (`src/metadata/`, below) and `buildDocumentBytes` (`src/convert/from-package.ts`, above) all dispatch through this one registry rather than each maintaining its own per-format switch — this is what let `setDocumentMetadata`/`buildDocumentBytes` stop rejecting xlsx once the registry gained a real xlsx codec, with no change needed at either call site beyond removing the special case.
|
|
589
597
|
- **`src/metadata/`** — cross-format metadata read/write, both dispatched through `DOCUMENT_FORMAT_CODECS` (above) rather than a hand-written per-format switch. `read.ts`'s `readDocumentMetadata` resolves a `LayoutMetadata` for any of the ten `DocumentFormat`s, with one deliberately-kept named exception: xlsx does **not** dispatch through the registry's own `content` codec at all, instead rendering through `xlsxToPdf` and reading the resulting PDF's own metadata, because a direct `readXlsxContent(...).metadata` and that PDF-preview path disagree on real fields (`createdIso`/`modifiedIso`/`producer`) — confirmed directly rather than assumed (`read.test.ts`'s own xlsx case), so switching xlsx onto the uniform path here would silently change what this function reports. `write.ts`'s `setDocumentMetadata` patches `title`/`author`/`subject`/`keywords` in place without converting format: a `pdf` source/target patches the parsed `LayoutDocument` directly, and every other `REBUILD_FORMATS` member (`docx`/`pptx`/`odt`/`odp`/`ods`/`odg`/`markdown`/`xlsx`) rebuilds a fresh package from that format's own `ContentDocument` via the registry's `content` codec — xlsx joined this set once the registry gained a real xlsx codec (`src/codecs/registry.ts`), so it is no longer rejected the way it once was; `odf` is still rejected outright in both directions (no write path back out at all).
|
|
590
598
|
- **`src/package-codec.ts`** — `decodeDocumentPackage`/`encodeDocumentPackage`/`decodeOdbPackage` (Usage above), the format-aware counterpart to `ooxml.js`'s/`odf.js`'s own `decodePackage`/`encodePackage`. Dispatches docx/pptx/xlsx through `ooxml.js`'s OPC codec and odt/odp/ods/odg/odf through `odf.js`'s ODF codec by a plain format-membership lookup, throwing `UnsupportedPackageFormatError` (a named class, matching this package's own "recognised but unsupported" convention — `OdbUnsupportedFormatError`, `UnsupportedFontSourceFormatError`) for `markdown`/`pdf`, neither of which has a raw-package concept at all. `decodeOdbPackage` decodes `.odb` bytes through the identical `odf.js` `decodePackage` regardless — `.odb` is at the raw-zip-container level an ordinary ODF package — but is deliberately kept out of `decodeDocumentPackage`'s own `DocumentFormat`-keyed dispatch, since `'odb'` is not, and cannot be, a `DocumentFormat` member (see the `.odb` Architecture/Gotchas entries below); there is no `encodeOdbPackage`, since nothing in this package's `.odb` support ever writes a new `.odb` file.
|
|
@@ -615,6 +623,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
615
623
|
- **Live views, not flatten-and-regenerate.** `src/edit/*`'s editor classes hold a reference directly into the real `Package`/`XmlElement` objects; saving is `encodePackage(pkg)`, nothing more. This is what makes "everything you didn't touch stays byte-faithful" a structural guarantee rather than a best effort.
|
|
616
624
|
- **A three-tier PDF-read failure policy** governs everything `readPdf` reports back through its own `PdfDiagnosticSink` — throw for a file that cannot be meaningfully processed at all, recover-with-diagnostic for something malformed but salvageable, degrade-with-diagnostic for an individual unsupported feature while the rest of the document still reads. This policy is pdf-codec's own convention now, applied consistently across every one of its read modules — see that package's own README for the full statement.
|
|
617
625
|
- **Conventional commits**, enforced via commitlint + husky, matching `ooxml.js`.
|
|
626
|
+
- **Worker-isomorphic runtime.** The runtime `src/` is typechecked against a web-only environment (`lib: ["ES2024", "WebWorker"]`, no `@types/node`) as an isomorphism gate, an `eslint` rule (`no-restricted-imports` on `node:*`, `no-restricted-globals` on `Buffer`) bans Node-only imports and globals in runtime src, and a dedicated `test:workers` suite (`vitest.workers.config.ts`, run via `@cloudflare/vitest-pool-workers`) proves the PDF-bypassing paths (`markdownToDocx`, the docx content reader/decoder, `encodeMarkdownText`/`decodeMarkdownText`) genuinely execute inside a `workerd` isolate with no Node-only API usage. The PDF pivot itself (`readPdf`/`writePdf` and every X-to-PDF conversion) depends on the heavier `pdf-codec` and is intentionally out of scope for the isolate check; the same runtime discipline is enforced inside each sibling package too.
|
|
618
627
|
|
|
619
628
|
## Gotchas and quirks
|
|
620
629
|
|
|
@@ -654,7 +663,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
|
|
|
654
663
|
- **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.
|
|
655
664
|
- **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.
|
|
656
665
|
- **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.
|
|
657
|
-
- **`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
|
|
666
|
+
- **`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 sixteen 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.
|
|
658
667
|
- **`.odb` as a whole has no `odbToPdf` of its own, and does not need one — but a rendered *Report* specifically now has real one-call wrappers to PDF, docx, and odt.** All three parts of rendering a Report are 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. `odbReportToPdf`/`odbReportToDocx`/`odbReportToOdt` (`src/convert/convert.ts`, Usage above) are exactly that composition, wrapped as the same one-call ergonomic shape every other conversion in this package has. What has genuinely NOT changed: a wrapper over `.odb` **as a whole** — `odbToPdf` in the "give me a PDF of this entire database front-end" sense — would still pick one of tables/queries/reports arbitrarily and imply `.odb` had a single natural output format, which it does not; that is why `.odb` stays out of `DocumentFormat` and the `DocumentConverter` port entirely, and why `odbReportToPdf` takes an already-rendered report `ContentDocument`, not raw `.odb` bytes, as its input. 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.
|
|
659
668
|
- **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.
|
|
660
669
|
- **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.
|
|
@@ -734,7 +743,7 @@ The prose below is authoritative; this table is a quick-reference summary of it,
|
|
|
734
743
|
| **markdown** | ~ | – | – | ~ | – | – | – | – | — | ~ |
|
|
735
744
|
| **pdf** | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | – | ✗✗ | — |
|
|
736
745
|
|
|
737
|
-
|
|
746
|
+
33 of the 90 possible directional pairs have a real, ergonomic conversion function; PDF is the one layout codec every content format but `odf` renders to and reconstructs from (it is not "the hub" — the `ContentDocument` and `LayoutDocument` pivots in `document-schema.js` are, and fourteen of the sixteen cross-format bridge functions already bypass PDF entirely for the seven pairs that share a content variant directly or through a semantic transform). `document-cli` and `document-mcp` add no conversion logic of their own, so this fidelity is identical across all three.
|
|
738
747
|
|
|
739
748
|
**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.
|
|
740
749
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "js.documents",
|
|
3
|
-
"version": "1.95.
|
|
3
|
+
"version": "1.95.3",
|
|
4
4
|
"description": "Bidirectional docx/pptx <-> PDF conversion and a read+write editable OOXML document model, built on ooxml.js and Zod 4 codecs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"byte-codec": "^1.0.4",
|
|
62
|
-
"document-schema.js": "^2.5.
|
|
62
|
+
"document-schema.js": "^2.5.2",
|
|
63
63
|
"fflate": "^0.8.3",
|
|
64
|
-
"markdown-codec": "^1.2.
|
|
65
|
-
"odf.js": "^2.4.
|
|
66
|
-
"ooxml.js": "^2.9.
|
|
67
|
-
"pdf-codec": "^2.1.
|
|
64
|
+
"markdown-codec": "^1.2.3",
|
|
65
|
+
"odf.js": "^2.4.23",
|
|
66
|
+
"ooxml.js": "^2.9.4",
|
|
67
|
+
"pdf-codec": "^2.1.2",
|
|
68
68
|
"zod": "^4.4.3"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|