A command-line interface and an interactive terminal (Ink) app for
documents.js: every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/markdown conversion, bridge, and editor documents.js exposes, wired up as a scriptable subcommand or a full-screen terminal editor. Installs as eitherdocument-cliordoculi.
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.
graph TD
schema("document-schema.js")
ooxml("ooxml.js")
odf("odf.js")
pdfcodec("pdf-codec")
mdcodec("markdown-codec")
documents("documents.js")
mcp("document-mcp")
cli("document-cli")
schema --> ooxml
schema --> odf
schema --> pdfcodec
schema --> mdcodec
schema --> documents
ooxml --> documents
odf --> documents
pdfcodec --> documents
mdcodec --> documents
documents --> mcp
pdfcodec --> mcp
documents --> cli
odf --> cli
pdfcodec --> cli
click schema "https://github.com/ExaDev/document-schema.js" "document-schema.js"
click ooxml "https://github.com/ExaDev/ooxml.js" "ooxml.js"
click odf "https://github.com/ExaDev/odf.js" "odf.js"
click pdfcodec "https://github.com/ExaDev/pdf-codec" "pdf-codec"
click mdcodec "https://github.com/ExaDev/markdown-codec" "markdown-codec"
click documents "https://github.com/ExaDev/documents.js" "documents.js"
click mcp "https://github.com/ExaDev/document-mcp" "document-mcp"
click cli "https://github.com/ExaDev/document-cli" "document-cli"
style cli fill:#f9a825,stroke:#333,stroke-width:3px
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 (including a bounded SQL engine over an .odb's own tables and full report rendering), its PDF inspector, and its document metadata/source-font introspection become a single command-line invocation, and its seven live-view editors (docx/pptx/odt/odp/ods/odg/markdown) become a keyboard-driven terminal app that never needs a code editor open at all.
document-mcp is the sibling frontend over the identical documents.js library — an MCP server rather than a terminal CLI/TUI — so the two are independent consumers of one shared implementation, each exposing whatever subset of it suits a human at a terminal versus an MCP-speaking agent.
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.
Requires Node.js >=20.
npm i -g document-cli
# or, identically:
npm i -g doculiBoth names install the exact same package and the exact same binary — package.json's bin field declares both document-cli and doculi pointing at the one built entry point unconditionally, so there is no "real" name and an alias; pick whichever you find easier to type. This mirrors the alias-publishing pattern already established elsewhere in this package family (documents.js's own scoped @exadev/documents.js republish to GitHub Packages) — a second name for the same build, not a second build.
Every conversion and extraction command reads one input and writes one output. Pass - for either to use stdin/stdout instead of a file — useful for piping through other tools without a temp file:
document-cli docx-to-pdf report.docx report.pdf
cat report.docx | document-cli docx-to-pdf - - > report.pdfThe 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]:
document-cli docx-to-pdf report.docx report.pdf
document-cli ods-to-xlsx budget.ods budget.xlsxconvert <input> [output] — the same conversions through one generic command, inferring source format from the input's extension and target format from the output's extension (or --to <format> when the output path doesn't carry one, e.g. writing to stdout):
document-cli convert report.docx report.pdf
document-cli convert report.docx - --to pdf > report.pdfformats — lists every source -> target pair the commands above support (--json for a machine-readable array), plus a pointer to the commands not on that list because they don't fit the source/target shape (odm-to-pdf, odb-to-csv, odb-to-xlsx, odb-tables, odb-forms, odb-reports, pdf-inspect, from-package, fonts, docx-extras, metadata, set-metadata).
from-package <input> [output] — reads back a DocumentPackage JSON file a previous conversion wrote via --dump-package (below) and exports it to a real target format, closing the round trip --dump-package otherwise has no return path for. Target resolution matches convert: an output path's own extension, or --to <format> when it doesn't have one. pdf writes the package's own layout half directly (writePdf); every other format builds a fresh package from the content half through the identical buildXPackage function the matching pdf-to-X/bridge conversion already uses. xlsx 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:
document-cli docx-to-pdf report.docx report.pdf --dump-package report.package.json
document-cli from-package report.package.json report2.pdf
document-cli from-package report.package.json report.odt --to odtodm-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.
odb-to-xlsx <input> [output], odb-to-csv <input> [output], odb-tables <input> — extract table data from an embedded .odb database (HSQLDB TEXT script, HSQLDB CACHED binary row store, or Firebird gbak backup — detected automatically). odb-to-xlsx exports every table, one sheet each; odb-to-csv exports exactly one table (--table <name>, required whenever the database declares more than one); odb-tables lists every table's name, columns, and row count without writing anything:
document-cli odb-tables customers.odb
document-cli odb-to-csv customers.odb --table CUSTOMERS customers.csvodb-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:
document-cli odb-forms sales.odb
document-cli odb-reports sales.odb --jsonodb-query <input> — runs a bounded single-table SELECT over every table an embedded .odb extracts (documents.js's own src/odb/sql/ engine — no database anywhere in the path), given directly via --sql <text> or by naming one of the .odb's own saved queries via --query <savedName> (the two are mutually exclusive, and one of them is required). --json emits the bare { columns, rows } result set; otherwise a plain-text table:
document-cli odb-query sales.odb --sql "SELECT REGION, SUM(AMOUNT) FROM SALES GROUP BY REGION"
document-cli odb-query sales.odb --query HighValueSales --jsonodb-render-report <input> [output] — renders one of an .odb's own reports to a real document: its data-source command resolved and run through the same SQL engine odb-query uses, its rpt: formulas evaluated, its bands laid out, then built into docx, odt, or pdf — the only three targets a rendered report can become, since readOdbReportContent always produces a wordprocessing-variant document and no other format has a wordprocessing counterpart to build one into. --report <name> selects which report when the .odb declares more than one; --to <format> when the target can't be inferred from the output path:
document-cli odb-render-report sales.odb SalesByRegion.pdf --report SalesByRegionpdf-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:
document-cli pdf-inspect report.pdf
document-cli pdf-inspect report.pdf --jsonfonts <input> — lists every source-embedded font face a docx/pptx/odt/odp/ods/odg document carries (family, weight/style, byte length) — the same embedded faces every <format>-to-pdf conversion already extracts and renders through automatically (see Real fonts below); this command just reports what's there without converting anything. Rejects a format with no source-embedded-font concept at all (xlsx, pdf, markdown, odf), naming it:
document-cli fonts report.docxdocx-extras <input> — prints a docx's own comments, footnotes, headers, footers, and numbering definitions: data readDocxContent's ContentDocument shape has nowhere to carry, so documents.js reads it through a second, independent pass (readDocxExtras) over the same package. --json emits the raw DocxExtras object:
document-cli docx-extras report.docxmetadata <input> — prints a document's own title/author/subject/keywords/creator/producer/created/modified metadata, for any of the ten supported formats (docx, pptx, xlsx, odt, odp, ods, odg, odf, markdown, pdf). --json emits the raw metadata object:
document-cli metadata report.pdfset-metadata <input> [output] — patches a document's own title/author/subject/keywords, leaving every other field untouched (--set-title, --set-author, --set-subject, --set-keywords — the last a comma-separated list); source and target format must match, so run convert/from-package first if a different target format is also needed. A pdf source/target patches the parsed PDF directly (writePdf) with no layout engine involved — genuinely lossless for everything else on the page; every other format rebuilds a fresh package from that format's own ContentDocument, which for docx specifically is lossy (it drops everything docx-extras covers, since buildDocxPackage has no way to carry that data through a ContentDocument-only rebuild):
document-cli set-metadata report.docx report.docx --set-title "Q3 Report" --set-author "Finance"
document-cli set-metadata report.docx report.odt --set-keywords "draft,internal"tui [file] — launches the interactive terminal app; see The TUI below.
The 27 explicit conversions, convert, odm-to-pdf, odb-to-xlsx, odb-to-csv, set-metadata, and odb-render-report — every command that reads one file and writes one — share:
| Flag | Meaning |
|---|---|
-o, --out <file> |
Output path; defaults to the input path with the target format's own extension. Conflicts with a positional output argument that names a different path. |
--timeout <ms> |
Abort the run after this many milliseconds. |
--json |
Emit diagnostics and the result summary as newline-delimited JSON on stderr, instead of human-readable lines. |
-q, --quiet |
Suppress diagnostic and summary output (the JSON result-summary line still prints in --json mode, so a script consuming NDJSON always gets a terminating record). |
--verbose |
Include a full stack trace in the error line when the run fails. |
--dump-package <file> is one flag further, registered only on the 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-*/set-metadata don't expose the flag at all, since none of them goes through DocumentConverter.convert in the first place. odb-tables, odb-forms, odb-reports, fonts, docx-extras, metadata, formats, and pdf-inspect each take only their own --json (plus pdf-inspect's own --full); odb-query takes --sql <text>/--query <savedName> (mutually exclusive) alongside its own --json, with none of the shared flags above since it only reads and writes nothing; from-package and set-metadata each take --to <format> alongside the shared flags in this table; odb-render-report takes --report <name> and --to <format> alongside the shared flags and the font flags below; tui takes no flags at all, only an optional positional file.
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.
document-cli docx-to-pdf report.docx report.pdf \
--font-file ~/fonts/Calibri.ttf \
--font-file ~/fonts/Calibri-Bold.ttf \
--report-font-substitutionsThe 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.
--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.
Both flags are registered only where they can do something: the nine <format>-to-pdf conversions, convert, odm-to-pdf, and odb-render-report. The last two register them unconditionally (like convert, whose target isn't known until the output path or --to resolves) even though a docx/odt render has nothing to resolve fonts against — the same non-pdf no-op every other command in this list already documents. 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.
Diagnostics and the summary line always go to stderr; stdout is reserved for the converted bytes on any command writing to -.
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
The input was unusable — a malformed or encrypted PDF, or any other conversion failure not covered by the codes below. |
2 |
A usage error — bad flags, conflicting output destinations, an unrecognised format, or (for a bare/--help/--version invocation) commander's own exit path. |
3 |
documents.js needs more information to finish, not a different file — an unresolved .odm chapter, a .odb table that wasn't specified (or wasn't found, or has no embedded engine at all, or uses an unsupported HSQLDB script serialisation), or a .odb report that wasn't specified when the database declares more than one. |
124 |
The run's own --timeout elapsed before it finished. |
130 |
Interrupted by SIGINT (Ctrl+C). |
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.
It supports the same seven formats documents.js's live-view editors cover — docx, pptx, odt, odp, ods, odg, markdown — each with a full navigate/edit/save experience built on that format's editor (paragraphs and runs for docx/odt/markdown, 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. On a docx document, x from the paragraph/table list opens a read-only view of the document's own comments, footnotes, headers/footers, and numbering definitions — a docx-only concept with no odt equivalent, and the TUI counterpart to the docx-extras command, rendered through the identical src/docx-extras-format.ts line formatter so the two can't drift apart. m, global to every screen with a document open, shows that document's own title/author/subject/keywords/etc metadata read-only — every format the metadata command covers, including .odb/.pdf/.xlsx; documents.js's live-view editors have no metadata setter to mutate in place, so this TUI has no matching write screen (use the set-metadata command for that).
Markdown (.md/.markdown) shares the same paragraph/run/table body-list screens docx and odt already use, through documents.js's own MarkdownEditor (openMarkdown/createMarkdownEditor) — a genuine live view over a mutable ContentDocument, the same live-view contract every other editor here follows, even though there is no XmlElement tree underneath it the way there is for docx/odt (MarkdownEditor.toMarkdownText() re-serialises the whole document fresh on every call, rather than exposing a toBytes()). Appending a paragraph, appending a run, and toggling bold/italic all go through the identical reducer actions docx/odt use; a markdown run has no underline, colour, font family, or font size at all (CommonMark/GFM has no construct for any of the four), so those keys — along with image insertion, which MarkdownParagraph has no counterpart for — are simply absent from a markdown paragraph's own key hints rather than opening a prompt that could only end in a warning. A markdown table can be created and its cells edited through the same 'T' wizard and table-view screens docx/odt use, but GFM tables have no cell-merge concept, so a merge requested alongside table creation still creates the table (unmerged) and reports why the merge itself didn't happen. :view-source (markdown documents only) shows the literal text the document was opened with side by side with what a save would write right now — these can genuinely differ even with no edits made this session, from a heading-style, bullet-marker, or line-ending choice the writer normalises. Every save re-serialises the whole document fresh through buildMarkdownText, a deliberate, permanent consequence of structured editing rather than something to work around. Diagnostics from the read side (a clamped heading level, a dropped front-matter key, a fenced code block's own info string with nowhere to go, …) now surface into the same diagnostics panel a PDF export's own substitutions already populate, the moment a .md file is opened, not only on export. documents.js's own createMarkdownEditor() exists now, but this TUI does not yet wire a "new markdown document" flow into :new/the new-document picker, so a markdown document can still only be opened from an existing file.
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). Enter on a report's own detail screen renders it — its data-source command resolved, its rpt: formulas evaluated, its bands laid out — to a real docx/odt/pdf file, through the same two-field destination-path-then-font-list form the PDF-export screen below uses; the TUI counterpart to the odb-render-report command. A standalone .odf formula document has no TUI editor either — nothing to edit interactively, only a PDF conversion.
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.
The global bindings below apply everywhere; individual screens (a docx run's own bold/italic toggles, an ods cell's own value-kind picker) add their own on top:
| Keys | Action |
|---|---|
↑ / k |
Move the selection up |
↓ / j |
Move the selection down |
Enter / → / l |
Open or edit the selected item |
Esc / ← / h |
Go back to the previous screen |
PageUp / PageDown |
Scroll a page at a time |
Home / End |
Jump to the first or last item |
a |
Append a new item to the current list |
m |
Show the open document's metadata (read-only) |
Ctrl+S |
Save the open document |
Ctrl+W |
Close the open document |
Ctrl+Z |
Undo the last change |
q / Ctrl+C |
Quit |
: |
Open the command palette |
/ |
Search within the current screen |
? |
Show this help |
Ctrl+D |
Show the diagnostics panel |
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'sbuildConversionAction(source, target)is the one implementation behind every<source>-to-<target>command and the genericconvert— it partially applies a format pair and hands back a ready commander action, so the conversion-running logic (read input, callcreateLocalDocumentConverter().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,fonts,docx-extras,metadata}.tseach call their own documents.js function directly instead, since none of them fits the genericDocumentConverterport's bytes-in/bytes-out shape (odmToPdfneeds aresolveSubDocumentcallback,.odbextraction/query/report-rendering has no PDF conversion or port entry at all,pdf-inspectreads without converting, andfonts/docx-extras/metadataeach read a document without producing one).commands/set-metadata.tsalso bypassesbuildConversionActiondespite writing a file, sincesetDocumentMetadata(source, target, bytes, overrides)patches a document rather than converting one and requires source and target to match.src/odb-structure.tssits alongsidesrc/format.tsas a module shared by both layers: it turns anOdbForm/OdbReportinto a flat array of already-indented lines, which theodb-forms/odb-reportscommands join with newlines and the TUI's own form/report detail screens render one per list row;src/docx-extras-format.tsandsrc/sql-result-format.tsare the same idea for their own commands — the first turns aDocxExtrasvalue into the flat line listdocx-extrasprints and the TUI's ownDocxExtrasScreenrenders, the second turns anSqlResultSetinto an aligned plain-text table forodb-query.src/runtime/holds the process-level concerns every command shares:abort.ts'screateRuntimeSignal(oneSIGINTlistener 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 andmapErrorToExit,diagnostics.ts's stderr reporter,fonts.ts'sloadProvidedFonts(validating every--font-filepath into documents.js's ownProvidedFontshape, shared by every font-accepting command and the TUI's own PDF-export and report-render screens), andmetadata-format.ts'sformatMetadataLines(shared bymetadata,pdf-inspect, and the TUI's own metadata screen).src/tui/is the Ink app, entered lazily.src/cli.tsonly imports./tui/index.jsinside a dynamicimport(), called just once dispatch has already decided the TUI is actually running — a plaindocument-cli docx-to-pdf a binvocation never loads React, Ink, or any TUI screen module at all, andtsdown.config.ts's bin build correctly code-splits the TUI into its own lazily-loaded chunk as a result. Inside the TUI,state/reducer.tsandstate/types.tshold the singleAppState(a screen stack, the open document, undo history, overlays),format/open-document.tsis the one place bytes become an open document for every format, andscreens/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.src/index.tsre-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 wantsdocument-cli's conversion-running logic directly rather than spawning the bin as a subprocess.- One package, two npm names.
package.json'sbinfield listsdocument-clianddoculiunconditionally, both pointing at the same built entry point — there is no separate build, alias package, or npm alias mechanism involved, just two keys in onebinobject.
pnpm build # tsdown -> dist/ (a library build for src/index.ts, and a separate ESM-only bin build for src/cli.ts)
pnpm typecheck # tsc --noEmit
pnpm lint # eslint . --max-warnings 0
pnpm test # vitest run --project unit
pnpm test:watch # vitest --project unit
pnpm test:smoke # tsdown, then vitest run --project smoke -- spawns the built dist/cli.js as a real child process- The lazy TUI import is load-bearing, not incidental.
src/cli.tscomputes the dispatch token before doing anything else and only reachesawait import('./tui/index.js')on the bare/tuibranch — every other command path (all 27 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. - A bare invocation and an explicit
tuiinvocation fail differently on non-interactive stdout.document-cliwith no arguments and redirected stdout prints help and exits0, on the assumption that a bare invocation piped somewhere was more likely a forgotten argument than a deliberate TUI request.document-cli tuiwith redirected stdout is unambiguous, so it fails outright (exit2) rather than silently reinterpreting it as a help request. tsdown.config.tsdisablesfixedExtensionon both build passes.platform: 'node'defaults tsdown'sfixedExtensiontotrue, which would emitdist/cli.mjs/dist/index.mjsregardless ofpackage.json's own"type": "module"— mismatching the.js/.cjspathsbinandexportsactually name. Both entries setfixedExtension: falseexplicitly so the build output matches what's published.readOdbTablesneedsdecodeOdbPackagefromdocuments.js, notdecodePackage.documents.js's owndecodePackagere-exportsooxml.js's OOXML-only reader and cannot decode an ODF.odbcontainer at all;decodeDocumentPackage(format, bytes)dispatches toodf.jsinternally for every realDocumentFormatmember (odt/odp/ods/odg/odf), but.odbis 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.decodeOdbPackageis the.odb-specific sibling that decodes the identical raw ODF container directly —commands/odb.tsand the TUI'sformat/open-document.tsboth use it for exactly this reason.odf.jsstays a genuine direct dependency regardless: the TUI's own ods spreadsheet grid (screens/editors/ods/spreadsheet-grid.tsx) importscellReference/columnIndexToLettersfrom it directly for A1-style cell/column labelling, a small utility need unrelated to package decoding.- A TUI screen must call an editor's own accessors fresh on every render, never cache them. documents.js's live-view editors (
DocxRun,OdtParagraph,OdsCell, ...) mutate the real XML tree in place —editor.paragraphs()/slide.shapes()/sheet.cell(r, c)called once and stored inuseState/useMemogoes stale the instant any screen mutates the underlying document, with nothing in the type system or in React to catch it (seesrc/tui/state/types.ts's own doc comment onScreen). - The conversions this CLI runs carry the same fidelity limits documents.js itself documents — most notably that PDF-pivot conversions are not round-trip-lossless (line wrapping drifts under standard-14 font substitution, justified text renders left-aligned, tables and vector shapes don't reconstruct from a PDF), and that
pdf-to-odsrecovers only what a spreadsheet printed, never a real number/date/formula. None of that is specific to this CLI or the TUI; see documents.js's own README, particularly its Fidelity section, for the full, format-by-format account.
MIT