diff --git a/README.md b/README.md
index ce7d4af..3b917b0 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,7 @@ document-cli convert report.docx - --to pdf > report.pdf
**`formats`** — lists every `source -> target` pair the commands above support (`--json` for a machine-readable array), plus a pointer to the commands not on that list because they don't fit the source/target shape (`odm-to-pdf`, `odb-to-csv`, `odb-to-xlsx`, `odb-tables`, `odb-forms`, `odb-reports`, `pdf-inspect`, `from-package`, `fonts`, `docx-extras`, `metadata`, `set-metadata`).
-**`from-package [output]`** — reads back a `DocumentPackage` JSON file a previous conversion wrote via `--dump-package` (below) and exports it to a real target format, closing the round trip `--dump-package` otherwise has no return path for. Target resolution matches `convert`: an output path's own extension, or `--to ` when it doesn't have one. `pdf` writes the package's own `layout` half directly (`writePdf`); every other format builds a fresh package from the `content` half through the identical `buildXPackage` function the matching `pdf-to-X`/bridge conversion already uses — `xlsx` included, via `ooxml.js`'s own `buildXlsxPackage`. `odf` is the one target rejected outright, since a standalone formula document has no write path from `ContentDocument` at all. Only a file genuinely written by `--dump-package` round-trips here; anything else fails with its `$schema` mismatch named:
+**`from-package [output]`** — reads back a `DocumentPackage` JSON file a previous conversion wrote via `--dump-package` (below) and exports it to a real target format, closing the round trip `--dump-package` otherwise has no return path for. Target resolution matches `convert`: an output path's own extension, or `--to ` when it doesn't have one. `pdf` rebuilds the layout from the package's own recorded positions — each content node's `frames` plus the package's `pages` geometry (`layoutDocumentFromPackage` → `writePdf`; a package no longer carries a separate `layout` half at all); every other format builds a fresh package from the `content` half through the identical `buildXPackage` function the matching `pdf-to-X`/bridge conversion already uses — `xlsx` included, via `ooxml.js`'s own `buildXlsxPackage`. `odf` is the one target rejected outright, since a standalone formula document has no write path from `ContentDocument` at all. Only a file genuinely written by a current `--dump-package` round-trips here; anything else fails with its `$schema` mismatch named, and a dump from the documents.js 1.x `formatVersion` 1 shape (content plus a separate `layout` half) is rejected with an error naming the version change rather than silently accepted:
```sh
document-cli docx-to-pdf report.docx report.pdf --dump-package report.package.json
@@ -178,7 +178,7 @@ The explicit conversions, `convert`, `odm-to-pdf`, `odb-to-xlsx`, `odb-to-csv`,
| `-q, --quiet` | Suppress diagnostic and summary output (the JSON result-summary line still prints in `--json` mode, so a script consuming NDJSON always gets a terminating record). |
| `--verbose` | Include a full stack trace in the error line when the run fails. |
-`--dump-package ` is one flag further, registered only on the explicit conversions and `convert` — it writes the intermediate `DocumentPackage` (content + layout) that conversion built to a JSON file, tagged with its own `$schema` so `from-package` (above) can read it back in. Every conversion populates one except `odf-to-pdf`, which accepts but never invokes its own `onDocument` callback (a standalone formula document has no `ContentDocument`/`LayoutDocument` pivot behind it); the sixteen PDF-bypassing bridges (the ten same-variant pairs `odt-to-docx`/`docx-to-odt`, `odp-to-pptx`/`pptx-to-odp`, `ods-to-xlsx`/`xlsx-to-ods`, `markdown-to-docx`/`docx-to-markdown`, `markdown-to-odt`/`odt-to-markdown`, plus the six cross-variant pairs `docx-to-pptx`/`pptx-to-docx`, `odt-to-odp`/`odp-to-odt`, `xlsx-to-markdown`/`markdown-to-xlsx`) populate one too, just with `layout` always `undefined`, since a bridge never runs a layout engine. `odm-to-pdf`/`odb-*`/`set-metadata` don't expose the flag at all, since none of them goes through `DocumentConverter.convert` in the first place. `odb-tables`, `odb-forms`, `odb-reports`, `fonts`, `docx-extras`, `metadata`, `formats`, and `pdf-inspect` each take only their own `--json` (plus `pdf-inspect`'s own `--full`); `odb-query` takes `--sql `/`--query ` (mutually exclusive) alongside its own `--json`, with none of the shared flags above since it only reads and writes nothing; `from-package` and `set-metadata` each take `--to ` alongside the shared flags in this table; `odb-render-report` takes `--report ` and `--to ` alongside the shared flags and the font flags below; `tui` takes no flags at all, only an optional positional file.
+`--dump-package ` is one flag further, registered only on the explicit conversions and `convert` — it writes the intermediate `DocumentPackage` (content carrying its own per-node rendered `frames`, plus the `pages` geometry those frames index into) that conversion built to a JSON file, tagged with its own `$schema` so `from-package` (above) can read it back in. Every conversion populates one, `odf-to-pdf` included — its dump carries a `formula`-kind content and the one A4 page it renders (the formula's glyphs travel through the PDF writer's own positioned-formula channel rather than as frame-stamped page content); the sixteen PDF-bypassing bridges (the ten same-variant pairs `odt-to-docx`/`docx-to-odt`, `odp-to-pptx`/`pptx-to-odp`, `ods-to-xlsx`/`xlsx-to-ods`, `markdown-to-docx`/`docx-to-markdown`, `markdown-to-odt`/`odt-to-markdown`, plus the six cross-variant pairs `docx-to-pptx`/`pptx-to-docx`, `odt-to-odp`/`odp-to-odt`, `xlsx-to-markdown`/`markdown-to-xlsx`) populate one too, just with `pages` always absent, since a bridge never runs a layout engine. `odm-to-pdf`/`odb-*`/`set-metadata` don't expose the flag at all, since none of them goes through `DocumentConverter.convert` in the first place. `odb-tables`, `odb-forms`, `odb-reports`, `fonts`, `docx-extras`, `metadata`, `formats`, and `pdf-inspect` each take only their own `--json` (plus `pdf-inspect`'s own `--full`); `odb-query` takes `--sql `/`--query ` (mutually exclusive) alongside its own `--json`, with none of the shared flags above since it only reads and writes nothing; `from-package` and `set-metadata` each take `--to ` alongside the shared flags in this table; `odb-render-report` takes `--report ` and `--to ` alongside the shared flags and the font flags below; `tui` takes no flags at all, only an optional positional file.
### Real fonts
diff --git a/package.json b/package.json
index 516daf8..0487a42 100644
--- a/package.json
+++ b/package.json
@@ -88,7 +88,7 @@
"packageManager": "pnpm@11.6.0",
"dependencies": {
"commander": "^15.0.0",
- "documents.js": "^1.102.1",
+ "documents.js": "^2.0.0",
"ink": "^7.1.1",
"ink-text-input": "^6.0.0",
"react": "^19.2.8"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd34089..9ae96bf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,8 +12,8 @@ importers:
specifier: ^15.0.0
version: 15.0.0
documents.js:
- specifier: ^1.102.1
- version: 1.102.1
+ specifier: ^2.0.0
+ version: 2.0.0
ink:
specifier: ^7.1.1
version: 7.1.1(@types/react@19.2.18)(react@19.2.8)
@@ -1728,10 +1728,6 @@ packages:
resolution: {integrity: sha512-AQDb6GEEHcQFBLbn06TR7yJgQY3tDvjmvF0FCSrw+Eyk9/ZOpMWdc/SUKysjr+pZZWdWuc6/kl2G+gu6iZjj8Q==}
engines: {node: '>=20'}
- document-schema.js@2.7.17:
- resolution: {integrity: sha512-z+1XNFToTrERlxIgoeyFb4Rw5b2nP+EgFOMH/CQfIjo0V9+To0LG6jJe2JUbTUfaggvbBRAEfDAHDMPVcBPROQ==}
- engines: {node: '>=20'}
-
document-schema.js@3.1.0:
resolution: {integrity: sha512-Qj+0yx3lkorssXCAOIhLtrPKdJEMaB3E1QyloXWJt2H3NM8+ed7w6TyjWHeiaCnl57eGoAuUDWFGgwYDm/CGqA==}
engines: {node: '>=20'}
@@ -1740,8 +1736,8 @@ packages:
resolution: {integrity: sha512-XDu/+fo56WrXrcR3f16xH5lYEQX+3b4W6kJELRNFwrrWxOqhQBQepXMkCi+niSrgCEIcfaC1IeaGPlZ8oj5gSw==}
engines: {node: '>=20'}
- documents.js@1.102.1:
- resolution: {integrity: sha512-sWQHJreL5i6PftY5qYQn79+TbqPFzr83hVzh4osH0kgY/didT+83Wf6D4i4lIM+eeFWDPLtlvW5+mlMrKgWC8A==}
+ documents.js@2.0.0:
+ resolution: {integrity: sha512-ryI83OYP6JimwjrWsU7DEV7fGZuLy2QDyEoO6WRZW+ZFPHtJh+EQ1Q3wtaHxnW9gHizjumh00rjtlxc2rx9HXQ==}
engines: {node: '>=20'}
hasBin: true
@@ -2432,8 +2428,8 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
- markdown-codec@1.4.2:
- resolution: {integrity: sha512-SfY0hXE5GdgS+GuiUVOf3v4SeD9XxIfT6ZBcJfLVY3Qa9Q3TfBzvNAVstE30g9CrW8vykvcz1xEACEYOos11Pw==}
+ markdown-codec@2.0.0:
+ resolution: {integrity: sha512-vbNj6KPo4hJxA7EAzqIiTAx9x3hF1xmFHFEJm/VVptlzuy8KfYYp3wmq7ZkWPbQ47/pIvANXfiA3Zc5NUvVitQ==}
engines: {node: '>=20'}
marked-terminal@7.3.0:
@@ -2618,10 +2614,6 @@ packages:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
- odf.js@2.7.23:
- resolution: {integrity: sha512-qmee21eZYO0WRBjmwUL72ZaggZ/NvkQ1LNkgK+DAn/9sV4PKtjOAuIb4uScL2jSJJ1a8S3afMP32fV+1eGDngw==}
- engines: {node: '>=20'}
-
odf.js@3.0.1:
resolution: {integrity: sha512-gVTXbQc6oHy1y1m7VxXXCQFU0HkTYoTSg7j/CH2Q+hSPulzOV4SCyPucS4CilSpmruckRONILpIMCi8DX4bFrA==}
engines: {node: '>=20'}
@@ -2634,8 +2626,8 @@ packages:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
- ooxml.js@2.11.32:
- resolution: {integrity: sha512-SwSn/CkRANEeaxSMyvD3s1EMrpXFvBTWF9c/uQo+A5sFCNGVYYPpBjn1VT+rsw9EE5/WGXzRvOelkjDklT9Itw==}
+ ooxml.js@2.16.0:
+ resolution: {integrity: sha512-NLoTLONWlZ09ZdUURCfF+oC+yZ2+A7VO+qDYy6vGbhcOVCRka8W95TuESNKEO+737JRDRSI527IBv82YS1EHTg==}
engines: {node: '>=20'}
optionator@0.9.4:
@@ -4999,10 +4991,6 @@ snapshots:
dependencies:
zod: 4.4.3
- document-schema.js@2.7.17:
- dependencies:
- zod: 4.4.3
-
document-schema.js@3.1.0:
dependencies:
zod: 4.4.3
@@ -5011,14 +4999,14 @@ snapshots:
dependencies:
zod: 4.4.3
- documents.js@1.102.1:
+ documents.js@2.0.0:
dependencies:
byte-codec: 1.1.9
- document-schema.js: 2.7.17
+ document-schema.js: 3.2.0
fflate: 0.8.3
- markdown-codec: 1.4.2
- odf.js: 2.7.23
- ooxml.js: 2.11.32
+ markdown-codec: 2.0.0
+ odf.js: 3.0.1
+ ooxml.js: 2.16.0
pdf-codec: 2.2.35
zod: 4.4.3
@@ -5698,9 +5686,9 @@ snapshots:
dependencies:
semver: 7.8.5
- markdown-codec@1.4.2:
+ markdown-codec@2.0.0:
dependencies:
- document-schema.js: 2.7.17
+ document-schema.js: 3.2.0
zod: 4.4.3
marked-terminal@7.3.0(marked@15.0.12):
@@ -5818,13 +5806,6 @@ snapshots:
obug@2.1.4: {}
- odf.js@2.7.23:
- dependencies:
- document-schema.js: 2.7.17
- fast-xml-parser: 5.11.0
- fflate: 0.8.3
- zod: 4.4.3
-
odf.js@3.0.1:
dependencies:
document-schema.js: 3.1.0
@@ -5840,9 +5821,9 @@ snapshots:
dependencies:
mimic-fn: 4.0.0
- ooxml.js@2.11.32:
+ ooxml.js@2.16.0:
dependencies:
- document-schema.js: 2.7.17
+ document-schema.js: 3.2.0
fast-xml-parser: 5.11.0
fflate: 0.8.3
zod: 4.4.3
diff --git a/src/commands/from-package.test.ts b/src/commands/from-package.test.ts
index 02cb636..f4fe442 100644
--- a/src/commands/from-package.test.ts
+++ b/src/commands/from-package.test.ts
@@ -70,6 +70,11 @@ describe('from-package', () => {
const dumpedText = await readFile(packagePath, 'utf-8');
expect(dumpedText).toContain('"$schema"');
expect(dumpedText).toContain('document-package.schema.json');
+ // The dump carries the formatVersion 2 fused shape -- content nodes with their own rendered frames plus page sizes -- and never the old separate layout half.
+ expect(dumpedText).toContain('"formatVersion": 2');
+ expect(dumpedText).toContain('"pages"');
+ expect(dumpedText).toContain('"frames"');
+ expect(dumpedText).not.toContain('"layout"');
const fromPackageRun = await runCli(['from-package', packagePath, rebuiltPath]);
expect(fromPackageRun.exitCode).toBe(EXIT_SUCCESS);
@@ -79,6 +84,24 @@ describe('from-package', () => {
expect(paragraphs.some((paragraph) => paragraph.text === PARAGRAPH_TEXT)).toBe(true);
});
+ it("rebuilds a pdf from a dumped package's own frames and page sizes", async () => {
+ const packagePath = join(workspace, 'dumped-for-pdf.package.json');
+ const rebuiltPdfPath = join(workspace, 'rebuilt.pdf');
+
+ // docx-to-pdf is the conversion whose dump carries a fully frame-stamped content tree, so its package is the honest input for the pdf target's rebuild-from-frames path (documents.js's layoutDocumentFromPackage -> writePdf, replacing the old stored-layout-half read).
+ const dumpRun = await runCli(['docx-to-pdf', join(workspace, 'source.docx'), join(workspace, 'source-for-pdf.pdf'), '--dump-package', packagePath]);
+ expect(dumpRun.exitCode).toBe(EXIT_SUCCESS);
+
+ const fromPackageRun = await runCli(['from-package', packagePath, rebuiltPdfPath]);
+ expect(fromPackageRun.exitCode).toBe(EXIT_SUCCESS);
+
+ const rebuiltPdfBytes = new Uint8Array(await readFile(rebuiltPdfPath));
+ expect(rebuiltPdfBytes.byteLength).toBeGreaterThan(0);
+ // The minimal honest check on the rebuilt pdf itself: a real PDF file, not an empty or mislabelled write.
+ expect(rebuiltPdfBytes[0]).toBe(0x25); // '%'
+ expect(new TextDecoder().decode(rebuiltPdfBytes.subarray(0, 5))).toBe('%PDF-');
+ });
+
it('infers the target format from the output extension, matching --to explicitly given', async () => {
const packagePath = join(workspace, 'dumped-for-markdown.package.json');
await runCli(['docx-to-pdf', join(workspace, 'source.docx'), join(workspace, 'unused.pdf'), '--dump-package', packagePath]);
@@ -147,9 +170,9 @@ describe('from-package', () => {
expect(stderr).toContain('no recognised $schema');
});
- it("rejects 'pdf' as the target when the dumped package came from a bridge conversion with no layout half at all", async () => {
+ it("rejects 'pdf' as the target when the dumped package came from a bridge conversion with no page sizes at all", async () => {
const packagePath = join(workspace, 'dumped-from-bridge.package.json');
- // A bridge conversion (odt-to-docx) runs no layout engine at all -- its own dumped package always carries a real ContentDocument but layout left undefined (see documents.js's own DocumentBridgeOptions.onDocument comment), unlike every docx-to-pdf/pdf-to-docx dump the other tests in this file use.
+ // A bridge conversion (odt-to-docx) runs no layout engine at all -- its own dumped package always carries a real ContentDocument but pages left undefined (see documents.js's own DocumentBridgeOptions.onDocument comment), unlike every docx-to-pdf/pdf-to-docx dump the other tests in this file use.
await runCli(['docx-to-odt', join(workspace, 'source.docx'), join(workspace, 'source.odt')]);
const bridgeRun = await runCli(['odt-to-docx', join(workspace, 'source.odt'), join(workspace, 'unused-bridge.docx'), '--dump-package', packagePath]);
expect(bridgeRun.exitCode).toBe(EXIT_SUCCESS);
@@ -157,6 +180,30 @@ describe('from-package', () => {
const { exitCode, stderr } = await runCli(['from-package', packagePath, join(workspace, 'never-written4.pdf')]);
expect(exitCode).not.toBe(EXIT_SUCCESS);
- expect(stderr).toContain('this DocumentPackage has no layout');
+ expect(stderr).toContain('this DocumentPackage has no pages');
+ });
+
+ it("rejects an old formatVersion 1 dump (a documents.js 1.x --dump-package file) with an error naming the version change", async () => {
+ const oldDumpPath = join(workspace, 'old-shape.package.json');
+ // A user-provided old dump: the exact shape documents.js 1.x wrote via --dump-package -- $schema-tagged, formatVersion 1, content plus a separate layout half. Hand-built here rather than generated, since nothing in this tree can still produce that shape; the $schema URI is version-agnostic by design (documentSchemaKindOf matches the file stem alone), so an old dump still identifies as a DocumentPackage and would otherwise fail DocumentPackageSchema.parse with a raw ZodError wall naming neither the version change nor the remedy.
+ const oldDump = {
+ $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@1.9.9/schemas/document-package.schema.json',
+ formatVersion: 1,
+ content: {
+ kind: 'wordprocessing',
+ formatVersion: 2,
+ metadata: {},
+ sections: [{ blocks: [{ kind: 'paragraph', styleId: 'Heading1', runs: [{ text: PARAGRAPH_TEXT }] }] }],
+ },
+ layout: { formatVersion: 1, metadata: {}, images: {}, pages: [{ widthPt: 595, heightPt: 842, items: [] }] },
+ };
+ await writeFile(oldDumpPath, JSON.stringify(oldDump, undefined, 2));
+
+ const { exitCode, stderr } = await runCli(['from-package', oldDumpPath, join(workspace, 'never-written5.docx')]);
+
+ expect(exitCode).not.toBe(EXIT_SUCCESS);
+ expect(stderr).toContain('formatVersion 1');
+ expect(stderr).toContain('formatVersion 2');
+ expect(stderr).toContain('--dump-package');
});
});
diff --git a/src/commands/from-package.ts b/src/commands/from-package.ts
index 047598a..f802e2d 100644
--- a/src/commands/from-package.ts
+++ b/src/commands/from-package.ts
@@ -1,5 +1,5 @@
import { type Command } from 'commander';
-import { UnrecognizedDocumentSchemaError, buildDocumentBytes, documentFromJson } from 'documents.js';
+import { UnrecognizedDocumentSchemaError, buildDocumentBytes, documentFromJson, documentSchemaKindOf } from 'documents.js';
import { createRuntimeSignal } from '../runtime/abort';
import { createDiagnosticReporter } from '../runtime/diagnostics';
import { EXIT_INPUT_ERROR, EXIT_SUCCESS, EXIT_USAGE_ERROR, mapErrorToExit } from '../runtime/exit-codes';
@@ -11,6 +11,13 @@ interface FromPackageCliOptions extends ConversionCliFlags {
readonly to?: string;
}
+// Narrowing guard for the old-dump check below, built on `in` rather than an index-signature cast -- formatVersion 1 is the only package version that ever carried a separate layout half, so it alone identifies a pre-documents.js-2.0 dump regardless of whatever the current version constant is.
+function isFormatVersionOneRecord(value: unknown): boolean {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
+ if (!('formatVersion' in value)) return false;
+ return value.formatVersion === 1;
+}
+
async function runFromPackage(input: string, output: string | undefined, options: FromPackageCliOptions): Promise {
const command = 'from-package';
@@ -40,6 +47,12 @@ async function runFromPackage(input: string, output: string | undefined, options
return EXIT_INPUT_ERROR;
}
+ // A dump from formatVersion 1 (documents.js 1.x's package shape: content plus a separate layout half) identifies as a DocumentPackage by its $schema but then fails DocumentPackageSchema.parse with a raw ZodError -- a wall of JSON issues naming neither the version change nor the remedy. Intercepted here for the one wrong-version case a real user hits after upgrading, so the error names the shape change and how to get a current dump; every other structurally invalid tagged package keeps documentFromJson's own ZodError.
+ if (documentSchemaKindOf(parsed) === 'DocumentPackage' && isFormatVersionOneRecord(parsed)) {
+ process.stderr.write(`[${command}] '${input}' is a DocumentPackage dump at formatVersion 1 (the documents.js 1.x shape: content plus a separate layout half) -- documents.js 2.0.0 replaced it with formatVersion 2 (content carrying its own rendered frames, plus page sizes); re-run the source conversion with --dump-package to write a current dump\n`);
+ return EXIT_INPUT_ERROR;
+ }
+
const result = documentFromJson(parsed);
if (result.kind !== 'DocumentPackage') {
process.stderr.write(`[${command}] '${input}' is a ${result.kind}, not a DocumentPackage -- only a file written by --dump-package can be read back by this command\n`);
diff --git a/src/commands/options.ts b/src/commands/options.ts
index 72d0938..154164b 100644
--- a/src/commands/options.ts
+++ b/src/commands/options.ts
@@ -22,7 +22,7 @@ export function addVerboseOption(command: Command): Command {
}
export function addDumpPackageOption(command: Command): Command {
- return command.option('--dump-package ', 'write the intermediate DocumentPackage (content + layout) this conversion built to a JSON file');
+ return command.option('--dump-package ', 'write the intermediate DocumentPackage (content with its per-node layout frames + page sizes) this conversion built to a JSON file');
}
// Accumulates repeated --font-file flags into a list, in the order given -- which is also the order documents.js's own FontRegistry resolves them in, so an earlier --font-file wins a family+weight+slope tie against a later one.