js.documents 1.98.2 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,21 @@ 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
+ A single generic entry point, `convertDocument`, sits behind every named function above and reaches every pair the composition engine can route — all 73 supported (source, target) combinations, including pairs no named function covers. The named functions (`docxToPdf`, `odtToDocx`, `markdownToPdf`, etc.) are thin one-line forwarders to it; they remain the ergonomic layer for a caller who wants a fixed pair and the editor's own autocomplete as the discovery mechanism, while `convertDocument` is the first-class entry point for a caller working from a runtime format pair — a CLI, an MCP tool, a caller enumerating the matrix. See [Architecture](#architecture)'s `src/convert/` entry for the composition engine itself (the plain-data primitive registry and minimum-cost pathfinder that decide which real hops run for a given pair).
110
+
111
+ ```ts
112
+ import { convertDocument } from 'documents.js';
113
+
114
+ // markdown -> pptx has no named function of its own: the composition engine routes it
115
+ // as one cross-variant transform hop (read wordprocessing, wordprocessingToPresentation, build pptx).
116
+ const pptxBytes = convertDocument('markdown', 'pptx', markdownBytes);
117
+
118
+ // Every option a named function accepts is accepted here too, threaded to whichever hop consumes it.
119
+ const odtBytes = convertDocument('docx', 'odt', docxBytes, { onMathDiagnostic: (d) => console.warn(d) });
120
+ ```
121
+
122
+ `convertDocument` throws `UnsupportedConversionError` (a named class, so a caller can branch on it) for any pair the composition engine cannot route — there is no silent fallback or best-effort approximation at this layer. `resolveCompositionPlan(source, target)` is exported too, for a caller that wants the resolved hop plan without running it — useful for surfacing "this will route through PDF" to a user before the conversion runs. The full pair list is exported through the `DocumentConverter` port's `conversions` field below.
123
+
109
124
  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
125
 
111
126
  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.
@@ -134,7 +149,7 @@ const { document, diagnostics } = await converter.convert(
134
149
  );
135
150
  ```
136
151
 
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:
152
+ `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). The port's `conversions` list is derived from `resolveCompositionPlan` (the composition pathfinder in `src/convert/composition.ts`) plus the `odf`→`pdf` special case 73 (source, target) pairs in total, every pair the pathfinder can route across the eight content formats and PDF, far beyond the sixteen named bridge functions above (which cover only the pairs the pathfinder routes as a single same-variant or cross-variant bridge hop; see `convertDocument` above and the [Architecture](#architecture)'s `src/convert/` entry). `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
153
 
139
154
  ```ts
140
155
  import { DOCUMENT_FORMATS, DocumentFormatSchema } from 'documents.js';
@@ -725,7 +740,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
725
740
  - **`md/table-cell-formatting-dropped`** and **`md/table-cell-multi-paragraph-joined`** — a GFM table cell's own run-level formatting beyond plain text, and a cell containing more than one paragraph (both reachable via `docxToMarkdown`/`odtToMarkdown`, since docx/odt table cells support both), are both lossy: GFM's own table-cell grammar has no multi-paragraph or rich-formatting representation to write back to.
726
741
  - **`buildMarkdownText` throws `MarkdownUnsupportedDocumentKindError` for a non-`'wordprocessing'` `ContentDocument`**, matching `buildDocxPackage`/`buildOdtPackage`'s own "throw outright for the wrong document kind" convention — markdown has no presentation/spreadsheet/drawing equivalent to render, so `docxToMarkdown`/`odtToMarkdown` never need a redundant guard of their own before calling it (see `src/markdown/write.ts`'s own module comment).
