Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input> [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 <format>` 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 <input> [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 <format>` 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
Expand Down Expand Up @@ -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 <file>` 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 <text>`/`--query <savedName>` (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 <format>` alongside the shared flags in this table; `odb-render-report` takes `--report <name>` and `--to <format>` alongside the shared flags and the font flags below; `tui` takes no flags at all, only an optional positional file.
`--dump-package <file>` 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 <text>`/`--query <savedName>` (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 <format>` alongside the shared flags in this table; `odb-render-report` takes `--report <name>` and `--to <format>` alongside the shared flags and the font flags below; `tui` takes no flags at all, only an optional positional file.

### Real fonts

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 17 additions & 36 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 50 additions & 3 deletions src/commands/from-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]);
Expand Down Expand Up @@ -147,16 +170,40 @@ 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);

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');
});
});
15 changes: 14 additions & 1 deletion src/commands/from-package.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<number> {
const command = 'from-package';

Expand Down Expand Up @@ -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`);
Expand Down
Loading
Loading