document-cli 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,16 +12,19 @@ graph TD
12
12
  ooxml("ooxml.js")
13
13
  odf("odf.js")
14
14
  pdfcodec("pdf-codec")
15
+ mdcodec("markdown-codec")
15
16
  documents("documents.js")
16
17
  cli("document-cli")
17
18
 
18
19
  schema --> ooxml
19
20
  schema --> odf
20
21
  schema --> pdfcodec
22
+ schema --> mdcodec
21
23
  schema --> documents
22
24
  ooxml --> documents
23
25
  odf --> documents
24
26
  pdfcodec --> documents
27
+ mdcodec --> documents
25
28
  documents --> cli
26
29
  odf --> cli
27
30
 
@@ -29,6 +32,7 @@ graph TD
29
32
  click ooxml "https://github.com/ExaDev/ooxml.js" "ooxml.js"
30
33
  click odf "https://github.com/ExaDev/odf.js" "odf.js"
31
34
  click pdfcodec "https://github.com/ExaDev/pdf-codec" "pdf-codec"
35
+ click mdcodec "https://github.com/ExaDev/markdown-codec" "markdown-codec"
32
36
  click documents "https://github.com/ExaDev/documents.js" "documents.js"
33
37
  click cli "https://github.com/ExaDev/document-cli" "document-cli"
34
38
 
@@ -37,7 +41,7 @@ graph TD
37
41
 
38
42
  ## Why
39
43
 
40
- `documents.js` is a library, not a tool — everything it does happens through function calls from TypeScript/JavaScript. Most people who want to convert a docx to a PDF, extract an `.odb` table to CSV, or poke at a PDF's structure from a terminal don't want to write a script to do it. `document-cli` is that missing entry point: every one of documents.js's 19 direct conversion pairs, its generic converter, its `.odm`/`.odb` extraction functions, and its PDF inspector become a single command-line invocation, and its six live-view editors (docx/pptx/odt/odp/ods/odg) become a keyboard-driven terminal app that never needs a code editor open at all.
44
+ `documents.js` is a library, not a tool — everything it does happens through function calls from TypeScript/JavaScript. Most people who want to convert a docx to a PDF, extract an `.odb` table to CSV, or poke at a PDF's structure from a terminal don't want to write a script to do it. `document-cli` is that missing entry point: every one of documents.js's 27 direct conversion pairs, its generic converter, its `.odm`/`.odb` extraction functions, and its PDF inspector become a single command-line invocation, and its six live-view editors (docx/pptx/odt/odp/ods/odg) plus its line-based markdown editor become a keyboard-driven terminal app that never needs a code editor open at all.
41
45
 
42
46
  The CLI and the TUI are deliberately not two separate implementations of the same logic. The TUI's own document-opening, saving, and PDF-export code (`src/tui/format/`) calls the identical `documents.js` functions the CLI commands call — `openDocx`/`createDocx`/`docxToPdf` and their five siblings per format, plus `readOdbTables`/`readPdf` for the two read-only sources — so there is exactly one place either surface can drift from what documents.js itself does: nowhere.
43
47
 
@@ -64,7 +68,7 @@ cat report.docx | document-cli docx-to-pdf - - > report.pdf
64
68
 
65
69
  ### Commands
66
70
 
67
- **The 19 explicit `<source>-to-<target>` conversions** — one command per pair `createLocalDocumentConverter().conversions` declares in `documents.js`: `docx-to-pdf`, `pptx-to-pdf`, `odt-to-pdf`, `odp-to-pdf`, `ods-to-pdf`, `odg-to-pdf`, `odf-to-pdf` (one-way, no `pdf-to-odf` see documents.js's own README), `pdf-to-docx`, `pdf-to-pptx`, `pdf-to-odt`, `pdf-to-odp`, `pdf-to-ods`, `pdf-to-odg`, and the six PDF-bypassing bridges `odt-to-docx`, `docx-to-odt`, `odp-to-pptx`, `pptx-to-odp`, `ods-to-xlsx`, `xlsx-to-ods`. Each takes `<input> [output]`:
71
+ **The 27 explicit `<source>-to-<target>` conversions** — one command per pair `createLocalDocumentConverter().conversions` declares in `documents.js` (confirmed by running the built CLI's own `formats` command, not assumed): the nine `<format>-to-pdf` conversions `docx-to-pdf`, `pptx-to-pdf`, `odt-to-pdf`, `odp-to-pdf`, `ods-to-pdf`, `odg-to-pdf`, `odf-to-pdf`, `xlsx-to-pdf`, `markdown-to-pdf`; the eight `pdf-to-<format>` reverse conversions `pdf-to-docx`, `pdf-to-pptx`, `pdf-to-odt`, `pdf-to-odp`, `pdf-to-ods`, `pdf-to-odg`, `pdf-to-xlsx`, `pdf-to-markdown` (`odf-to-pdf` is one-way — there is no `pdf-to-odf` — see documents.js's own README); and ten further PDF-bypassing bridges, `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`. Each takes `<input> [output]`:
68
72
 
69
73
  ```sh
70
74
  document-cli docx-to-pdf report.docx report.pdf
@@ -78,7 +82,15 @@ document-cli convert report.docx report.pdf
78
82
  document-cli convert report.docx - --to pdf > report.pdf
79
83
  ```
80
84
 
81
- **`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`, `pdf-inspect`).
85
+ **`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`).
86
+
87
+ **`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` and `odf` are rejected outright — documents.js exposes no `ContentDocument`-to-xlsx builder (convert to `ods` and run `ods-to-xlsx` instead) and a 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:
88
+
89
+ ```sh
90
+ document-cli docx-to-pdf report.docx report.pdf --dump-package report.package.json
91
+ document-cli from-package report.package.json report2.pdf
92
+ document-cli from-package report.package.json report.odt --to odt
93
+ ```
82
94
 
