diff --git a/README.md b/README.md
index 8cce3af..eb9d3c0 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/ExaDev/document-cli) [](https://www.npmjs.com/package/document-cli) [](https://github.com/ExaDev/document-cli/releases/latest) [](https://github.com/ExaDev/document-cli/actions)
-> A command-line interface and an interactive terminal (Ink) app for [`documents.js`](https://github.com/ExaDev/documents.js): every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/csv/svg/markdown conversion, bridge, and editor documents.js exposes, wired up as a scriptable subcommand or a full-screen terminal editor. Installs as either `document-cli` or `doculi`.
+> A command-line interface and an interactive terminal (Ink) app for [`documents.js`](https://github.com/ExaDev/documents.js): every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/csv/svg/markdown conversion, bridge, and editor documents.js exposes, plus the outline projection [`document-outline.js`](https://github.com/ExaDev/document-outline.js) builds over any readable document, wired up as a scriptable subcommand or a full-screen terminal editor. Installs as either `document-cli` or `doculi`.
`document-cli` adds no conversion or editing logic of its own — it is a dispatch layer over `documents.js`'s existing conversion functions, `DocumentConverter` port, live-view editors, and `.odb`/PDF readers. What it adds is two ways to drive them without writing TypeScript: a scriptable, Unix-shaped CLI (stdin/stdout, exit codes, `--json` diagnostics) for pipelines, and a full-screen Ink terminal app for browsing and editing a document interactively.
@@ -15,6 +15,7 @@ graph TD
mdcodec("markdown-codec")
bytecodec("byte-codec")
documents("documents.js")
+ outline("document-outline.js")
mcp("document-mcp")
cli("document-cli")
@@ -23,6 +24,7 @@ graph TD
schema --> pdfcodec
schema --> mdcodec
schema --> documents
+ schema --> outline
ooxml --> documents
odf --> documents
pdfcodec --> documents
@@ -32,6 +34,7 @@ graph TD
documents --> mcp
pdfcodec --> mcp
documents --> cli
+ outline --> cli
odf --> cli
pdfcodec --> cli
@@ -42,6 +45,7 @@ graph TD
click mdcodec "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/ExaDev/markdown-codec" "markdown-codec"
click bytecodec "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/ExaDev/byte-codec" "byte-codec"
click documents "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/ExaDev/documents.js" "documents.js"
+ click outline "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/ExaDev/document-outline.js" "document-outline.js"
click mcp "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/ExaDev/document-mcp" "document-mcp"
click cli "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/ExaDev/document-cli" "document-cli"
@@ -93,7 +97,7 @@ document-cli convert report.docx report.pdf
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`).
+**`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`, `outline`).
**`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. The package this command reads is the tree form since documents.js 3.0.0 — content grouped one group per container (section, slide, sheet, draw page) under `children`, with the minted `styles` table at the root — flattened once at this boundary, exactly as documents.js's own builders do. `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 flattened content 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; a `csv` or `svg` target is written through the identical `buildCsvText`/`buildSvgText` functions the codec registry's own write wrappers call, so the `--delimiter`/`--sheet`/`--page` selection flags below reach it exactly as they do a live conversion. Only a file genuinely written by a current `--dump-package` round-trips here: the `$schema` URI a dump carries pins the document-schema.js release that wrote it, and any pre-4.0.0 dump — the flat `{ formatVersion, content, pages }` envelope documents.js 1.x/2.x wrote, whatever its `formatVersion` — is rejected with an error naming the pinned release, the flat-to-tree change, and the remedy, rather than a raw validation wall. A layout-document dump (a document-schema.js 3.x layout artefact, e.g. an old `pdf-inspect --full` output) gets its own pointer: that schema moved to pdf-codec.
@@ -164,6 +168,13 @@ document-cli set-metadata report.docx report.docx --set-title "Q3 Report" --set-
document-cli set-metadata report.docx report.odt --set-keywords "draft,internal"
```
+**`outline `** — prints a document's outline: the table-of-contents projection over the tree-form `DocumentPackage` a conversion of that document builds — headings nested by heading level, list items nested under their heading or slide, one group per slide (labelled `Slide N`), sheet (labelled with the sheet's own name), or draw page (labelled `Page N`) — rendered as indented text, two spaces per nesting depth. Leaves render their own text (a paragraph's runs, a table's cell text, an image's alt text, a formula's LaTeX) or their kind in brackets when they carry none (`[page-break]`, `[vector]`, `[embeddedObject]`). Works on any of the twelve readable formats: the command runs the cheapest conversion that still leaves a package behind — a PDF-bypassing bridge to a sibling format, so no layout engine runs (`docx`→`odt`, `markdown`→`docx`, `ods`→`xlsx`, ...); `pdf` reconstructs through `pdf-to-docx` and `odf` renders through `odf-to-pdf`, the one conversion each of those formats has — and projects that package's tree through [`document-outline.js`](https://github.com/ExaDev/document-outline.js)'s own `buildOutline`, this command being that package's first real consumer. `--json` emits the outline tree itself — groups as `{ text, level, children }`, leaves as the package leaves they are — rather than a CLI-private shape; the internal conversion's diagnostics (a pdf reconstruction's parse warnings, say) reach stderr exactly as they would on the matching conversion command. `--from ` names the source format when the input path carries no recognised extension to infer it from — the only way to outline a document read from stdin (`-`), which otherwise has no extension to read at all. Heading nesting depends on the source document actually carrying a heading-level signal on disk (`w:outlineLvl` for docx, `text:outline-level` for odt) — present in anything authored by Word or LibreOffice, and always present for a markdown source, whose own reader parses `#`/`##` headings directly; a docx or odt this ecosystem's own writers produce does not currently stamp that signal even though the paragraph still carries its `Heading1`/`Heading2`-style reference, so outlining a docx/odt generated by this CLI's own conversions loses heading nesting specifically (list nesting and slide/sheet/page grouping are unaffected):
+
+```sh
+document-cli outline report.docx
+document-cli outline slides.pptx --json
+```
+
**`tui [file]`** — launches the interactive terminal app; see [The TUI](#the-tui) below.
### Shared flags
@@ -180,7 +191,7 @@ The explicit conversions, `convert`, `odm-to-pdf`, `odb-to-xlsx`, `odb-to-csv`,
Three further flags select what a csv or svg edge of a conversion works on, threaded straight into documents.js's own `ConversionOptions`: `--delimiter ` (the field delimiter a csv source reads with, or a csv target writes with — default `,`), `--sheet ` (the sheet a csv target writes, required when the source document has more than one), and `--page ` (the 0-based page an svg target draws, required when the source document has more than one). On the explicit commands they are registered only where the pair can reach the edge in question — `--delimiter` on any pair with a csv edge, `--sheet` on a csv target, `--page` on an svg target — and unconditionally on `convert` and `from-package`, whose target is only known once the output path or `--to` resolves at run time (the same registration reasoning the font flags below document). Leaving `--sheet` or `--page` unanswered on an ambiguous document fails with exit `3`, naming the sheets or page count to pick from:
-`--dump-package ` is one flag further, registered only on the explicit conversions and `convert` — it writes the intermediate `DocumentPackage` (the tree form: content grouped one group per container, the content nodes carrying their own rendered `frames`, plus the `pages` geometry those frames index into and the minted `styles` table at the root) that conversion built to a JSON file, tagged with its own version-pinned `$schema` — the URI *is* the package's version — 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); every PDF-bypassing bridge (`ods-to-xlsx`, `docx-to-pptx`, `odg-to-svg`, and every other content-format pair above) populates 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.
+`--dump-package ` is one flag further, registered only on the explicit conversions and `convert` — it writes the intermediate `DocumentPackage` (the tree form: content grouped one group per container, the content nodes carrying their own rendered `frames`, plus the `pages` geometry those frames index into and the minted `styles` table at the root) that conversion built to a JSON file, tagged with its own version-pinned `$schema` — the URI *is* the package's version — 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); every PDF-bypassing bridge (`ods-to-xlsx`, `docx-to-pptx`, `odg-to-svg`, and every other content-format pair above) populates 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; `outline` takes the shared flags except `--out` (it prints to stdout and writes no file), plus its own `--from ` for a source format extension inference can't resolve — it runs a conversion internally to obtain the package it projects, so its diagnostics and `--timeout` behave like any conversion's; `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
@@ -251,7 +262,7 @@ The global bindings below apply everywhere; individual screens (a docx run's own
The package splits into two independent layers sharing one thin format-detection module, `src/format.ts` (extension ⇄ `DocumentFormat` inference), so a change to how a format is recognised from a path never needs making twice:
-- **`src/commands/` + `src/runtime/`** is the CLI proper. `commands/shared.ts`'s `buildConversionAction(source, target)` is the one implementation behind every `-to-` command and the generic `convert` — it partially applies a format pair and hands back a ready commander action, so the conversion-running logic (read input, call `createLocalDocumentConverter().convert`, write output, report diagnostics, map errors to exit codes) exists exactly once regardless of which pair is invoked. `commands/{odm,odb,pdf-inspect,fonts,docx-extras,metadata}.ts` each call their own documents.js function directly instead, since none of them fits the generic `DocumentConverter` port's bytes-in/bytes-out shape (`odmToPdf` needs a `resolveSubDocument` callback, `.odb` extraction/query/report-rendering has no PDF conversion or port entry at all, `pdf-inspect` reads without converting, and `fonts`/`docx-extras`/`metadata` each read a document without producing one). `commands/set-metadata.ts` also bypasses `buildConversionAction` despite writing a file, since `setDocumentMetadata(source, target, bytes, overrides)` patches a document rather than converting one and requires source and target to match. `src/odb-structure.ts` sits alongside `src/format.ts` as a module shared by both layers: it turns an `OdbForm`/`OdbReport` into a flat array of already-indented lines, which the `odb-forms`/`odb-reports` commands join with newlines and the TUI's own form/report detail screens render one per list row; `src/docx-extras-format.ts` and `src/sql-result-format.ts` are the same idea for their own commands — the first turns a `DocxExtras` value into the flat line list `docx-extras` prints and the TUI's own `DocxExtrasScreen` renders, the second turns an `SqlResultSet` into an aligned plain-text table for `odb-query`. `src/runtime/` holds the process-level concerns every command shares: `abort.ts`'s `createRuntimeSignal` (one `SIGINT` listener and an optional timeout, combined into a single signal), `io.ts`'s stdin/stdout/file `-`-aware read and write helpers, `exit-codes.ts`'s exit-code constants and `mapErrorToExit`, `diagnostics.ts`'s stderr reporter, `fonts.ts`'s `loadProvidedFonts` (validating every `--font-file` path into documents.js's own `ProvidedFont` shape, shared by every font-accepting command and the TUI's own PDF-export and report-render screens), and `metadata-format.ts`'s `formatMetadataLines` (shared by `metadata`, `pdf-inspect`, and the TUI's own metadata screen).
+- **`src/commands/` + `src/runtime/`** is the CLI proper. `commands/shared.ts`'s `buildConversionAction(source, target)` is the one implementation behind every `-to-` command and the generic `convert` — it partially applies a format pair and hands back a ready commander action, so the conversion-running logic (read input, call `createLocalDocumentConverter().convert`, write output, report diagnostics, map errors to exit codes) exists exactly once regardless of which pair is invoked. `commands/{odm,odb,pdf-inspect,fonts,docx-extras,metadata}.ts` each call their own documents.js function directly instead, since none of them fits the generic `DocumentConverter` port's bytes-in/bytes-out shape (`odmToPdf` needs a `resolveSubDocument` callback, `.odb` extraction/query/report-rendering has no PDF conversion or port entry at all, `pdf-inspect` reads without converting, and `fonts`/`docx-extras`/`metadata` each read a document without producing one); `commands/outline.ts` does go through the port, but only to take `ConversionResult.package` — its output bytes are discarded and document-outline.js's `buildOutline` projects the tree instead, the one command here whose product is neither the converted bytes nor a direct documents.js read. `commands/set-metadata.ts` also bypasses `buildConversionAction` despite writing a file, since `setDocumentMetadata(source, target, bytes, overrides)` patches a document rather than converting one and requires source and target to match. `src/odb-structure.ts` sits alongside `src/format.ts` as a module shared by both layers: it turns an `OdbForm`/`OdbReport` into a flat array of already-indented lines, which the `odb-forms`/`odb-reports` commands join with newlines and the TUI's own form/report detail screens render one per list row; `src/docx-extras-format.ts` and `src/sql-result-format.ts` are the same idea for their own commands — the first turns a `DocxExtras` value into the flat line list `docx-extras` prints and the TUI's own `DocxExtrasScreen` renders, the second turns an `SqlResultSet` into an aligned plain-text table for `odb-query`. `src/runtime/` holds the process-level concerns every command shares: `abort.ts`'s `createRuntimeSignal` (one `SIGINT` listener and an optional timeout, combined into a single signal), `io.ts`'s stdin/stdout/file `-`-aware read and write helpers, `exit-codes.ts`'s exit-code constants and `mapErrorToExit`, `diagnostics.ts`'s stderr reporter, `fonts.ts`'s `loadProvidedFonts` (validating every `--font-file` path into documents.js's own `ProvidedFont` shape, shared by every font-accepting command and the TUI's own PDF-export and report-render screens), and `metadata-format.ts`'s `formatMetadataLines` (shared by `metadata`, `pdf-inspect`, and the TUI's own metadata screen).
- **`src/tui/`** is the Ink app, entered lazily. `src/cli.ts` only imports `./tui/index.js` inside a dynamic `import()`, called just once dispatch has already decided the TUI is actually running — a plain `document-cli docx-to-pdf a b` invocation never loads React, Ink, or any TUI screen module at all, and `tsdown.config.ts`'s bin build correctly code-splits the TUI into its own lazily-loaded chunk as a result. Inside the TUI, `state/reducer.ts` and `state/types.ts` hold the single `AppState` (a screen stack, the open document, undo history, overlays), `format/open-document.ts` is the one place bytes become an open document for every format, and `screens/editors//` holds each format's own screen components — reusing shared building blocks (`screens/shared/paragraph-family.tsx`, `slide-family.tsx`) between docx/odt and pptx/odp respectively, the same way documents.js's own odp editor reuses its odt paragraph/run classes.
- **`src/index.ts`** re-exports the CLI's command-layer, format, and exit-code logic (not the TUI, which stays behind its own lazy import) as this package's `"."` library export, for a caller that wants `document-cli`'s conversion-running logic directly rather than spawning the bin as a subprocess.
- **One package, two npm names.** `package.json`'s `bin` field lists `document-cli` and `doculi` unconditionally, both pointing at the same built entry point — there is no separate build, alias package, or npm alias mechanism involved, just two keys in one `bin` object.
@@ -270,7 +281,7 @@ pnpm test:workers # turbo run _test:workers -> vitest run --config vitest.worke
## Gotchas
-- **The lazy TUI import is load-bearing, not incidental.** `src/cli.ts` computes the dispatch token before doing anything else and only reaches `await import('./tui/index.js')` on the bare/`tui` branch — every other command path (all explicit conversions, `convert`, `formats`, `from-package`, `odm-to-pdf`, `odb-*`, `pdf-inspect`, `fonts`, `docx-extras`, `metadata`, `set-metadata`) never touches that import at all. This is what keeps a scripted, high-frequency CLI invocation from paying React/Ink's module-load cost on every call.
+- **The lazy TUI import is load-bearing, not incidental.** `src/cli.ts` computes the dispatch token before doing anything else and only reaches `await import('./tui/index.js')` on the bare/`tui` branch — every other command path (all explicit conversions, `convert`, `formats`, `from-package`, `odm-to-pdf`, `odb-*`, `pdf-inspect`, `fonts`, `docx-extras`, `metadata`, `set-metadata`, `outline`) never touches that import at all. This is what keeps a scripted, high-frequency CLI invocation from paying React/Ink's module-load cost on every call.
- **A bare invocation and an explicit `tui` invocation fail differently on non-interactive stdout.** `document-cli` with no arguments and redirected stdout prints help and exits `0`, on the assumption that a bare invocation piped somewhere was more likely a forgotten argument than a deliberate TUI request. `document-cli tui` with redirected stdout is unambiguous, so it fails outright (exit `2`) rather than silently reinterpreting it as a help request.
- **`tsdown.config.ts` disables `fixedExtension` on both build passes.** `platform: 'node'` defaults tsdown's `fixedExtension` to `true`, which would emit `dist/cli.mjs`/`dist/index.mjs` regardless of `package.json`'s own `"type": "module"` — mismatching the `.js`/`.cjs` paths `bin` and `exports` actually name. Both entries set `fixedExtension: false` explicitly so the build output matches what's published.
- **`readOdbTables` needs `decodeOdbPackage` from `documents.js`, not `decodePackage`.** `documents.js`'s own `decodePackage` re-exports `ooxml.js`'s OOXML-only reader and cannot decode an ODF `.odb` container at all; `decodeDocumentPackage(format, bytes)` dispatches to `odf.js` internally for every real `DocumentFormat` member (odt/odp/ods/odg/odf), but `.odb` is deliberately not one of those (it has no PDF conversion and no write direction — see documents.js's own README), so it has no format string to pass either. `decodeOdbPackage` is the `.odb`-specific sibling that decodes the identical raw ODF container directly — `commands/odb.ts` and the TUI's `format/open-document.ts` both use it for exactly this reason. `odf.js` and `pdf-codec` are devDependencies only: every runtime reach into them (the spreadsheet grid's `cellReference`/`columnIndexToLetters` for A1-style cell/column labelling, font-file inspection via `describeFontFace`) now goes through `documents.js`'s own re-exports, and `odf.js` survives in `devDependencies` solely because `src/test-support/embedded-font-fixture.ts` builds real ODF package fixtures from its low-level XML primitives.
diff --git a/package.json b/package.json
index a7500b3..9449553 100644
--- a/package.json
+++ b/package.json
@@ -88,6 +88,7 @@
"packageManager": "pnpm@11.6.0",
"dependencies": {
"commander": "^15.0.0",
+ "document-outline.js": "^1.0.0",
"documents.js": "^3.1.2",
"ink": "^7.1.1",
"ink-text-input": "^6.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2e52402..7255dd4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
commander:
specifier: ^15.0.0
version: 15.0.0
+ document-outline.js:
+ specifier: ^1.0.0
+ version: 1.0.0
documents.js:
specifier: ^3.1.2
version: 3.1.2
@@ -1720,6 +1723,10 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
+ document-outline.js@1.0.0:
+ resolution: {integrity: sha512-x55gJFT4eyPieUkHRDbpV5ty3qeNRnPx96/h2T+PwHGINX1HCqMLrtkpqciSYUzrcKFG/s2hrQa1+Vk0Tz5Gow==}
+ engines: {node: '>=20'}
+
document-schema.js@4.0.0:
resolution: {integrity: sha512-aCBeWHDubmchJBXPx7ZesLmGgOCAPOEI3n/PzunPLBEUq/w+hGpl/XLczGyyPkURu7nKR+dP6M1p7u6k4YxDsw==}
engines: {node: '>=20'}
@@ -4979,6 +4986,11 @@ snapshots:
dependencies:
path-type: 4.0.0
+ document-outline.js@1.0.0:
+ dependencies:
+ document-schema.js: 4.2.0
+ zod: 4.4.3
+
document-schema.js@4.0.0:
dependencies:
zod: 4.4.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b81643d..1de4473 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -13,3 +13,4 @@ minimumReleaseAgeExclude:
- pdf-codec
- markdown-codec
- byte-codec
+ - document-outline.js
diff --git a/src/commands/formats.ts b/src/commands/formats.ts
index 9b59eb2..f85d135 100644
--- a/src/commands/formats.ts
+++ b/src/commands/formats.ts
@@ -2,7 +2,7 @@ import { type Command } from 'commander';
import { createLocalDocumentConverter } from 'documents.js';
const COMMANDS_NOT_LISTED =
- 'odm-to-pdf, odb-to-csv, odb-to-xlsx, odb-tables, odb-forms, odb-reports, odb-query, odb-render-report, pdf-inspect, from-package, fonts, docx-extras, metadata, set-metadata';
+ 'odm-to-pdf, odb-to-csv, odb-to-xlsx, odb-tables, odb-forms, odb-reports, odb-query, odb-render-report, pdf-inspect, from-package, fonts, docx-extras, metadata, set-metadata, outline';
export function registerFormatsCommand(program: Command): void {
program
diff --git a/src/commands/outline.test.ts b/src/commands/outline.test.ts
new file mode 100644
index 0000000..0ce9ba3
--- /dev/null
+++ b/src/commands/outline.test.ts
@@ -0,0 +1,192 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createDocx, createOdg, createOds, docxToPdf } from 'documents.js';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createProgram } from '../program';
+import { EXIT_SUCCESS, EXIT_USAGE_ERROR } from '../runtime/exit-codes';
+
+// Drives the real assembled commander program against real source files, one per interesting outline shape: a markdown source for heading and list nesting (the wordprocessing projection), an ods source for per-sheet groups (the spreadsheet projection), a pdf source for the reconstruction path (the one source that cannot be bridged), and a --json run pinning the machine-readable tree to document-outline.js's own shape rather than a CLI-private one.
+
+let workspace: string;
+
+// Commander's action sets `process.exitCode` on the real process; a command that failed would otherwise leave a non-zero code behind and fail the whole vitest run for reasons unrelated to any assertion here.
+let savedExitCode: typeof process.exitCode;
+
+interface CapturedRun {
+ readonly exitCode: typeof process.exitCode;
+ readonly stdout: string;
+ readonly stderr: string;
+}
+
+async function runCli(args: readonly string[]): Promise {
+ const stdoutChunks: string[] = [];
+ const stderrChunks: string[] = [];
+ const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
+ stdoutChunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
+ return true;
+ });
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
+ stderrChunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
+ return true;
+ });
+ try {
+ await createProgram().parseAsync(['node', 'document-cli', ...args]);
+ } finally {
+ stdoutSpy.mockRestore();
+ stderrSpy.mockRestore();
+ }
+ return { exitCode: process.exitCode, stdout: stdoutChunks.join(''), stderr: stderrChunks.join('') };
+}
+
+// Narrows a JSON.parse result into a property bag without a type assertion: the values below come off the wire as `unknown`, and the lint bans `as` casts, so each access site guards with this instead.
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+// The array counterpart of isRecord, needed for the same reason and one step less obvious: TypeScript's own Array.isArray narrows an `unknown` to `any[]`, not `unknown[]`, so indexing straight off a bare Array.isArray check hands back `any` and trips no-unsafe-assignment at the very access sites isRecord then guards. Restating the identical runtime check behind a `readonly unknown[]` predicate keeps every element `unknown` all the way to its own guard.
+function isUnknownArray(value: unknown): value is readonly unknown[] {
+ return Array.isArray(value);
+}
+
+beforeAll(async () => {
+ workspace = await mkdtemp(join(tmpdir(), 'document-cli-outline-'));
+});
+
+afterAll(async () => {
+ await rm(workspace, { recursive: true, force: true });
+});
+
+beforeEach(() => {
+ savedExitCode = process.exitCode;
+});
+
+afterEach(() => {
+ process.exitCode = savedExitCode;
+});
+
+describe('outline', () => {
+ it('renders a markdown document as indented text: headings nested by level, list items nested under their heading, plain paragraphs as leaf lines', async () => {
+ const sourcePath = join(workspace, 'notes.md');
+ await writeFile(sourcePath, '# Introduction\n\nIntro paragraph.\n\n## Details\n\n- First item\n- Second item\n');
+
+ const { exitCode, stdout, stderr } = await runCli(['outline', sourcePath]);
+
+ expect(stderr).toBe('');
+ expect(exitCode).toBe(EXIT_SUCCESS);
+ // 'Details' is indented once because buildOutline's stack semantics nest an H2 under the open H1; the list items are groups (not leaves) under it; the plain paragraph is a leaf line inside the Introduction group.
+ expect(stdout).toBe('Introduction\n Intro paragraph.\n Details\n First item\n Second item\n');
+ });
+
+ it('renders a spreadsheet as one group per sheet, labelled with the sheet names in order', async () => {
+ const sheetPath = join(workspace, 'budget.ods');
+ const editor = createOds();
+ const first = editor.sheets()[0];
+ if (first === undefined) {
+ throw new Error('createOds() did not produce a default sheet');
+ }
+ first.name = 'Q1';
+ editor.addSheet('Q2');
+ await writeFile(sheetPath, editor.toBytes());
+
+ const { exitCode, stdout } = await runCli(['outline', sheetPath]);
+
+ expect(exitCode).toBe(EXIT_SUCCESS);
+ // Cells are addressable data, not outline content -- a sheet's group carries its images and embedded objects, so a cell-only sheet renders as a bare label with nothing under it.
+ expect(stdout).toBe('Q1\nQ2\n');
+ });
+
+ // Regression coverage for OUTLINE_CONVERSION_TARGET.odg: it used to bridge to 'svg', and buildSvgText refuses to write a multi-page document at all (SvgMultiPageNotSpecifiedError) since this command has no --page flag to answer it with -- every multi-page .odg failed outright with an error naming a target format ('svg') the caller never asked for. The bridge target is 'odp' now (a registered odg conversion pair with no per-document page-selection constraint), so this exercises the one source format the original entry made impossible to outline past a single page.
+ it('outlines a multi-page odg as one group per page, with no --page selection needed', async () => {
+ const drawingPath = join(workspace, 'slides.odg');
+ const editor = createOdg();
+ editor.addPage().addTextBox({ frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 20 }, text: 'First page text' });
+ editor.addPage().addTextBox({ frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 20 }, text: 'Second page text' });
+ editor.addPage().addTextBox({ frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 20 }, text: 'Third page text' });
+ await writeFile(drawingPath, editor.toBytes());
+
+ const { exitCode, stdout, stderr } = await runCli(['outline', drawingPath]);
+
+ expect(stderr).toBe('');
+ expect(exitCode).toBe(EXIT_SUCCESS);
+ // odp is a presentation-variant bridge, so each drawing page becomes a slide group -- "Slide N", document-outline.js's own presentation convention, rather than the "Page N" a same-variant drawing bridge would use.
+ expect(stdout).toBe('Slide 1\n First page text\nSlide 2\n Second page text\nSlide 3\n Third page text\n');
+ });
+
+ it('outlines a pdf source through its reconstruction, printing recovered paragraph text as flat leaf lines', async () => {
+ const paragraphText = 'A paragraph whose outline survives the reconstruction.';
+ const editor = createDocx();
+ editor.body.appendParagraph().appendRun({ text: paragraphText });
+ const pdfPath = join(workspace, 'source.pdf');
+ await writeFile(pdfPath, docxToPdf(editor.toBytes()));
+
+ const { exitCode, stdout } = await runCli(['outline', pdfPath]);
+
+ expect(exitCode).toBe(EXIT_SUCCESS);
+ // A PDF carries no heading signal, so the reconstruction yields plain paragraph leaves at the root rather than any grouping -- the honest outline of a format with no structure of its own.
+ expect(stdout).toBe(`${paragraphText}\n`);
+ });
+
+ it('emits document-outline.js own tree shape under --json, not a CLI-private projection', async () => {
+ const sourcePath = join(workspace, 'json.md');
+ await writeFile(sourcePath, '# Title\n\nBody text.\n');
+
+ const { exitCode, stdout } = await runCli(['outline', sourcePath, '--json']);
+
+ expect(exitCode).toBe(EXIT_SUCCESS);
+
+ const outline: unknown = JSON.parse(stdout);
+ if (!isUnknownArray(outline) || outline.length !== 1) {
+ throw new Error(`expected a one-element root array, got ${JSON.stringify(outline)}`);
+ }
+ // The group arm: { text, level, children } with the heading's own text and headingLevel as the level signal.
+ const group = outline[0];
+ if (!isRecord(group)) {
+ throw new Error(`expected a group object at the root, got ${JSON.stringify(group)}`);
+ }
+ expect(group.text).toBe('Title');
+ expect(group.level).toBe(1);
+ // The leaf arm: the package leaf itself (kind 'paragraph', its runs), not a summarised copy.
+ const children = group.children;
+ if (!isUnknownArray(children) || children.length !== 1) {
+ throw new Error(`expected one child under the heading, got ${JSON.stringify(children)}`);
+ }
+ const leaf = children[0];
+ if (!isRecord(leaf)) {
+ throw new Error(`expected a leaf object under the heading, got ${JSON.stringify(leaf)}`);
+ }
+ expect(leaf.kind).toBe('paragraph');
+ });
+
+ it('fails with a usage error naming the recognised extensions when the input has none', async () => {
+ const barePath = join(workspace, 'notes.txt');
+ await writeFile(barePath, 'no outline signal here\n');
+
+ const { exitCode, stderr } = await runCli(['outline', barePath]);
+
+ expect(exitCode).toBe(EXIT_USAGE_ERROR);
+ expect(stderr).toContain('cannot infer a source format');
+ });
+
+ it('prints nothing at all for a document with no outline content, rather than one stray blank line', async () => {
+ const emptyPath = join(workspace, 'empty.md');
+ await writeFile(emptyPath, '');
+
+ const { exitCode, stdout, stderr } = await runCli(['outline', emptyPath]);
+
+ expect(stderr).toBe('');
+ expect(exitCode).toBe(EXIT_SUCCESS);
+ // Not '\n' -- joining zero lines and then appending a trailing newline unconditionally would still write one blank line for a document with nothing to outline at all.
+ expect(stdout).toBe('');
+ });
+
+ it('--json still emits an empty array for the same empty document', async () => {
+ const emptyPath = join(workspace, 'empty-json.md');
+ await writeFile(emptyPath, '');
+
+ const { exitCode, stdout } = await runCli(['outline', emptyPath, '--json']);
+
+ expect(exitCode).toBe(EXIT_SUCCESS);
+ expect(stdout).toBe('[]\n');
+ });
+});
diff --git a/src/commands/outline.ts b/src/commands/outline.ts
new file mode 100644
index 0000000..bc030fd
--- /dev/null
+++ b/src/commands/outline.ts
@@ -0,0 +1,152 @@
+import { dirname, resolve } from 'node:path';
+import { type Command } from 'commander';
+import { buildOutline, isOutlineNode, outlineLeafText, type OutlineChild, type OutlineLeaf } from 'document-outline.js';
+import { type DocumentFormat, createLocalDocumentConverter } from 'documents.js';
+import { inferFormatFromExtension, isDocumentFormat } from '../format';
+import { createRuntimeSignal } from '../runtime/abort';
+import { createDiagnosticReporter } from '../runtime/diagnostics';
+import { EXIT_SUCCESS, EXIT_USAGE_ERROR, mapErrorToExit } from '../runtime/exit-codes';
+import { readInput } from '../runtime/io';
+import { createFilesystemMarkdownImageResolver } from '../runtime/markdown-images';
+import { addQuietOption, addTimeoutOption, addVerboseOption } from './options';
+import { KNOWN_DOCUMENT_FORMATS, formatError } from './shared';
+
+// The conversion this command runs exists only for ConversionResult.package -- its output bytes are discarded, since the outline projects over the tree-form DocumentPackage, not over any rendered target. A PDF-bypassing bridge is the cheapest conversion that still populates a package (no layout engine runs), so each of the ten content formats bridges to a sibling it shares a registry entry with; the two formats outside that set each take the one conversion they actually have -- pdf reconstructs through pdf-to-docx (a PDF carries no content tree of its own to read), and odf renders through odf-to-pdf (its only conversion; the outline reads the formula package that conversion builds, not the rendered pages). The target is otherwise incidental: the package a bridge leaves behind is built from the source document's own content, so the outline it feeds is the source document's outline.
+const OUTLINE_CONVERSION_TARGET: Readonly> = {
+ docx: 'odt',
+ odt: 'docx',
+ markdown: 'docx',
+ pptx: 'odp',
+ odp: 'pptx',
+ xlsx: 'ods',
+ ods: 'xlsx',
+ csv: 'xlsx',
+ odg: 'odp',
+ svg: 'odg',
+ pdf: 'docx',
+ odf: 'pdf',
+};
+
+// Two spaces per nesting depth -- the indentation is this command's level rendering. An OutlineNode's own `level` field is the SOURCE's level signal (a heading's headingLevel, a list item's list level, 1 for the synthetic slide/sheet/page groups), deliberately not used for indentation: those scales differ per construct and legitimately coexist (a level-0 list item inside a level-1 slide group), while tree depth is unambiguous and recoverable from the nesting itself.
+const INDENT = ' ';
+
+// A leaf's text can carry newlines (a table's own text joins its rows with '\n', a paragraph's runs can span source line breaks), which would break the indentation of every line after the first -- so the text rendering collapses runs of whitespace to single spaces. The --json view keeps the raw text.
+function singleLineText(text: string): string {
+ return text.replaceAll(/\s+/g, ' ').trim();
+}
+
+// The label for a textless leaf's bracket placeholder, discriminated structurally the same way outlineLeafText itself discriminates ('runs' in leaf, 'rows' in leaf, ...): every PackageLeaf arm carries a `kind` literal except the two that do not -- a sheet-anchored embedded object names its discriminator `objectKind`, and a ContentFormula carries no discriminator at all (its mathml payload is its identifying field), so the fall-through arm is exactly the formula leaf and labels itself as such.
+function leafKindLabel(leaf: OutlineLeaf): string {
+ if ('kind' in leaf) return leaf.kind;
+ if ('objectKind' in leaf) return leaf.objectKind;
+ return 'formula';
+}
+
+// One line per outline entry, groups and leaves alike: a group renders its own label (the heading's text, the list item's text, the "Slide N"/sheet-name/"Page N" label) and recurses into its children one depth deeper; a leaf renders its own text (a paragraph's runs, a table's cell text, an image's alt text, a formula's LaTeX), or its kind in brackets when it carries none (a page break, a vector, an embedded object) -- so the text view never silently drops an entry the JSON view carries. A group whose label is empty (an empty heading, a formula with no LaTeX linearisation) renders as a bare indented line, the honest transcript of an empty label rather than an invented placeholder.
+function appendOutlineLines(children: readonly OutlineChild[], depth: number, lines: string[]): void {
+ for (const child of children) {
+ if (isOutlineNode(child)) {
+ lines.push(`${INDENT.repeat(depth)}${singleLineText(child.text)}`);
+ appendOutlineLines(child.children, depth + 1, lines);
+ } else {
+ const text = singleLineText(outlineLeafText(child));
+ lines.push(text === '' ? `${INDENT.repeat(depth)}[${leafKindLabel(child)}]` : `${INDENT.repeat(depth)}${text}`);
+ }
+ }
+}
+
+interface OutlineCliOptions {
+ readonly timeout?: number;
+ readonly json: boolean;
+ readonly quiet: boolean;
+ readonly verbose: boolean;
+ readonly from?: string;
+}
+
+// Mirrors resolveTargetFormat's own resolution order (commands/shared.ts), source-side: an explicit --from always wins, falling back to the input path's own extension, and finally a usage error. Extracted as its own function rather than inlined in runOutline because the three failure messages differ by cause (an unrecognised --from value, stdin with nothing to infer from, a real path with no recognised extension) and inlining them would bury that distinction in the try block below.
+function resolveSourceFormat(input: string, from: string | undefined): { readonly format: DocumentFormat } | { readonly errorMessage: string } {
+ if (from !== undefined) {
+ if (!isDocumentFormat(from)) {
+ return { errorMessage: `unknown --from format '${from}'; expected one of ${KNOWN_DOCUMENT_FORMATS}` };
+ }
+ return { format: from };
+ }
+ if (input === '-') {
+ return { errorMessage: `cannot infer a source format from stdin; pass --from (${KNOWN_DOCUMENT_FORMATS})` };
+ }
+ const inferred = inferFormatFromExtension(input);
+ if (inferred === undefined) {
+ return { errorMessage: `cannot infer a source format from '${input}'; rename the file with a recognised extension (${KNOWN_DOCUMENT_FORMATS}) or pass --from ` };
+ }
+ return { format: inferred };
+}
+
+async function runOutline(input: string, options: OutlineCliOptions): Promise {
+ const command = 'outline';
+ const source = resolveSourceFormat(input, options.from);
+ if ('errorMessage' in source) {
+ process.stderr.write(`[${command}] ${source.errorMessage}\n`);
+ return EXIT_USAGE_ERROR;
+ }
+ const target = OUTLINE_CONVERSION_TARGET[source.format];
+
+ const { signal, getAbortReason } = createRuntimeSignal({ timeoutMs: options.timeout });
+ const reporter = createDiagnosticReporter({ json: options.json, quiet: options.quiet, command });
+
+ try {
+ const inputBytes = await readInput(input, { signal });
+ const converter = createLocalDocumentConverter();
+ const result = await converter.convert(
+ { source: { format: source.format, bytes: new Uint8Array(inputBytes) }, targetFormat: target },
+ {
+ signal,
+ // Resolved exactly as buildConversionAction resolves the same option for a live conversion: a markdown source's own non-data: images resolve against the input file's directory rather than degrading to alt text with an unresolved-image diagnostic. For stdin the base directory is the current working directory, matching buildConversionAction's own stdin handling -- '-' reaches this line now that resolveSourceFormat above requires --from for it rather than failing before this conversion ever runs.
+ images: createFilesystemMarkdownImageResolver(input === '-' ? '.' : dirname(resolve(input))),
+ },
+ );
+
+ // The internal conversion's own diagnostics are not swallowed: a pdf reconstruction can report parse warnings and character substitutions, and those belong on stderr exactly as they would on the matching pdf-to-docx command.
+ for (const diagnostic of result.diagnostics) {
+ reporter.report(diagnostic);
+ }
+
+ // The port declares `package` optional (a remote adapter is free to report none), while the local converter this command uses populates one on every conversion -- so an absent package is a broken contract, not a document with an empty outline, and fails loudly instead of printing nothing.
+ if (result.package === undefined) {
+ throw new Error(`the ${source.format}-to-${target} conversion produced no intermediate DocumentPackage`);
+ }
+
+ const outline = buildOutline(result.package);
+
+ if (options.json) {
+ // The tree document-outline.js itself returns, verbatim -- groups as { text, level, children }, leaves as the package leaves they are -- so a consumer can walk it with that package's own isOutlineNode/isOutlineChild guards rather than a CLI-private shape.
+ process.stdout.write(`${JSON.stringify(outline, undefined, 2)}\n`);
+ return EXIT_SUCCESS;
+ }
+
+ const lines: string[] = [];
+ appendOutlineLines(outline, 0, lines);
+ // An empty document (no groups, no leaves) has nothing to print -- joining an empty array still needs the trailing newline suppressed, or stdout would carry one blank line for a document with no outline at all.
+ if (lines.length > 0) {
+ process.stdout.write(`${lines.join('\n')}\n`);
+ }
+ return EXIT_SUCCESS;
+ } catch (error) {
+ process.stderr.write(`[${command}] ${formatError(error, options.verbose)}\n`);
+ return mapErrorToExit(error, getAbortReason());
+ }
+}
+
+// Prints the table-of-contents projection over any readable document -- the first CLI surface over document-outline.js's buildOutline, reached by running the cheapest conversion that leaves a package behind (see OUTLINE_CONVERSION_TARGET) and projecting that package.
+export function registerOutlineCommand(program: Command): void {
+ const command = program
+ .command('outline ')
+ .description(`print a document's outline -- headings, list items, and slide/sheet/page groups as indented text (${KNOWN_DOCUMENT_FORMATS})`);
+ addTimeoutOption(command);
+ command.option('--json', 'emit the outline tree as JSON instead of indented text (diagnostics as NDJSON on stderr)', false);
+ addQuietOption(command);
+ addVerboseOption(command);
+ command.option('--from ', `source format when it cannot be inferred from the input path, e.g. reading from stdin (${KNOWN_DOCUMENT_FORMATS})`);
+ command.action(async (input: string, options: OutlineCliOptions) => {
+ process.exitCode = await runOutline(input, options);
+ });
+}
diff --git a/src/program.ts b/src/program.ts
index 07deea2..87ae25e 100644
--- a/src/program.ts
+++ b/src/program.ts
@@ -7,6 +7,7 @@ import { registerFromPackageCommand } from './commands/from-package';
import { registerMetadataCommand } from './commands/metadata';
import { registerOdbCommands } from './commands/odb';
import { registerOdmCommand } from './commands/odm';
+import { registerOutlineCommand } from './commands/outline';
import { registerPdfInspectCommand } from './commands/pdf-inspect';
import { registerSetMetadataCommand } from './commands/set-metadata';
import { EXIT_SUCCESS, EXIT_USAGE_ERROR } from './runtime/exit-codes';
@@ -34,6 +35,7 @@ export function createProgram(): Command {
registerFontsCommand(program);
registerDocxExtrasCommand(program);
registerMetadataCommand(program);
+ registerOutlineCommand(program);
registerSetMetadataCommand(program);
return program;
diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs
index 23574bc..7553963 100644
--- a/test/smoke.test.mjs
+++ b/test/smoke.test.mjs
@@ -44,7 +44,7 @@ describe('dist/cli.js --help', () => {
expect(code).toBe(EXIT_SUCCESS);
const text = stdout.toString('utf8');
// markdown-to-pdf is not registered by any code in this package -- it exists only because documents.js's own createLocalDocumentConverter().conversions now includes a markdown edge, and registerConversionCommands (src/commands/convert.ts) loops over that array unmodified. Its presence here is the end-to-end proof that registering a new format entirely inside documents.js is enough.
- for (const name of ['docx-to-pdf', 'markdown-to-pdf', 'convert', 'formats', 'odm-to-pdf', 'odb-tables', 'odb-forms', 'odb-reports', 'pdf-inspect', 'tui']) {
+ for (const name of ['docx-to-pdf', 'markdown-to-pdf', 'convert', 'formats', 'odm-to-pdf', 'odb-tables', 'odb-forms', 'odb-reports', 'pdf-inspect', 'outline', 'tui']) {
expect(text).toContain(name);
}
});
@@ -363,6 +363,59 @@ describe('dist/cli.js csv and svg conversions', () => {
});
});
+describe('dist/cli.js outline: real file round trip', () => {
+ it('prints an indented outline for a genuine docx fixture through the actual built binary', async () => {
+ const tmpDir = await mkdtemp(join(tmpdir(), 'document-cli-smoke-'));
+ try {
+ const inputPath = join(tmpDir, 'fixture.docx');
+ await writeFile(inputPath, buildFixtureDocxBytes());
+
+ const { code, stdout, stderr } = await spawnCli(['outline', inputPath]);
+ expect(code).toBe(EXIT_SUCCESS);
+ expect(stderr.length).toBe(0);
+ expect(stdout.toString('utf8')).toBe('Hello from the document-cli smoke test\n');
+ } finally {
+ await rm(tmpDir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe('dist/cli.js outline: multi-page odg', () => {
+ // Regression coverage for the odg bridge target: OUTLINE_CONVERSION_TARGET.odg used to be 'svg', and buildSvgText refuses a multi-page document outright (SvgMultiPageNotSpecifiedError) since outline has no --page flag to answer it with -- every multi-page .odg failed outright. The bridge target is 'odp' now, which has no such per-page write constraint -- the trade-off being that each drawing page becomes a presentation slide group (labelled "Slide N", document-outline.js's own presentation-variant convention) rather than a "Page N" draw-page group, since odp is a cross-variant bridge and svg is the only same-variant target odg has, and svg is exactly the format whose own write side cannot hold more than one page at all.
+ it('outlines a multi-page odg as one group per page, with no --page flag needed', async () => {
+ const tmpDir = await mkdtemp(join(tmpdir(), 'document-cli-smoke-'));
+ try {
+ const inputPath = join(tmpDir, 'multi.odg');
+ const editor = createOdg();
+ editor.addPage().addTextBox({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 50 }, text: 'First page text' });
+ editor.addPage().addTextBox({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 50 }, text: 'Second page text' });
+ await writeFile(inputPath, editor.toBytes());
+
+ const { code, stdout } = await spawnCli(['outline', inputPath]);
+ expect(code).toBe(EXIT_SUCCESS);
+ expect(stdout.toString('utf8')).toBe('Slide 1\n First page text\nSlide 2\n Second page text\n');
+ } finally {
+ await rm(tmpDir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe('dist/cli.js outline: --from and stdin', () => {
+ it('fails with a stdin-specific usage error naming --from, not the file-rename advice meant for a real path', async () => {
+ const { code, stdout, stderr } = await spawnCli(['outline', '-'], { input: buildFixtureDocxBytes() });
+ expect(code).toBe(EXIT_USAGE_ERROR);
+ expect(stdout.length).toBe(0);
+ expect(stderr.toString('utf8')).toContain('cannot infer a source format from stdin; pass --from ');
+ });
+
+ it('outlines a document piped in on stdin once --from names its format', async () => {
+ const { code, stdout, stderr } = await spawnCli(['outline', '-', '--from', 'docx'], { input: buildFixtureDocxBytes() });
+ expect(code).toBe(EXIT_SUCCESS);
+ expect(stderr.length).toBe(0);
+ expect(stdout.toString('utf8')).toBe('Hello from the document-cli smoke test\n');
+ });
+});
+
describe('dist/cli.js tui: non-interactive stdout', () => {
it('exits with a clear, non-crashing error about needing a TTY, never launching Ink at all', async () => {
const { code, stdout, stderr } = await spawnCli(['tui']);