727
742
  - **`decodeMarkdownText` (`src/markdown/text.ts`) throws `MarkdownInvalidUtf8Error` for malformed UTF-8 input, rather than silently producing U+FFFD replacement characters.** This is the third place this exact invariant is enforced independently: `MarkdownBytesSchema` (both `documents.js`'s own local copy in `src/model/bytes.ts` and markdown-codec's own in that package's `src/codec.ts`) catches it at the schema-validation boundary (`z.decode(markdownPdfCodec, ...)`/`z.decode(markdownDocxCodec, ...)`/etc.), and `decodeMarkdownText` catches it again for `markdownToPdf`/`markdownToDocx`/`markdownToOdt`, which call `readMarkdownContent` directly on already-decoded bytes rather than through a schema.
728
- - **markdown was wired into the capability/path-resolver model (`src/convert/capability.ts`) as a genuine third `wordprocessing`-variant node, but the four markdown cross-format bridge functions are hand-written, not generically composed.** `resolveConversionPath` can, in principle, find a one-hop composed path for any pair sharing an intermediate node the identical mechanism that already lets it independently rediscover the hand-composed `xlsxToPdf`/`pdfToXlsx` route (`xlsx ods pdf`) but `createLocalDocumentConverter` (`src/convert/local.ts`) only ever executes a `'direct'` strategy, never a `'composed'` one. Wiring `markdown docx`/`markdown odt` into the `DocumentConverter` port therefore still required four real, callable, registered bridge functions (`markdownToDocx`/`docxToMarkdown`/`markdownToOdt`/`odtToMarkdown`, `src/convert/convert.ts`) added to `DIRECT_EDGES`, exactly as `xlsxToPdf`/`pdfToXlsx` needed hand composition despite the resolver's own theoretical reach — the resolver's composition ability describes what a caller *could* build by hand, not something the port executes automatically on their behalf.
743
+ - **The composition engine (`src/convert/composition.ts`) routes every conversion pair generically through a declarative primitive registry and a minimum-cost pathfinder.** The earlier hand-written `DIRECT_EDGES` list and `resolveConversionPath` resolver (which only matched a direct edge, never composing a multi-hop path) have been superseded: `resolveCompositionPlan` finds the minimum-cost route (same-variant bridge < cross-variant transform < via-PDF multi-hop) and `convertDocument` runs it. Adding a format or a transform to the registry yields every reachable pair automatically no per-pair wiring, no `DIRECT_EDGES` entry to add. The named functions (`docxToPdf`, `odtToDocx`, etc.) remain as thin forwarders to `convertDocument`; they are the ergonomic layer, not the routing mechanism.
729
744
 
730
745
  ## Fidelity
731
746
 
@@ -744,7 +759,7 @@ The prose below is authoritative; this table is a quick-reference summary of it,
744
759
  | **markdown** | ~ | – | ✗✗ | ~ | – | – | – | – | — | ~ |
745
760
  | **pdf** | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | – | ✗✗ | — |
746
761
 
747
- 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.
762
+ 73 of the 90 possible directional pairs are routable through the composition engine — every pair the pathfinder can resolve across the eight content formats and PDF (including cross-variant transform bridges and via-PDF multi-hop routes), plus the one `odf → pdf` special case. 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 the cross-variant transform bridges already bypass PDF entirely for the 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.
748
763
 
749
764
  **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.
750
765
 
@@ -135,6 +135,14 @@ const TRANSFORMS = {
135
135
  "presentation->wordprocessing": (doc) => {
136
136
  if (doc.kind !== "presentation") throw new Error("presentationToWordprocessing: expected a presentation ContentDocument");
137
137
  return require_convert_variant_bridges.presentationToWordprocessing(doc);
138
+ },
139
+ "drawing->presentation": (doc) => {
140
+ if (doc.kind !== "drawing") throw new Error("drawingToPresentation: expected a drawing ContentDocument");
141
+ return require_convert_variant_bridges.drawingToPresentation(doc);
142
+ },
143
+ "presentation->drawing": (doc) => {
144
+ if (doc.kind !== "presentation") throw new Error("presentationToDrawing: expected a presentation ContentDocument");
145
+ return require_convert_variant_bridges.presentationToDrawing(doc);
138
146
  }
139
147
  };
140
148
  const LAYOUT_ENGINES = {
@@ -21,7 +21,7 @@ import { throwIfAborted } from "../ports/abort.js";
21
21
  import { convertSpreadsheetToLayout } from "../layout/sheets.js";
22
22
  import { convertDrawingToLayout } from "../layout/drawing.js";
23
23
  import { reconstructDrawing, reconstructPresentation, reconstructSpreadsheet, reconstructWordprocessing } from "../layout/reconstruct.js";
24
- import { presentationToWordprocessing, wordprocessingToPresentation } from "./variant-bridges.js";
24
+ import { drawingToPresentation, presentationToDrawing, presentationToWordprocessing, wordprocessingToPresentation } from "./variant-bridges.js";
25
25
  import { UnsupportedConversionError } from "./capability.js";
26
26
  import { buildXlsxPackage, decodePackage, encodePackage, readXlsxContent } from "ooxml.js";
27
27
  import { DOCUMENT_PACKAGE_FORMAT_VERSION } from "document-schema.js";
@@ -134,6 +134,14 @@ const TRANSFORMS = {
134
134
  "presentation->wordprocessing": (doc) => {
135
135
  if (doc.kind !== "presentation") throw new Error("presentationToWordprocessing: expected a presentation ContentDocument");
136
136
  return presentationToWordprocessing(doc);
137
+ },
138
+ "drawing->presentation": (doc) => {
139
+ if (doc.kind !== "drawing") throw new Error("drawingToPresentation: expected a drawing ContentDocument");
140
+ return drawingToPresentation(doc);
141
+ },
142
+ "presentation->drawing": (doc) => {
143
+ if (doc.kind !== "presentation") throw new Error("presentationToDrawing: expected a presentation ContentDocument");
144
+ return presentationToDrawing(doc);
137
145
  }
138
146
  };
139
147
  const LAYOUT_ENGINES = {
@@ -69,6 +69,34 @@ function presentationToWordprocessing(doc) {
69
69
  sections: [section]
70
70
  };
71
71
  }
72
+ function drawingToPresentation(doc) {
73
+ const slides = doc.pages.map((page) => ({
74
+ size: page.size,
75
+ shapes: page.shapes,
76
+ notes: ""
77
+ }));
78
+ return {
79
+ kind: "presentation",
80
+ formatVersion: document_schema_js.CONTENT_FORMAT_VERSION,
81
+ metadata: doc.metadata,
82
+ slides
83
+ };
84
+ }
85
+ function presentationToDrawing(doc) {
86
+ const pages = doc.slides.map((slide) => ({
87
+ size: slide.size,
88
+ shapes: slide.shapes,
89
+ vectors: []
90
+ }));
91
+ return {
92
+ kind: "drawing",
93
+ formatVersion: document_schema_js.CONTENT_FORMAT_VERSION,
94
+ metadata: doc.metadata,
95
+ pages
96
+ };
97
+ }
72
98
  //#endregion
99
+ exports.drawingToPresentation = drawingToPresentation;
100
+ exports.presentationToDrawing = presentationToDrawing;
73
101
  exports.presentationToWordprocessing = presentationToWordprocessing;
74
102
  exports.wordprocessingToPresentation = wordprocessingToPresentation;
@@ -6,7 +6,12 @@ type WordprocessingContentDocument = Extract<ContentDocument, {
6
6
  type PresentationContentDocument = Extract<ContentDocument, {
7
7
  kind: 'presentation';
8
8
  }>;
9
+ type DrawingContentDocument = Extract<ContentDocument, {
10
+ kind: 'drawing';
11
+ }>;
9
12
  declare function wordprocessingToPresentation(doc: WordprocessingContentDocument): PresentationContentDocument;
10
13
  declare function presentationToWordprocessing(doc: PresentationContentDocument): WordprocessingContentDocument;
14
+ declare function drawingToPresentation(doc: DrawingContentDocument): PresentationContentDocument;
15
+ declare function presentationToDrawing(doc: PresentationContentDocument): DrawingContentDocument;
11
16
  //#endregion
12
- export { presentationToWordprocessing, wordprocessingToPresentation };
17
+ export { drawingToPresentation, presentationToDrawing, presentationToWordprocessing, wordprocessingToPresentation };
@@ -6,7 +6,12 @@ type WordprocessingContentDocument = Extract<ContentDocument, {
6
6
  type PresentationContentDocument = Extract<ContentDocument, {
7
7
  kind: 'presentation';
8
8
  }>;
9
+ type DrawingContentDocument = Extract<ContentDocument, {
10
+ kind: 'drawing';
11
+ }>;
9
12
  declare function wordprocessingToPresentation(doc: WordprocessingContentDocument): PresentationContentDocument;
10
13
  declare function presentationToWordprocessing(doc: PresentationContentDocument): WordprocessingContentDocument;
14
+ declare function drawingToPresentation(doc: DrawingContentDocument): PresentationContentDocument;
15
+ declare function presentationToDrawing(doc: PresentationContentDocument): DrawingContentDocument;
11
16
  //#endregion
12
- export { presentationToWordprocessing, wordprocessingToPresentation };
17
+ export { drawingToPresentation, presentationToDrawing, presentationToWordprocessing, wordprocessingToPresentation };
@@ -68,5 +68,31 @@ function presentationToWordprocessing(doc) {
68
68
  sections: [section]
69
69
  };
70
70
  }
71
+ function drawingToPresentation(doc) {
72
+ const slides = doc.pages.map((page) => ({
73
+ size: page.size,
74
+ shapes: page.shapes,
75
+ notes: ""
76
+ }));
77
+ return {
78
+ kind: "presentation",
79
+ formatVersion: CONTENT_FORMAT_VERSION,
80
+ metadata: doc.metadata,
81
+ slides
82
+ };
83
+ }
84
+ function presentationToDrawing(doc) {
85
+ const pages = doc.slides.map((slide) => ({
86
+ size: slide.size,
87
+ shapes: slide.shapes,
88
+ vectors: []
89
+ }));
90
+ return {
91
+ kind: "drawing",
92
+ formatVersion: CONTENT_FORMAT_VERSION,
93
+ metadata: doc.metadata,
94
+ pages
95
+ };
96
+ }
71
97
  //#endregion
72
- export { presentationToWordprocessing, wordprocessingToPresentation };
98
+ export { drawingToPresentation, presentationToDrawing, presentationToWordprocessing, wordprocessingToPresentation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js.documents",
3
- "version": "1.98.2",
3
+ "version": "1.99.0",
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": {
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "devDependencies": {
74
74
  "@arethetypeswrong/cli": "^0.18.5",
75
- "@cloudflare/vitest-pool-workers": "^0.20.2",
75
+ "@cloudflare/vitest-pool-workers": "^0.20.3",
76
76
  "@commitlint/cli": "^21.2.1",
77
77
  "@commitlint/config-conventional": "^21.2.0",
78
78
  "@eslint/js": "^10.0.1",