83
95
  **`odm-to-pdf <input> [output]`** — converts a `.odm` master document to PDF. A `.odm`'s chapters are external references to standalone `.odt` files, never inlined in the master document itself, so this command needs to be told where to find them: `--chapters-dir <dir>` (matched by each chapter's own basename) and/or repeatable `--chapter <href>=<file>` overrides. An unresolved chapter fails with every missing `href` named at once, not just the first.
84
96
 
@@ -89,6 +101,13 @@ document-cli odb-tables customers.odb
89
101
  document-cli odb-to-csv customers.odb --table CUSTOMERS customers.csv
90
102
  ```
91
103
 
104
+ **`odb-forms <input>`**, **`odb-reports <input>`** — read a `.odb`'s form and report *structure* rather than its table data. A form or report is a static ODF sub-document inside the package, so neither command consults the embedded database at all: they work on an `.odb` whose connection points at an external server just as well as on an embedded one. `odb-forms` prints each form's own data source (table or saved query) and its field-bound controls, sub-forms nested under their parent with their own separate command; `odb-reports` prints each report's data-source command, its band structure (report/page headers and footers, groups, detail), every `rpt:` formula expression (`field:[AMOUNT]`, `rpt:SUM([AMOUNT])`), and any user-defined report functions. `--json` emits the same structure machine-readably — for a form that is the structure only, with the form's own parsed sub-document dropped:
105
+
106
+ ```sh
107
+ document-cli odb-forms sales.odb
108
+ document-cli odb-reports sales.odb --json
109
+ ```
110
+
92
111
  **`pdf-inspect <input>`** — reports a PDF's page count, per-page size and item-kind histogram, document metadata, and embedded image formats, without converting it to anything. `--full` dumps the entire parsed `LayoutDocument` as JSON instead of the summary:
93
112
 
94
113
  ```sh
@@ -100,7 +119,7 @@ document-cli pdf-inspect report.pdf --json
100
119
 
101
120
  ### Shared flags
102
121
 
103
- The 19 explicit conversions, `convert`, `odm-to-pdf`, `odb-to-xlsx`, and `odb-to-csv` — every command that reads one file and writes one — share:
122
+ The 27 explicit conversions, `convert`, `odm-to-pdf`, `odb-to-xlsx`, and `odb-to-csv` — every command that reads one file and writes one — share:
104
123
 
105
124
  | Flag | Meaning |
106
125
  |---|---|
@@ -110,7 +129,24 @@ The 19 explicit conversions, `convert`, `odm-to-pdf`, `odb-to-xlsx`, and `odb-to
110
129
  | `-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). |
111
130
  | `--verbose` | Include a full stack trace in the error line when the run fails. |
112
131
 
113
- `--dump-package <file>` is one flag further, registered only on the 19 explicit conversions and `convert` — it writes the intermediate `DocumentPackage` (content + layout) that conversion built to a JSON file. 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 six PDF-bypassing bridges populate one too, just with `layout` always `undefined`, since a bridge never runs a layout engine. `odm-to-pdf`/`odb-*` don't expose the flag at all, since neither goes through `DocumentConverter.convert` in the first place. `odb-tables`, `formats`, and `pdf-inspect` each take only their own `--json` (plus `pdf-inspect`'s own `--full`); `tui` takes no flags at all, only an optional positional file.
132
+ `--dump-package <file>` is one flag further, registered only on the 27 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 ten PDF-bypassing bridges (`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`) populate one too, just with `layout` always `undefined`, since a bridge never runs a layout engine. `odm-to-pdf`/`odb-*` don't expose the flag at all, since neither goes through `DocumentConverter.convert` in the first place. `odb-tables`, `odb-forms`, `odb-reports`, `formats`, and `pdf-inspect` each take only their own `--json` (plus `pdf-inspect`'s own `--full`); `from-package` takes `--to <format>` alongside the shared flags in this table; `tui` takes no flags at all, only an optional positional file.
133
+
134
+ ### Real fonts
135
+
136
+ By default a conversion renders through whatever `documents.js` resolves for itself: the source document's own embedded faces first, then `pdf-codec`'s vendored Carlito/Caladea substitutes (metric-compatible with Calibri/Cambria), then the standard 14 PDF fonts. `--font-file <path>` inserts your own faces between the first two steps — used wherever the document asks for the family a font file declares, and ignored where it doesn't.
137
+
138
+ ```sh
139
+ document-cli docx-to-pdf report.docx report.pdf \
140
+ --font-file ~/fonts/Calibri.ttf \
141
+ --font-file ~/fonts/Calibri-Bold.ttf \
142
+ --report-font-substitutions
143
+ ```
144
+
145
+ The flag is repeatable, takes a `.ttf`/`.otf` path, and needs **no accompanying family flag**: the family, weight, and slope are read from the font file's own `name` and `OS/2` tables. That is a deliberate choice over the alternative of a parallel `--font-family`/`--font-bold`/`--font-italic` set — three repeatable flags whose values must stay index-aligned with a fourth is a silent-misalignment hazard (pass two font files and one `--font-family` and the second face is mis-declared, with nothing reporting it), and every real font already states all three facts itself. The consequence to know about: a font file can only be supplied *as the family it says it is*. There is no way to say "draw Calibri using this file instead" — for that, the family in the document has to match the family in the font. A file that is not a readable font (a `.woff`, a `.ttc` collection, a mistyped path pointing at something else) fails the run outright, naming the file, rather than being quietly skipped.
146
+
147
+ `--report-font-substitutions` prints each face that resolved to something other than what the document asked for, as it happens, with its structured fields intact (`--json` makes it one more NDJSON record: `{"type":"font-substitution","requestedFamily":"Calibri",…}`). Without it, the same fallbacks are still reported — the `font/substituted` diagnostic lines every conversion already emits — just as rendered messages after the fact rather than structured events as they occur.
148
+
149
+ Both flags are registered only where they can do something: the nine `<format>-to-pdf` conversions, `convert`, and `odm-to-pdf`. A `pdf-to-<format>` reconstruction reads a PDF's own already-positioned glyphs and a format-to-format bridge runs no layout engine at all, so neither resolves a typeface and neither advertises the flags.
114
150
 
