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.