115
151
  Diagnostics and the summary line always go to stderr; stdout is reserved for the converted bytes on any command writing to `-`.
116
152
 
@@ -129,7 +165,13 @@ Diagnostics and the summary line always go to stderr; stdout is reserved for the
129
165
 
130
166
  Launch it either bare (`document-cli`, with no arguments) or explicitly with `document-cli tui [file]` — both open the same app; the explicit form additionally opens `file` immediately, skipping the launcher screen. The TUI needs an interactive terminal: a bare invocation with redirected stdout prints help text instead, and an explicit `tui` invocation with redirected stdout fails outright, since there's no terminal for Ink to draw into.
131
167
 
132
- It supports the same six formats documents.js's editors cover — docx, pptx, odt, odp, ods, odg — each with a full navigate/edit/save experience built on that format's live-view editor (paragraphs and runs for docx/odt, slides and shapes for pptx/odp, sheets and cells for ods, pages and vectors/shapes for odg), plus undo (whole-document snapshots taken before each committed mutation), search, a command palette, and PDF export straight from the open document. Two further formats open read-only: a `.odb` browses its tables and rows with no write path at all (documents.js's own `.odb` support has no write direction to offer), and a `.pdf` browses its pages and positioned items rather than being edited in place. `.xlsx` and standalone `.odf` have no TUI editor documents.js has no xlsx editor at all, and a standalone `.odf` formula document has nothing to edit interactively, only a PDF conversion.
168
+ It supports the same six formats documents.js's live-view editors cover — docx, pptx, odt, odp, ods, odg — each with a full navigate/edit/save experience built on that format's editor (paragraphs and runs for docx/odt, slides and shapes for pptx/odp, sheets and cells for ods, pages and vectors/shapes for odg), plus undo (whole-document snapshots taken before each committed mutation), search, a command palette, and PDF export straight from the open document. On a pptx or odp slide, `a` from the shape list also adds a real table (rows then columns, a two-step prompt) alongside the existing textbox/image choices, and `n` opens the slide's own speaker notes for either format `PptxSlide` and `OdpSlide` both carry a real `.notes` getter/setter, so notes editing was never odp-specific, only gated that way until this phase removed the gate.
169
+
170
+ Markdown (`.md`/`.markdown`) is also a fully supported TUI format, but a structurally different one: documents.js has no `MarkdownEditor` the way it has a `DocxEditor`/`OdtEditor` (its own markdown support is a thin read/write pair over a plain string, not an `XmlElement` tree to hold a live view into), so the TUI edits a markdown document as its own raw source text rather than through a live-view editor object. Opening a `.md` file loads its bytes verbatim as a source string (`decodeMarkdownText`); the root screen lists that source split into lines, one row each, with search filtering by line content; selecting a line opens a line editor that replaces its text in place; `Ctrl+S` writes the (possibly edited) source straight back to disk (`encodeMarkdownText`), the identical byte↔text boundary opening it went through in reverse — never through `readMarkdownContent`/`buildMarkdownText`. PDF export is the one place the `ContentDocument` pivot enters at all: exporting calls `markdownToPdf` directly on the current source text, run fresh on every export rather than kept in sync with in-progress edits. There is no "create a new markdown document" flow (documents.js has no `createMarkdown()`), so a markdown document can only be opened from an existing file, never created fresh from the TUI's new-document screen the way docx/pptx/odt/odp/ods/odg can.
171
+
172
+ Three further formats open read-only: a `.odb` browses its tables and rows with no write path at all (documents.js's own `.odb` support has no write direction to offer), a `.pdf` browses its pages and positioned items rather than being edited in place, and a `.xlsx` opens as a converted PDF preview — documents.js has no xlsx editor to hold a live view into, so opening one runs `xlsxToPdf` once at open time and browses the result through the identical page-list/page-items/item-detail screens a real `.pdf` uses, with the original bytes kept alongside so a later export re-runs `xlsxToPdf` with the caller's own fonts and diagnostics rather than reusing the fixed preview conversion. A `.odb` additionally browses its *structure* alongside its data: `f` from the table list opens the form browser and `r` the report browser, each listing what the database declares and opening one to show it in full — a form's own data source and field-bound controls (sub-forms nested under their parent), a report's data-source command, band and group structure, and every `rpt:` formula. Both are rendered through the same `src/odb-structure.ts` the `odb-forms`/`odb-reports` commands print, so the two views cannot drift apart, and search filters by line (`/SUM` narrows a long report to its aggregate expressions). A standalone `.odf` formula document has no TUI editor either — nothing to edit interactively, only a PDF conversion.
173
+
174
+ The export-to-PDF screen (`e` from any editor screen) is a two-field form: a destination path, then an optional comma-separated list of local `.ttf`/`.otf` paths, which are the same `--font-file` faces the CLI takes and are derived the same way — each font's family, weight, and slope come from the file itself. `Enter` on the path field moves to the fonts field and `Enter` there exports, so leaving fonts empty is still "type a path, press Enter twice". Comma-separated rather than space-separated because a font path routinely contains spaces and almost never a comma. A face falling back to a substitute is reported into the same diagnostics panel a character substitution already is, and a bad font path fails the export with the file named, before anything is written to the destination.
133
175
 
134
176
  ### Key bindings
135
177
 
@@ -156,7 +198,7 @@ The global bindings below apply everywhere; individual screens (a docx run's own
156
198
 
157
199
  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:
158
200
 
159
- - **`src/commands/` + `src/runtime/`** is the CLI proper. `commands/shared.ts`'s `buildConversionAction(source, target)` is the one implementation behind every `<source>-to-<target>` 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 of the 19 pairs is invoked. `commands/{odm,odb,pdf-inspect}.ts` each call their own documents.js function directly instead, since none of the three fits the generic `DocumentConverter` port's bytes-in/bytes-out shape (`odmToPdf` needs a `resolveSubDocument` callback, `.odb` extraction has no PDF conversion or port entry at all, and `pdf-inspect` reads without converting). `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`, and `diagnostics.ts`'s stderr reporter.
201
+ - **`src/commands/` + `src/runtime/`** is the CLI proper. `commands/shared.ts`'s `buildConversionAction(source, target)` is the one implementation behind every `<source>-to-<target>` 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 of the 27 pairs is invoked. `commands/{odm,odb,pdf-inspect}.ts` each call their own documents.js function directly instead, since none of the three fits the generic `DocumentConverter` port's bytes-in/bytes-out shape (`odmToPdf` needs a `resolveSubDocument` callback, `.odb` extraction has no PDF conversion or port entry at all, and `pdf-inspect` reads without converting). `src/odb-structure.ts` sits alongside `src/format.ts` as the second module both layers share: 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/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`, and `diagnostics.ts`'s stderr reporter.
160
202
  - **`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/<format>/` 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.
161
203
  - **`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.
162
204
  - **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.
@@ -174,7 +216,7 @@ pnpm test:smoke # tsdown, then vitest run --project smoke -- spawns the built
174
216
 
175
217
  ## Gotchas
176
218
 
177
- - **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 19 explicit conversions, `convert`, `formats`, `odm-to-pdf`, `odb-*`, `pdf-inspect`) 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.
219
+ - **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 27 explicit conversions, `convert`, `formats`, `odm-to-pdf`, `odb-*`, `pdf-inspect`) 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.
178
220
  - **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.
179
221
  - **`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.
180
222
  - **`readOdbTables` needs `decodePackage` from `odf.js`, not from `documents.js`.** `documents.js` re-exports `ooxml.js`'s own `decodePackage` under the same name for OOXML packages, and that function cannot read an ODF `.odb` container at all — `commands/odb.ts` and the TUI's `format/open-document.ts` both import `decodePackage` from `odf.js` directly for exactly this reason. Importing it from the wrong package fails silently different, not loudly: it throws trying to parse a `.odb`'s manifest as an OOXML relationship file.
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { a as inferFormatFromExtension, n as resolveDefaultOutputPath, o as isDocumentFormat, r as writeOutput, t as readInput } from "./io-Cl3MaB0L.js";
2
+ import { a as odbFormSummary, c as writeOutput, d as inferFormatFromExtension, f as isDocumentFormat, i as formatOdbReportLines, l as loadProvidedFonts, n as describeOdbReport, o as readInput, r as formatOdbFormLines, s as resolveDefaultOutputPath, t as describeOdbForm } from "./odb-structure-CX_0zL8_.js";
3
3
  import { Command, CommanderError, InvalidArgumentError } from "commander";
4
4
  import { writeFile } from "node:fs/promises";
5
- import { OdbNoEmbeddedDataSourceError, OdbTableNotFoundError, OdbTableNotSpecifiedError, OdbUnsupportedFormatError, OdmUnresolvedSectionError, PdfEncryptedError, PdfParseError, createLocalDocumentConverter, odbToCsv, odbToXlsx, odmToPdf, readOdbTables, readPdf } from "documents.js";
5
+ import { OdbNoEmbeddedDataSourceError, OdbTableNotFoundError, OdbTableNotSpecifiedError, OdbUnsupportedFormatError, OdmUnresolvedSectionError, PdfEncryptedError, PdfParseError, UnrecognizedDocumentSchemaError, buildDocxPackage, buildMarkdownText, buildOdgPackage, buildOdpPackage, buildOdsPackage, buildOdtPackage, buildPptxPackage, createLocalDocumentConverter, documentFromJson, documentPackageWithSchema, encodeMarkdownText, encodePackage, odbToCsv, odbToXlsx, odmToPdf, readOdbForms, readOdbReports, readOdbTables, readPdf, writePdf } from "documents.js";
6
6
  import { basename, dirname, extname, join } from "node:path";
7
- import { decodePackage } from "odf.js";
7
+ import { decodePackage, encodePackage as encodePackage$1 } from "odf.js";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  //#region src/runtime/abort.ts
10
10
  function combineSignals(a, b) {
@@ -72,6 +72,22 @@ function createDiagnosticReporter(options) {
72
72
  }
73
73
  };
74
74
  }
75
+ function createFontSubstitutionReporter(options) {
76
+ const { json, quiet, command } = options;
77
+ return (substitution) => {
78
+ if (quiet) return;
79
+ if (json) {
80
+ process.stderr.write(`${JSON.stringify({
81
+ type: "font-substitution",
82
+ command,
83
+ ...substitution
84
+ })}\n`);
85
+ return;
86
+ }
87
+ const styleClause = `${substitution.requestedBold ? " bold" : ""}${substitution.requestedItalic ? " italic" : ""}`;
88
+ process.stderr.write(`[${command}] font substitution: "${substitution.requestedFamily}"${styleClause} -> "${substitution.resolvedFamily}" (${substitution.reason})\n`);
89
+ };
90
+ }
75
91
  function substitutionToDiagnostic(substitution, pageIndex) {
76
92
  return {
77
93
  severity: "warning",
@@ -80,6 +96,13 @@ function substitutionToDiagnostic(substitution, pageIndex) {
80
96
  pageIndex
81
97
  };
82
98
  }
99
+ function fontSubstitutionToDiagnostic(substitution) {
100
+ return {
101
+ severity: "info",
102
+ code: "font/substituted",
103
+ message: `"${`${substitution.requestedFamily}${substitution.requestedBold ? " bold" : ""}${substitution.requestedItalic ? " italic" : ""}`}" is not available; ${substitution.reason === "vendored-substitute" ? `substituted the metric-compatible "${substitution.resolvedFamily}"` : `substituted another face of "${substitution.resolvedFamily}"`}`
104
+ };
105
+ }
83
106
  function pdfDiagnosticToDiagnostic(diagnostic) {
84
107
  return {
85
108
  severity: diagnostic.severity,
@@ -97,6 +120,18 @@ function mapErrorToExit(error, abortReason) {
97
120
  }
98
121
  //#endregion
99
122
  //#region src/commands/shared.ts
123
+ const KNOWN_DOCUMENT_FORMATS = "docx, pptx, xlsx, odt, odp, ods, odg, odf, markdown, pdf";
124
+ function resolveTargetFormat(output, out, to) {
125
+ if (to !== void 0) {
126
+ if (!isDocumentFormat(to)) return { errorMessage: `unknown --to format '${to}'; expected one of ${KNOWN_DOCUMENT_FORMATS}` };
127
+ return { format: to };
128
+ }
129
+ const destination = output ?? out;
130
+ if (destination === void 0) return { errorMessage: "cannot infer a target format -- pass an output path with a recognised extension, --out with one, or --to <format>" };
131
+ const inferred = inferFormatFromExtension(destination);
132
+ if (inferred === void 0) return { errorMessage: `cannot infer a target format from '${destination}'; pass --to <format> instead` };
133
+ return { format: inferred };
134
+ }
100
135
  function formatError(error, verbose) {
101
136
  if (!(error instanceof Error)) return `error: ${String(error)}`;
102
137
  const stackClause = verbose && error.stack !== void 0 ? `\n${error.stack}` : "";
@@ -113,13 +148,22 @@ function buildConversionAction(source, target) {
113
148
  const { signal, getAbortReason } = createRuntimeSignal({ timeoutMs: options.timeoutMs });
114
149
  try {
115
150
  const inputBytes = await readInput(input, { signal });
151
+ const fonts = await loadProvidedFonts(options.fontFiles ?? [], { signal });
116
152
  const result = await createLocalDocumentConverter().convert({
117
153
  source: {
118
154
  format: source,
119
155
  bytes: new Uint8Array(inputBytes)
120
156
  },
121
157
  targetFormat: target
122
- }, { signal });
158
+ }, {
159
+ signal,
160
+ fonts,
161
+ onFontSubstitution: options.reportFontSubstitutions === true ? createFontSubstitutionReporter({
162
+ json: options.json,
163
+ quiet: options.quiet,
164
+ command
165
+ }) : void 0
166
+ });
123
167
  await writeOutput(resolvedOutput, result.document.bytes);
124
168
  const reporter = createDiagnosticReporter({
125
169
  json: options.json,
@@ -128,7 +172,7 @@ function buildConversionAction(source, target) {
128
172
  });
129
173
  for (const diagnostic of result.diagnostics) reporter.report(diagnostic);
130
174
  if (options.dumpPackage !== void 0) if (result.package === void 0) process.stderr.write(`[${command}] this conversion does not produce an intermediate DocumentPackage\n`);
131
- else await writeFile(options.dumpPackage, JSON.stringify(result.package, void 0, 2));
175
+ else await writeFile(options.dumpPackage, JSON.stringify(documentPackageWithSchema(result.package), void 0, 2));
132
176
  reporter.summarize({
133
177
  output: resolvedOutput,
134
178
  bytes: result.document.bytes.byteLength,
@@ -161,6 +205,14 @@ function addVerboseOption(command) {
161
205
  function addDumpPackageOption(command) {
162
206
  return command.option("--dump-package <file>", "write the intermediate DocumentPackage (content + layout) this conversion built to a JSON file");
163
207
  }
208
+ function collectFontFile(value, previous) {
209
+ return [...previous, value];
210
+ }
211
+ function addFontOptions(command) {
212
+ command.option("--font-file <path>", "embed this font file (.ttf/.otf) when the document asks for the family it declares; repeatable. The family, weight, and slope are read from the font's own 'name'/'OS/2' tables, so no accompanying family flag is needed", collectFontFile, []);
213
+ command.option("--report-font-substitutions", "print each font face that resolved to something other than what the document asked for to stderr, as it happens", false);
214
+ return command;
215
+ }
164
216
  function addConversionFlags(command) {
165
217
  addOutOption(command);
166
218
  addTimeoutOption(command);
@@ -178,21 +230,11 @@ function toConversionCommandOptions(options) {
178
230
  json: options.json,
179
231
  quiet: options.quiet,
180
232
  verbose: options.verbose,
181
- dumpPackage: options.dumpPackage
233
+ dumpPackage: options.dumpPackage,
234
+ fontFiles: options.fontFile,
235
+ reportFontSubstitutions: options.reportFontSubstitutions
182
236
  };
183
237
  }
184
- const KNOWN_FORMATS = "docx, pptx, xlsx, odt, odp, ods, odg, odf, pdf";
185
- function resolveGenericTarget(output, options) {
186
- if (options.to !== void 0) {
187
- if (!isDocumentFormat(options.to)) return { errorMessage: `unknown --to format '${options.to}'; expected one of ${KNOWN_FORMATS}` };
188
- return { format: options.to };
189
- }
190
- const destination = output ?? options.out;
191
- if (destination === void 0) return { errorMessage: "cannot infer a target format -- pass an output path with a recognised extension, --out with one, or --to <format>" };
192
- const inferred = inferFormatFromExtension(destination);
193
- if (inferred === void 0) return { errorMessage: `cannot infer a target format from '${destination}'; pass --to <format> instead` };
194
- return { format: inferred };
195
- }
196
238
  async function runGenericConvert(input, output, options) {
197
239
  const extension = extname(input).toLowerCase();
198
240
  if (extension === ".odm") {
@@ -205,10 +247,10 @@ async function runGenericConvert(input, output, options) {
205
247
  }
206
248
  const source = inferFormatFromExtension(input);
207
249
  if (source === void 0) {
208
- process.stderr.write(`convert: cannot infer a source format from '${input}'; rename the file with a recognised extension (${KNOWN_FORMATS}) or use one of the explicit '<source>-to-<target>' commands\n`);
250
+ process.stderr.write(`convert: cannot infer a source format from '${input}'; rename the file with a recognised extension (${KNOWN_DOCUMENT_FORMATS}) or use one of the explicit '<source>-to-<target>' commands\n`);
209
251
  return 2;
210
252
  }
211
- const target = resolveGenericTarget(output, options);
253
+ const target = resolveTargetFormat(output, options.out, options.to);
212
254
  if ("errorMessage" in target) {
213
255
  process.stderr.write(`convert: ${target.errorMessage}\n`);
214
256
  return 2;
@@ -222,6 +264,7 @@ function registerConversionCommands(program) {
222
264
  const command = program.command(`${commandName} <input> [output]`).description(`convert a ${source} document to ${target}`);
223
265
  addConversionFlags(command);
224
266
  addDumpPackageOption(command);
267
+ if (target === "pdf") addFontOptions(command);
225
268
  command.action(async (input, output, options) => {
226
269
  process.exitCode = await buildConversionAction(source, target)(input, output, toConversionCommandOptions(options));
227
270
  });
@@ -229,14 +272,15 @@ function registerConversionCommands(program) {
229
272
  const generic = program.command("convert <input> [output]").description("convert between any two supported document formats, inferring source/target from file extensions where possible");
230
273
  addConversionFlags(generic);
231
274
  addDumpPackageOption(generic);
232
- generic.option("--to <format>", `target format when it cannot be inferred from the output path (${KNOWN_FORMATS})`);
275
+ addFontOptions(generic);
276
+ generic.option("--to <format>", `target format when it cannot be inferred from the output path (${KNOWN_DOCUMENT_FORMATS})`);
233
277
  generic.action(async (input, output, options) => {
234
278
  process.exitCode = await runGenericConvert(input, output, options);
235
279
  });
236
280
  }
237
281
  //#endregion
238
282
  //#region src/commands/formats.ts
239
- const COMMANDS_NOT_LISTED = "odm-to-pdf, odb-to-csv, odb-to-xlsx, odb-tables, pdf-inspect";
283
+ const COMMANDS_NOT_LISTED = "odm-to-pdf, odb-to-csv, odb-to-xlsx, odb-tables, odb-forms, odb-reports, pdf-inspect, from-package";
240
284
  function registerFormatsCommand(program) {
241
285
  program.command("formats").description("list every source -> target conversion this CLI supports via a <source>-to-<target> command").option("--json", "emit the conversion list as a JSON array instead of a human-readable table", false).action((options) => {
242
286
  const { conversions } = createLocalDocumentConverter();
@@ -249,6 +293,86 @@ function registerFormatsCommand(program) {
249
293
  });
250
294
  }
251
295
  //#endregion
296
+ //#region src/commands/from-package.ts
297
+ function buildBytesForTarget(pkg, target) {
298
+ if (target === "pdf") {
299
+ if (pkg.layout === void 0) throw new Error("this DocumentPackage has no layout -- only a package dumped from a <format>-to-pdf or pdf-to-<format> conversion carries one; a bridge conversion's own dump (e.g. odt-to-docx) never does, so 'pdf' is not a reachable target from it");
300
+ return writePdf(pkg.layout);
301
+ }
302
+ switch (target) {
303
+ case "docx": return encodePackage(buildDocxPackage(pkg.content));
304
+ case "pptx": return encodePackage(buildPptxPackage(pkg.content));
305
+ case "odt": return encodePackage$1(buildOdtPackage(pkg.content));
306
+ case "odp": return encodePackage$1(buildOdpPackage(pkg.content));
307
+ case "ods": return encodePackage$1(buildOdsPackage(pkg.content));
308
+ case "odg": return encodePackage$1(buildOdgPackage(pkg.content));
309
+ case "markdown": return encodeMarkdownText(buildMarkdownText(pkg.content));
310
+ case "xlsx": throw new Error("'xlsx' cannot be built from a DocumentPackage directly -- documents.js does not re-export a ContentDocument-to-xlsx builder; convert to 'ods' here, then run 'ods-to-xlsx' on the result instead");
311
+ case "odf": throw new Error("'odf' (a standalone formula document) cannot be built from a DocumentPackage -- there is no ContentDocument-to-odf builder");
312
+ }
313
+ }
314
+ async function runFromPackage(input, output, options) {
315
+ const command = "from-package";
316
+ if (output !== void 0 && options.out !== void 0 && output !== options.out) {
317
+ process.stderr.write(`[${command}] conflicting output destinations: positional '${output}' and --out '${options.out}'\n`);
318
+ return 2;
319
+ }
320
+ const target = resolveTargetFormat(output, options.out, options.to);
321
+ if ("errorMessage" in target) {
322
+ process.stderr.write(`[${command}] ${target.errorMessage}\n`);
323
+ return 2;
324
+ }
325
+ const resolvedOutput = output ?? options.out ?? (input === "-" ? "-" : resolveDefaultOutputPath(input, target.format));
326
+ const { signal, getAbortReason } = createRuntimeSignal({ timeoutMs: options.timeout });
327
+ try {
328
+ const inputBytes = await readInput(input, { signal });
329
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(inputBytes);
330
+ let parsed;
331
+ try {
332
+ parsed = JSON.parse(text);
333
+ } catch (error) {
334
+ process.stderr.write(`[${command}] '${input}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}\n`);
335
+ return 1;
336
+ }
337
+ const result = documentFromJson(parsed);
338
+ if (result.kind !== "DocumentPackage") {
339
+ 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`);
340
+ return 2;
341
+ }
342
+ const bytes = buildBytesForTarget(result.value, target.format);
343
+ await writeOutput(resolvedOutput, bytes);
344
+ createDiagnosticReporter({
345
+ json: options.json,
346
+ quiet: options.quiet,
347
+ command
348
+ }).summarize({
349
+ output: resolvedOutput,
350
+ bytes: bytes.byteLength,
351
+ diagnosticCount: 0
352
+ });
353
+ return 0;
354
+ } catch (error) {
355
+ if (error instanceof UnrecognizedDocumentSchemaError) {
356
+ process.stderr.write(`[${command}] '${input}' has no recognised $schema -- only a file written by --dump-package can be read back by this command\n`);
357
+ return 1;
358
+ }
359
+ process.stderr.write(`${formatError(error, options.verbose)}\n`);
360
+ return mapErrorToExit(error, getAbortReason());
361
+ }
362
+ }
363
+ function registerFromPackageCommand(program) {
364
+ const command = program.command("from-package <input> [output]").description("read a DocumentPackage previously written by --dump-package and export it to a real target format");
365
+ addOutOption(command);
366
+ addTimeoutOption(command);
367
+ addJsonOption(command);
368
+ addQuietOption(command);
369
+ addVerboseOption(command);
370
+ command.option("--to <format>", `target format when it cannot be inferred from the output path (${KNOWN_DOCUMENT_FORMATS})`);
371
+ command.action(async (input, output, options) => {
372
+ process.exitCode = await runFromPackage(input, output, options);
373
+ });
374
+ }
375
+ //#endregion
252
376
  //#region src/commands/odb.ts
253
377
  function reportOdbError(command, error, verbose, abortReason) {
254
378
  if (error instanceof OdbNoEmbeddedDataSourceError || error instanceof OdbUnsupportedFormatError) {
@@ -350,6 +474,52 @@ async function runOdbTables(input, options) {
350
474
  return reportOdbError(command, error, false, getAbortReason());
351
475
  }
352
476
  }
477
+ async function runOdbForms(input, options) {
478
+ const command = "odb-forms";
479
+ const { signal, getAbortReason } = createRuntimeSignal({});
480
+ try {
481
+ const inputBytes = await readInput(input, { signal });
482
+ const forms = readOdbForms(decodePackage(new Uint8Array(inputBytes)));
483
+ if (options.json) {
484
+ process.stdout.write(`${JSON.stringify(forms.map((form) => odbFormSummary(form)))}\n`);
485
+ return 0;
486
+ }
487
+ if (forms.length === 0) {
488
+ process.stdout.write("This database declares no forms.\n");
489
+ return 0;
490
+ }
491
+ for (const form of forms) {
492
+ process.stdout.write(`${describeOdbForm(form)}\n`);
493
+ for (const line of formatOdbFormLines(form)) process.stdout.write(` ${line}\n`);
494
+ }
495
+ return 0;
496
+ } catch (error) {
497
+ return reportOdbError(command, error, false, getAbortReason());
498
+ }
499
+ }
500
+ async function runOdbReports(input, options) {
501
+ const command = "odb-reports";
502
+ const { signal, getAbortReason } = createRuntimeSignal({});
503
+ try {
504
+ const inputBytes = await readInput(input, { signal });
505
+ const reports = readOdbReports(decodePackage(new Uint8Array(inputBytes)));
506
+ if (options.json) {
507
+ process.stdout.write(`${JSON.stringify(reports)}\n`);
508
+ return 0;
509
+ }
510
+ if (reports.length === 0) {
511
+ process.stdout.write("This database declares no reports.\n");
512
+ return 0;
513
+ }
514
+ for (const report of reports) {
515
+ process.stdout.write(`${describeOdbReport(report)}\n`);
516
+ for (const line of formatOdbReportLines(report)) process.stdout.write(` ${line}\n`);
517
+ }
518
+ return 0;
519
+ } catch (error) {
520
+ return reportOdbError(command, error, false, getAbortReason());
521
+ }
522
+ }
353
523
  function registerOdbToXlsxCommand(program) {
354
524
  const command = program.command("odb-to-xlsx <input> [output]").description("extract every table an embedded .odb database declares into one xlsx workbook, one sheet per table");
355
525
  addOutOption(command);
@@ -378,10 +548,22 @@ function registerOdbTablesCommand(program) {
378
548
  process.exitCode = await runOdbTables(input, options);
379
549
  });
380
550
  }
551
+ function registerOdbFormsCommand(program) {
552
+ program.command("odb-forms <input>").description("list every form an .odb declares, with each form's own data source and its field-bound controls").option("--json", "emit the form structure as a JSON array instead of a human-readable report", false).action(async (input, options) => {
553
+ process.exitCode = await runOdbForms(input, options);
554
+ });
555
+ }
556
+ function registerOdbReportsCommand(program) {
557
+ program.command("odb-reports <input>").description("list every report an .odb declares, with each report's own data-source command, band/group structure, and rpt: formula expressions").option("--json", "emit the report structure as a JSON array instead of a human-readable report", false).action(async (input, options) => {
558
+ process.exitCode = await runOdbReports(input, options);
559
+ });
560
+ }
381
561
  function registerOdbCommands(program) {
382
562
  registerOdbToXlsxCommand(program);
383
563
  registerOdbToCsvCommand(program);
384
564
  registerOdbTablesCommand(program);
565
+ registerOdbFormsCommand(program);
566
+ registerOdbReportsCommand(program);
385
567
  }
386
568
  //#endregion
387
569
  //#region src/commands/odm.ts
@@ -417,11 +599,26 @@ async function runOdmToPdf(input, output, options) {
417
599
  command
418
600
  });
419
601
  let diagnosticCount = 0;
602
+ const reportFontSubstitution = options.reportFontSubstitutions === true ? createFontSubstitutionReporter({
603
+ json: options.json,
604
+ quiet: options.quiet,
605
+ command
606
+ }) : void 0;
420
607
  try {
421
608
  const inputBytes = await readInput(input, { signal });
609
+ const fonts = await loadProvidedFonts(options.fontFile ?? [], { signal });
422
610
  const bytes = odmToPdf(new Uint8Array(inputBytes), {
423
611
  signal,
424
612
  resolveSubDocument,
613
+ fonts,
614
+ onFontSubstitution: (substitution) => {
615
+ if (reportFontSubstitution !== void 0) {
616
+ reportFontSubstitution(substitution);
617
+ return;
618
+ }
619
+ diagnosticCount += 1;
620
+ reporter.report(fontSubstitutionToDiagnostic(substitution));
621
+ },
425
622
  onSubstitution: (substitution, context) => {
426
623
  diagnosticCount += 1;
427
624
  reporter.report(substitutionToDiagnostic(substitution, context.pageIndex));
@@ -450,6 +647,7 @@ function registerOdmCommand(program) {
450
647
  addJsonOption(command);
451
648
  addQuietOption(command);
452
649
  addVerboseOption(command);
650
+ addFontOptions(command);
453
651
  command.option("--chapters-dir <dir>", "directory to search for each unresolved chapter href, matched by the href's own basename");
454
652
  command.option("--chapter <href>=<file>", "resolve one chapter href to a local file explicitly; repeatable", collectChapterOverride, /* @__PURE__ */ new Map());
455
653
  command.action(async (input, output, options) => {
@@ -546,7 +744,7 @@ function registerPdfInspectCommand(program) {
546
744
  }
547
745
  //#endregion
548
746
  //#region package.json
549
- var version = "1.0.1";
747
+ var version = "1.2.0";
550
748
  //#endregion
551
749
  //#region src/program.ts
552
750
  function createProgram() {
@@ -559,6 +757,7 @@ function createProgram() {
559
757
  });
560
758
  registerConversionCommands(program);
561
759
  registerFormatsCommand(program);
760
+ registerFromPackageCommand(program);
562
761
  registerOdmCommand(program);
563
762
  registerOdbCommands(program);
564
763
  registerPdfInspectCommand(program);
@@ -568,7 +767,7 @@ function createProgram() {
568
767
  //#region src/cli.ts
569
768
  async function launchTui(startPath, signal) {
570
769
  try {
571
- const { runTui } = await import("./tui-CucYgqmL.js");
770
+ const { runTui } = await import("./tui-DD4bQp39.js");
572
771
  await runTui({
573
772
  startPath,
574
773
  signal