diff --git a/README.md b/README.md index 3b917b0..31aebf1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/document-cli) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/document-cli) [![Release](https://img.shields.io/github/v/release/ExaDev/document-cli)](https://github.com/ExaDev/document-cli/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/document-cli/ci.yml?branch=main)](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/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, 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. @@ -79,7 +79,7 @@ cat report.docx | document-cli docx-to-pdf - - > report.pdf ### Commands -**The explicit `-to-` 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 `-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-` 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 sixteen PDF-bypassing bridges — ten same-variant pairs that copy content directly between formats sharing one `ContentDocument` variant (`odt-to-docx`, `docx-to-odt`, `odp-to-pptx`, `pptx-to-odp`, `ods-to-xlsx`, `xlsx-to-ods`, `markdown-to-docx`, `docx-to-markdown`, `markdown-to-odt`, `odt-to-markdown`), plus six cross-variant pairs that swap a document's content variant through a semantic transform (`docx-to-pptx`, `pptx-to-docx`, `odt-to-odp`, `odp-to-odt`, `xlsx-to-markdown`, `markdown-to-xlsx`). Each takes ` [output]`: +**The explicit `-to-` conversions** — one command per pair `createLocalDocumentConverter().conversions` declares in `documents.js` (confirmed by running the built CLI's own `formats` command, not assumed): every ordered pair of the ten content formats — docx, pptx, xlsx, odt, odp, ods, odg, svg, csv, markdown — in both directions as a PDF-bypassing bridge (a pair whose formats share one `ContentDocument` variant, like `odt-to-docx` or `ods-to-xlsx`, copies content across directly; a cross-variant pair, like `docx-to-pptx`, `xlsx-to-markdown`, or `odg-to-svg`, swaps the document's content variant through a semantic transform); each of those ten plus `odf` into pdf (`docx-to-pdf`, `xlsx-to-pdf`, `csv-to-pdf`, `svg-to-pdf`, `odf-to-pdf`, ...); and pdf back into the same ten (`pdf-to-docx`, `pdf-to-xlsx`, `pdf-to-csv`, `pdf-to-svg`, ... — there is no `pdf-to-odf`; `odf-to-pdf` is one-way, see documents.js's own README). Each takes ` [output]`: ```sh document-cli docx-to-pdf report.docx report.pdf @@ -95,7 +95,7 @@ document-cli convert report.docx - --to pdf > report.pdf **`formats`** — lists every `source -> target` pair the commands above support (`--json` for a machine-readable array), plus a pointer to the commands not on that list because they don't fit the source/target shape (`odm-to-pdf`, `odb-to-csv`, `odb-to-xlsx`, `odb-tables`, `odb-forms`, `odb-reports`, `pdf-inspect`, `from-package`, `fonts`, `docx-extras`, `metadata`, `set-metadata`). -**`from-package [output]`** — reads back a `DocumentPackage` JSON file a previous conversion wrote via `--dump-package` (below) and exports it to a real target format, closing the round trip `--dump-package` otherwise has no return path for. Target resolution matches `convert`: an output path's own extension, or `--to ` when it doesn't have one. `pdf` rebuilds the layout from the package's own recorded positions — each content node's `frames` plus the package's `pages` geometry (`layoutDocumentFromPackage` → `writePdf`; a package no longer carries a separate `layout` half at all); every other format builds a fresh package from the `content` half through the identical `buildXPackage` function the matching `pdf-to-X`/bridge conversion already uses — `xlsx` included, via `ooxml.js`'s own `buildXlsxPackage`. `odf` is the one target rejected outright, since a standalone formula document has no write path from `ContentDocument` at all. Only a file genuinely written by a current `--dump-package` round-trips here; anything else fails with its `$schema` mismatch named, and a dump from the documents.js 1.x `formatVersion` 1 shape (content plus a separate `layout` half) is rejected with an error naming the version change rather than silently accepted: +**`from-package [output]`** — reads back a `DocumentPackage` JSON file a previous conversion wrote via `--dump-package` (below) and exports it to a real target format, closing the round trip `--dump-package` otherwise has no return path for. Target resolution matches `convert`: an output path's own extension, or `--to ` when it doesn't have one. `pdf` rebuilds the layout from the package's own recorded positions — each content node's `frames` plus the package's `pages` geometry (`layoutDocumentFromPackage` → `writePdf`; a package no longer carries a separate `layout` half at all); every other format builds a fresh package from the `content` half through the identical `buildXPackage` function the matching `pdf-to-X`/bridge conversion already uses — `xlsx` included, via `ooxml.js`'s own `buildXlsxPackage`. `odf` is the one target rejected outright, since a standalone formula document has no write path from `ContentDocument` at all; 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; anything else fails with its `$schema` mismatch named, and a dump from the documents.js 1.x `formatVersion` 1 shape (content plus a separate `layout` half) is rejected with an error naming the version change rather than silently accepted: ```sh document-cli docx-to-pdf report.docx report.pdf --dump-package report.package.json @@ -139,7 +139,7 @@ document-cli pdf-inspect report.pdf document-cli pdf-inspect report.pdf --json ``` -**`fonts `** — 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 `-to-pdf` conversion already extracts and renders through automatically (see [Real fonts](#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: +**`fonts `** — 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 `-to-pdf` conversion already extracts and renders through automatically (see [Real fonts](#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, csv, svg, pdf, markdown, odf), naming it: ```sh document-cli fonts report.docx @@ -151,13 +151,13 @@ document-cli fonts report.docx document-cli docx-extras report.docx ``` -**`metadata `** — 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: +**`metadata `** — prints a document's own title/author/subject/keywords/creator/producer/created/modified metadata, for any of the twelve supported formats (docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, pdf); csv and svg carry no metadata container of their own, so they always report none. `--json` emits the raw metadata object: ```sh document-cli metadata report.pdf ``` -**`set-metadata [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): +**`set-metadata [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); a `csv` or `svg` source/target is rejected outright, since plain text has no metadata container and a rebuild would silently drop the override: ```sh document-cli set-metadata report.docx report.docx --set-title "Q3 Report" --set-author "Finance" @@ -178,7 +178,9 @@ The explicit conversions, `convert`, `odm-to-pdf`, `odb-to-xlsx`, `odb-to-csv`, | `-q, --quiet` | Suppress diagnostic and summary output (the JSON result-summary line still prints in `--json` mode, so a script consuming NDJSON always gets a terminating record). | | `--verbose` | Include a full stack trace in the error line when the run fails. | -`--dump-package ` is one flag further, registered only on the explicit conversions and `convert` — it writes the intermediate `DocumentPackage` (content carrying its own per-node rendered `frames`, plus the `pages` geometry those frames index into) that conversion built to a JSON file, tagged with its own `$schema` so `from-package` (above) can read it back in. Every conversion populates one, `odf-to-pdf` included — its dump carries a `formula`-kind content and the one A4 page it renders (the formula's glyphs travel through the PDF writer's own positioned-formula channel rather than as frame-stamped page content); the sixteen PDF-bypassing bridges (the ten same-variant pairs `odt-to-docx`/`docx-to-odt`, `odp-to-pptx`/`pptx-to-odp`, `ods-to-xlsx`/`xlsx-to-ods`, `markdown-to-docx`/`docx-to-markdown`, `markdown-to-odt`/`odt-to-markdown`, plus the six cross-variant pairs `docx-to-pptx`/`pptx-to-docx`, `odt-to-odp`/`odp-to-odt`, `xlsx-to-markdown`/`markdown-to-xlsx`) populate one too, just with `pages` always absent, since a bridge never runs a layout engine. `odm-to-pdf`/`odb-*`/`set-metadata` don't expose the flag at all, since none of them goes through `DocumentConverter.convert` in the first place. `odb-tables`, `odb-forms`, `odb-reports`, `fonts`, `docx-extras`, `metadata`, `formats`, and `pdf-inspect` each take only their own `--json` (plus `pdf-inspect`'s own `--full`); `odb-query` takes `--sql `/`--query ` (mutually exclusive) alongside its own `--json`, with none of the shared flags above since it only reads and writes nothing; `from-package` and `set-metadata` each take `--to ` alongside the shared flags in this table; `odb-render-report` takes `--report ` and `--to ` alongside the shared flags and the font flags below; `tui` takes no flags at all, only an optional positional file. +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` (content carrying its own per-node rendered `frames`, plus the `pages` geometry those frames index into) that conversion built to a JSON file, tagged with its own `$schema` so `from-package` (above) can read it back in. Every conversion populates one, `odf-to-pdf` included — its dump carries a `formula`-kind content and the one A4 page it renders (the formula's glyphs travel through the PDF writer's own positioned-formula channel rather than as frame-stamped page content); 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. ### Real fonts @@ -195,7 +197,7 @@ The flag is repeatable, takes a `.ttf`/`.otf` path, and needs **no accompanying `--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 `-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-` 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. +Both flags are registered only where they can do something: the `-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-` 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 `-`. @@ -206,7 +208,7 @@ Diagnostics and the summary line always go to stderr; stdout is reserved for the | `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. | +| `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), a `.odb` report that wasn't specified when the database declares more than one, a csv target whose source carries more than one sheet (or a `--sheet` that names one it doesn't), or an svg target whose source carries more than one page (or a `--page` that indexes past the last). | | `124` | The run's own `--timeout` elapsed before it finished. | | `130` | Interrupted by `SIGINT` (Ctrl+C). | @@ -218,7 +220,7 @@ It supports the same seven formats documents.js's live-view editors cover — do 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. +Three further kinds of format 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`, `.csv`, or `.svg` opens as a converted PDF preview — documents.js has no spreadsheet or svg editor to hold a live view into, so opening one runs `xlsxToPdf`, `csvToPdf`, or `svgToPdf` 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 the same conversion 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. diff --git a/package.json b/package.json index d9a106f..2c013f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "document-cli", "version": "2.0.0", - "description": "CLI and interactive Ink TUI for documents.js: every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/markdown conversion, bridge, and editor as a scriptable command or a terminal app.", + "description": "CLI and interactive Ink TUI for documents.js: every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/csv/svg/markdown conversion, bridge, and editor as a scriptable command or a terminal app.", "type": "module", "repository": { "type": "git", @@ -88,7 +88,7 @@ "packageManager": "pnpm@11.6.0", "dependencies": { "commander": "^15.0.0", - "documents.js": "^2.0.0", + "documents.js": "^2.3.0", "ink": "^7.1.1", "ink-text-input": "^6.0.0", "react": "^19.2.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ae96bf..458aa77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^15.0.0 version: 15.0.0 documents.js: - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.3.0 + version: 2.3.0 ink: specifier: ^7.1.1 version: 7.1.1(@types/react@19.2.18)(react@19.2.8) @@ -1736,8 +1736,8 @@ packages: resolution: {integrity: sha512-XDu/+fo56WrXrcR3f16xH5lYEQX+3b4W6kJELRNFwrrWxOqhQBQepXMkCi+niSrgCEIcfaC1IeaGPlZ8oj5gSw==} engines: {node: '>=20'} - documents.js@2.0.0: - resolution: {integrity: sha512-ryI83OYP6JimwjrWsU7DEV7fGZuLy2QDyEoO6WRZW+ZFPHtJh+EQ1Q3wtaHxnW9gHizjumh00rjtlxc2rx9HXQ==} + documents.js@2.3.0: + resolution: {integrity: sha512-FTQIA9PRiHYPjn2NFEVdUp3rlvfd7smFs+HU6WAYhPFC47V4TzHBkIWhq9ptIbhtpldAThESKh61eLYYQrHd5A==} engines: {node: '>=20'} hasBin: true @@ -3070,6 +3070,10 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + temml@0.13.4: + resolution: {integrity: sha512-k1yolMBswx34Jw9hZn5Xh2/GBlwqlW+wiN7QaUYUMhSa1ypfti6rlCfSmCxWyooxH1G32C/Z+qVvgAsBOELZBQ==} + engines: {node: '>=18.13.0'} + temp-dir@3.0.0: resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} engines: {node: '>=14.16'} @@ -4999,7 +5003,7 @@ snapshots: dependencies: zod: 4.4.3 - documents.js@2.0.0: + documents.js@2.3.0: dependencies: byte-codec: 1.1.9 document-schema.js: 3.2.0 @@ -5008,6 +5012,7 @@ snapshots: odf.js: 3.0.1 ooxml.js: 2.16.0 pdf-codec: 2.2.35 + temml: 0.13.4 zod: 4.4.3 dot-prop@5.3.0: @@ -6316,6 +6321,8 @@ snapshots: tagged-tag@1.0.0: {} + temml@0.13.4: {} + temp-dir@3.0.0: {} tempy@3.2.0: diff --git a/src/commands/convert-selection.test.ts b/src/commands/convert-selection.test.ts new file mode 100644 index 0000000..7dceab2 --- /dev/null +++ b/src/commands/convert-selection.test.ts @@ -0,0 +1,160 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createOdg, createOds } from 'documents.js'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createProgram } from '../program'; +import { EXIT_NEEDS_INFO, EXIT_SUCCESS } from '../runtime/exit-codes'; + +// Drives the real assembled commander program end to end against a real multi-sheet .ods and a real multi-page .odg, asserting the three csv/svg edge selections this CLI threads into documents.js's own ConversionOptions: a csv target that would be ambiguous fails with exit 3 naming the sheets (the CLI's own translation of CsvSheetNotSpecifiedError), --sheet answers it, --delimiter reaches both the csv write edge (and the csv read edge, via the read-side fixture below), and --page picks which page an svg target draws. The odg pages carry a rect each at disjoint coordinates rather than textboxes because buildSvgText itself draws vectors only -- a draw:frame shape has no SVG vector representation and is reported as svg/shape-unsupported instead, so a textbox would assert nothing. + +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 stderr: string; +} + +async function runCli(args: readonly string[]): Promise { + const stderrChunks: string[] = []; + 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 { + stderrSpy.mockRestore(); + } + return { exitCode: process.exitCode, stderr: stderrChunks.join('') }; +} + +// Two sheets so a csv target has to be told which one; two columns in the picked sheet so --delimiter has a boundary to draw. +function multiSheetOdsBytes(): Uint8Array { + const editor = createOds(); + const first = editor.sheets()[0]; + if (first === undefined) throw new Error('createOds produced no sheets'); + first.cell(0, 0).value = { kind: 'string', value: 'AlphaCell' }; + first.cell(0, 1).value = { kind: 'number', value: 42 }; + const beta = editor.addSheet('Beta'); + beta.cell(0, 0).value = { kind: 'string', value: 'BetaCell' }; + beta.cell(0, 1).value = { kind: 'number', value: 7 }; + return editor.toBytes(); +} + +// Two pages whose only vector sits at disjoint coordinates, so which page an svg target drew is a substring check on the emitted . +function multiPageOdgBytes(): Uint8Array { + const editor = createOdg(); + editor.addPage().addRect({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 50 } }); + editor.addPage().addRect({ frame: { xPt: 300, yPt: 400, widthPt: 100, heightPt: 50 } }); + return editor.toBytes(); +} + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), 'document-cli-selection-')); + await writeFile(join(workspace, 'multi.ods'), multiSheetOdsBytes()); + await writeFile(join(workspace, 'multi.odg'), multiPageOdgBytes()); + // A semicolon-delimited csv the read side needs --delimiter to parse as two columns rather than one. + await writeFile(join(workspace, 'semi.csv'), 'Left;Right\nfirst;second\n'); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +beforeEach(() => { + savedExitCode = process.exitCode; +}); + +afterEach(() => { + process.exitCode = savedExitCode; +}); + +describe('ods-to-csv sheet selection', () => { + it('fails with exit 3 naming the sheets when the source has more than one and --sheet is absent', async () => { + const run = await runCli(['ods-to-csv', join(workspace, 'multi.ods'), join(workspace, 'unpicked.csv')]); + expect(run.exitCode).toBe(EXIT_NEEDS_INFO); + expect(run.stderr).toContain('Sheet1'); + expect(run.stderr).toContain('Beta'); + }); + + it('writes the named sheet with --sheet', async () => { + const output = join(workspace, 'beta.csv'); + const run = await runCli(['ods-to-csv', join(workspace, 'multi.ods'), output, '--sheet', 'Beta']); + expect(run.exitCode).toBe(EXIT_SUCCESS); + await expect(readFile(output, 'utf8')).resolves.toContain('BetaCell,7'); + }); + + it('fails with exit 3 when --sheet names a sheet the source does not have', async () => { + const run = await runCli(['ods-to-csv', join(workspace, 'multi.ods'), join(workspace, 'missing.csv'), '--sheet', 'Nope']); + expect(run.exitCode).toBe(EXIT_NEEDS_INFO); + expect(run.stderr).toContain('Nope'); + }); +}); + +describe('csv delimiter selection', () => { + it('writes the csv target with --delimiter', async () => { + const output = join(workspace, 'semi-out.csv'); + const run = await runCli(['ods-to-csv', join(workspace, 'multi.ods'), output, '--sheet', 'Sheet1', '--delimiter', ';']); + expect(run.exitCode).toBe(EXIT_SUCCESS); + await expect(readFile(output, 'utf8')).resolves.toContain('AlphaCell;42'); + }); + + it('reads the csv source with --delimiter', async () => { + const output = join(workspace, 'semi-to.md'); + const run = await runCli(['csv-to-markdown', join(workspace, 'semi.csv'), output, '--delimiter', ';']); + expect(run.exitCode).toBe(EXIT_SUCCESS); + const markdown = await readFile(output, 'utf8'); + expect(markdown).toContain('Left'); + expect(markdown).toContain('first'); + // A single comma-free table row proves the read split on semicolons: an unparsed 'first;second' cell would surface verbatim. + expect(markdown).not.toContain('first;second'); + }); +}); + +describe('odg-to-svg page selection', () => { + it('fails with exit 3 naming the page count when the source has more than one page and --page is absent', async () => { + const run = await runCli(['odg-to-svg', join(workspace, 'multi.odg'), join(workspace, 'unpicked.svg')]); + expect(run.exitCode).toBe(EXIT_NEEDS_INFO); + expect(run.stderr).toContain('page'); + }); + + it('draws the 0-based --page index selected', async () => { + const output = join(workspace, 'page1.svg'); + const run = await runCli(['odg-to-svg', join(workspace, 'multi.odg'), output, '--page', '1']); + expect(run.exitCode).toBe(EXIT_SUCCESS); + const svg = await readFile(output, 'utf8'); + expect(svg).toContain(' { + const run = await runCli(['odg-to-svg', join(workspace, 'multi.odg'), join(workspace, 'over.svg'), '--page', '5']); + expect(run.exitCode).toBe(EXIT_NEEDS_INFO); + expect(run.stderr).toContain('page index 5'); + }); +}); + +describe('csv and svg through the generic convert command', () => { + it('converts a csv source to pdf', async () => { + const output = join(workspace, 'semi.pdf'); + const run = await runCli(['convert', join(workspace, 'semi.csv'), output, '--delimiter', ';']); + expect(run.exitCode).toBe(EXIT_SUCCESS); + // A minimal but real PDF: the header announces the format and the byte length clears the smallest well-formed file. + const bytes = await readFile(output); + expect(bytes.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + expect(bytes.byteLength).toBeGreaterThan(100); + }); + + it('carries --page through the generic convert command to an svg target', async () => { + const output = join(workspace, 'generic.svg'); + const run = await runCli(['convert', join(workspace, 'multi.odg'), output, '--page', '0']); + expect(run.exitCode).toBe(EXIT_SUCCESS); + const svg = await readFile(output, 'utf8'); + expect(svg).toContain(' { process.exitCode = await buildConversionAction(source, target)(input, output, toConversionCommandOptions(options)); }); @@ -79,6 +92,10 @@ export function registerConversionCommands(program: Command): void { addDumpPackageOption(generic); // Unconditionally here, unlike the explicit per-pair commands above: this command's target is only known once --to or the output path has been resolved at run time, and pdf is one of the targets it resolves to. A run that lands on some other target simply passes fonts the port has nothing to resolve them against, which local.ts already documents as a no-op for a non-layout edge. addFontOptions(generic); + // Same reasoning for the csv/svg edge selections: a run that lands on a target with no csv or svg edge simply passes options the port has nothing to hand them to. + addDelimiterOption(generic); + addSheetOption(generic); + addPageOption(generic); generic.option('--to ', `target format when it cannot be inferred from the output path (${KNOWN_DOCUMENT_FORMATS})`); generic.action(async (input: string, output: string | undefined, options: GenericConvertCliOptions) => { process.exitCode = await runGenericConvert(input, output, options); diff --git a/src/commands/from-package.ts b/src/commands/from-package.ts index f802e2d..039da36 100644 --- a/src/commands/from-package.ts +++ b/src/commands/from-package.ts @@ -1,13 +1,13 @@ import { type Command } from 'commander'; -import { UnrecognizedDocumentSchemaError, buildDocumentBytes, documentFromJson, documentSchemaKindOf } from 'documents.js'; +import { UnrecognizedDocumentSchemaError, buildCsvText, buildDocumentBytes, buildSvgText, documentFromJson, documentSchemaKindOf, encodeCsvText, encodeSvgText } from 'documents.js'; import { createRuntimeSignal } from '../runtime/abort'; import { createDiagnosticReporter } from '../runtime/diagnostics'; import { EXIT_INPUT_ERROR, EXIT_SUCCESS, EXIT_USAGE_ERROR, mapErrorToExit } from '../runtime/exit-codes'; import { readInput, resolveDefaultOutputPath, writeOutput } from '../runtime/io'; import { KNOWN_DOCUMENT_FORMATS, formatError, resolveTargetFormat } from './shared'; -import { addJsonOption, addOutOption, addQuietOption, addTimeoutOption, addVerboseOption, type ConversionCliFlags } from './options'; +import { addDelimiterOption, addJsonOption, addOutOption, addPageOption, addQuietOption, addSheetOption, addTimeoutOption, addVerboseOption, type ConversionCliFlags, type SelectionCliFlags } from './options'; -interface FromPackageCliOptions extends ConversionCliFlags { +interface FromPackageCliOptions extends ConversionCliFlags, SelectionCliFlags { readonly to?: string; } @@ -59,7 +59,13 @@ async function runFromPackage(input: string, output: string | undefined, options return EXIT_USAGE_ERROR; } - const bytes = buildDocumentBytes(result.value, target.format); + // csv and svg are the two targets whose codecs take selection options buildDocumentBytes cannot pass (its content.write contract is options-free, so a multi-sheet package would fail CsvSheetNotSpecifiedError with no flag to answer it), so they are built through the identical buildCsvText/buildSvgText functions the codec registry's own write wrappers call, carrying this command's --delimiter/--sheet/--page straight through. + const bytes = + target.format === 'csv' + ? encodeCsvText(buildCsvText(result.value.content, { delimiter: options.delimiter, sheet: options.sheet })) + : target.format === 'svg' + ? encodeSvgText(buildSvgText(result.value.content, { page: options.page })) + : buildDocumentBytes(result.value, target.format); await writeOutput(resolvedOutput, bytes); const reporter = createDiagnosticReporter({ json: options.json, quiet: options.quiet, command }); @@ -85,6 +91,10 @@ export function registerFromPackageCommand(program: Command): void { addJsonOption(command); addQuietOption(command); addVerboseOption(command); + // Unconditionally, like the generic `convert` command's own font flags: the target is only known once --to or the output path resolves at run time, and csv/svg are two of the targets it can resolve to. + addDelimiterOption(command); + addSheetOption(command); + addPageOption(command); command.option('--to ', `target format when it cannot be inferred from the output path (${KNOWN_DOCUMENT_FORMATS})`); command.action(async (input: string, output: string | undefined, options: FromPackageCliOptions) => { process.exitCode = await runFromPackage(input, output, options); diff --git a/src/commands/options.ts b/src/commands/options.ts index 154164b..e1429d9 100644 --- a/src/commands/options.ts +++ b/src/commands/options.ts @@ -66,3 +66,23 @@ export interface FontCliFlags { readonly fontFile?: readonly string[]; readonly reportFontSubstitutions?: boolean; } + +// The three selection flags documents.js's converter threads to its csv and svg edges: a csv source reads (and a csv target writes) with `delimiter`, a csv target picks `sheet` when the source document carries more than one, and an svg target picks `page` when the source document has more than one (0-based, matching the array index documents.js's own SvgPageNotFoundError reports). Registered only on the commands whose fixed format pair can reach the edge in question, plus unconditionally on `convert` and `from-package` whose target is only known at run time -- the same registration reasoning addFontOptions's own comment documents for the font flags. +export function addDelimiterOption(command: Command): Command { + return command.option('--delimiter ', 'field delimiter a csv source reads with, or a csv target writes with (default \',\')'); +} + +export function addSheetOption(command: Command): Command { + return command.option('--sheet ', 'the sheet a csv target writes, when the source document has more than one'); +} + +export function addPageOption(command: Command): Command { + return command.option('--page ', 'the 0-based page an svg target draws, when the source document has more than one', (value: string) => Number.parseInt(value, 10)); +} + +// The three attributes the helpers above register, kept out of ConversionCliFlags for the same reason FontCliFlags is: they exist only on the subset of commands the helpers were applied to. +export interface SelectionCliFlags { + readonly delimiter?: string; + readonly sheet?: string; + readonly page?: number; +} diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 40df1d1..28ac15f 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -10,7 +10,7 @@ import { createFilesystemMarkdownImageResolver } from '../runtime/markdown-image import { readInput, resolveDefaultOutputPath, writeOutput } from '../runtime/io'; // Every DocumentFormat this CLI's commands know how to name in a usage error -- shared between the generic `convert` command (commands/convert.ts) and `from-package` (commands/from-package.ts), the two commands whose target format is not already fixed by their own name. -export const KNOWN_DOCUMENT_FORMATS = 'docx, pptx, xlsx, odt, odp, ods, odg, odf, markdown, pdf'; +export const KNOWN_DOCUMENT_FORMATS = 'docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, pdf'; // Resolves a target DocumentFormat the same way for both callers above: an explicit --to always wins (it is the caller stating intent unambiguously), falling back to the output path's own extension, and finally failing with a usage error naming exactly what is missing. export function resolveTargetFormat(output: string | undefined, out: string | undefined, to: string | undefined): { readonly format: DocumentFormat } | { readonly errorMessage: string } { @@ -41,6 +41,10 @@ export interface ConversionCommandOptions { // Both absent on a command addFontOptions (commands/options.ts) was never applied to -- a pdf-to- reconstruction or a format-to-format bridge, neither of which resolves a typeface at all. readonly fontFiles?: readonly string[]; readonly reportFontSubstitutions?: boolean; + // The csv/svg edge selections from commands/options.ts's own SelectionCliFlags -- threaded straight into the port's own ConversionOptions, which passes them only to the edges that read them (a csv read/write delimiter and sheet pick, an svg write page pick). Undefined on a pair with no csv or svg edge, where the port has nothing to hand them to. + readonly delimiter?: string; + readonly sheet?: string; + readonly page?: number; } // One clean line for a human, with a full stack trace appended only under --verbose -- a bare stack trace on every failure is noise for the common "wrong file" case, but indispensable when actually debugging this CLI itself. @@ -82,6 +86,9 @@ export function buildConversionAction( onFontSubstitution: options.reportFontSubstitutions === true ? createFontSubstitutionReporter({ json: options.json, quiet: options.quiet, command }) : undefined, // Resolve a markdown source's own non-data: image destinations against the input file's directory, so `convert notes.md` embeds `![](./image.png)` rather than degrading it to alt text. Ignored by every non-markdown conversion (the port threads it only to the markdown edges), so wiring it unconditionally is a no-op for docx/pptx/odt/... sources. For stdin (`-`) the base directory is the current working directory. images: createFilesystemMarkdownImageResolver(input === '-' ? '.' : dirname(resolve(input))), + delimiter: options.delimiter, + sheet: options.sheet, + page: options.page, }, ); diff --git a/src/format.test.ts b/src/format.test.ts index 4a602a8..b93a75b 100644 --- a/src/format.test.ts +++ b/src/format.test.ts @@ -3,7 +3,7 @@ import { formatToExtension, inferFormatFromExtension, isDocumentFormat } from '. describe('isDocumentFormat', () => { it('accepts every recognised format string', () => { - for (const format of ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'odf', 'markdown', 'pdf']) { + for (const format of ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'odf', 'csv', 'markdown', 'pdf']) { expect(isDocumentFormat(format)).toBe(true); } }); @@ -70,6 +70,11 @@ describe('inferFormatFromExtension', () => { expect(inferFormatFromExtension('macro.xlsm')).toBe('xlsx'); expect(inferFormatFromExtension('macro.pptm')).toBe('pptx'); }); + + it('infers the csv and svg text formats from their own extensions', () => { + expect(inferFormatFromExtension('table.csv')).toBe('csv'); + expect(inferFormatFromExtension('drawing.svg')).toBe('svg'); + }); }); describe('formatToExtension', () => { @@ -84,6 +89,8 @@ describe('formatToExtension', () => { ['ods', 'ods'], ['odg', 'odg'], ['odf', 'odf'], + ['svg', 'svg'], + ['csv', 'csv'], ['markdown', 'md'], ['pdf', 'pdf'], ]; diff --git a/src/format.ts b/src/format.ts index 3d09f99..5565f6b 100644 --- a/src/format.ts +++ b/src/format.ts @@ -21,6 +21,8 @@ const EXTENSION_TO_FORMAT: Readonly> = { otg: 'odg', odf: 'odf', otf: 'odf', + csv: 'csv', + svg: 'svg', markdown: 'markdown', md: 'markdown', pdf: 'pdf', @@ -35,6 +37,8 @@ const FORMAT_TO_EXTENSION: Readonly> = { ods: 'ods', odg: 'odg', odf: 'odf', + csv: 'csv', + svg: 'svg', markdown: 'md', pdf: 'pdf', }; diff --git a/src/program.ts b/src/program.ts index 45799a8..07deea2 100644 --- a/src/program.ts +++ b/src/program.ts @@ -16,7 +16,7 @@ import { version } from '../package.json'; // Builds the fully assembled commander program, but never parses argv or exits the process itself -- that is src/cli.ts's job, so this stays testable as pure construction and importable from anywhere (including the Ink TUI, which shells out to individual commands rather than re-implementing conversion logic of its own). export function createProgram(): Command { const program = new Command('document-cli'); - program.description('every documents.js docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/markdown conversion, bridge, and inspector as a scriptable command'); + program.description('every documents.js docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/csv/svg/markdown conversion, bridge, and inspector as a scriptable command'); program.version(version); // Without exitOverride, a commander usage error (or --help/--version) calls process.exit() directly from deep inside .parse()/.parseAsync(), which is exactly the hard, uncontrolled exit this codebase's own convention (set process.exitCode, let Node drain and exit naturally) exists to avoid. Registering a callback here is the only way to prevent that call (see commander's own Command#_exit -- it falls through to a bare process.exit(exitCode) immediately after invoking the callback unless the callback throws), so process.exitCode is set correctly *before* rethrowing; by the time this throws, the exit code is already right regardless of what, if anything, the eventual .parseAsync() caller in src/cli.ts does with the rejection. diff --git a/src/runtime/exit-codes.test.ts b/src/runtime/exit-codes.test.ts index 8a6ce18..5ca3ec6 100644 --- a/src/runtime/exit-codes.test.ts +++ b/src/runtime/exit-codes.test.ts @@ -1,11 +1,16 @@ import { + CsvSheetNotFoundError, + CsvSheetNotSpecifiedError, OdbNoEmbeddedDataSourceError, + OdbReportNotSpecifiedError, OdbTableNotFoundError, OdbTableNotSpecifiedError, OdbUnsupportedFormatError, OdmUnresolvedSectionError, PdfEncryptedError, PdfParseError, + SvgMultiPageNotSpecifiedError, + SvgPageNotFoundError, } from 'documents.js'; import { describe, expect, it } from 'vitest'; import { EXIT_INPUT_ERROR, EXIT_INTERRUPTED, EXIT_NEEDS_INFO, EXIT_TIMEOUT, mapErrorToExit } from './exit-codes'; @@ -43,6 +48,26 @@ describe('mapErrorToExit', () => { expect(mapErrorToExit(new OdbUnsupportedFormatError('unrecognised-engine', 'no known embedded database engine detected'), undefined)).toBe(EXIT_NEEDS_INFO); }); + it('maps OdbReportNotSpecifiedError to EXIT_NEEDS_INFO', () => { + expect(mapErrorToExit(new OdbReportNotSpecifiedError(['SalesByRegion', 'StockByWarehouse']), undefined)).toBe(EXIT_NEEDS_INFO); + }); + + it('maps CsvSheetNotSpecifiedError to EXIT_NEEDS_INFO', () => { + expect(mapErrorToExit(new CsvSheetNotSpecifiedError(['Sheet1', 'Sheet2']), undefined)).toBe(EXIT_NEEDS_INFO); + }); + + it('maps CsvSheetNotFoundError to EXIT_NEEDS_INFO', () => { + expect(mapErrorToExit(new CsvSheetNotFoundError('MISSING', ['Sheet1', 'Sheet2']), undefined)).toBe(EXIT_NEEDS_INFO); + }); + + it('maps SvgMultiPageNotSpecifiedError to EXIT_NEEDS_INFO', () => { + expect(mapErrorToExit(new SvgMultiPageNotSpecifiedError(3), undefined)).toBe(EXIT_NEEDS_INFO); + }); + + it('maps SvgPageNotFoundError to EXIT_NEEDS_INFO', () => { + expect(mapErrorToExit(new SvgPageNotFoundError(7, 3), undefined)).toBe(EXIT_NEEDS_INFO); + }); + it('maps PdfEncryptedError to EXIT_INPUT_ERROR', () => { expect(mapErrorToExit(new PdfEncryptedError('/Encrypt present'), undefined)).toBe(EXIT_INPUT_ERROR); }); diff --git a/src/runtime/exit-codes.ts b/src/runtime/exit-codes.ts index c4c446c..bb2b935 100644 --- a/src/runtime/exit-codes.ts +++ b/src/runtime/exit-codes.ts @@ -1,4 +1,6 @@ import { + CsvSheetNotFoundError, + CsvSheetNotSpecifiedError, HsqldbSqlEvaluationError, HsqldbSqlParseError, HsqldbSqlUnsupportedError, @@ -10,6 +12,8 @@ import { OdmUnresolvedSectionError, PdfEncryptedError, PdfParseError, + SvgMultiPageNotSpecifiedError, + SvgPageNotFoundError, UnsupportedFontSourceFormatError, } from 'documents.js'; @@ -29,14 +33,18 @@ export function mapErrorToExit(error: unknown, abortReason: 'interrupt' | 'timeo if (abortReason === 'timeout') { return EXIT_TIMEOUT; } - // These six all mean "documents.js already told the caller exactly what extra input it needs" (which .odm chapter hrefs are unresolved, which .odb table or report to pick, which format isn't embedded) -- distinct from an ordinary unusable-input failure because the fix is supplying more information, not a different file. + // These ten all mean "documents.js already told the caller exactly what extra input it needs" (which .odm chapter hrefs are unresolved, which .odb table or report to pick, which format isn't embedded, which sheet a csv target should write or which page an svg target should draw) -- distinct from an ordinary unusable-input failure because the fix is supplying more information, not a different file. if ( error instanceof OdmUnresolvedSectionError || error instanceof OdbTableNotSpecifiedError || error instanceof OdbTableNotFoundError || error instanceof OdbNoEmbeddedDataSourceError || error instanceof OdbUnsupportedFormatError || - error instanceof OdbReportNotSpecifiedError + error instanceof OdbReportNotSpecifiedError || + error instanceof CsvSheetNotSpecifiedError || + error instanceof CsvSheetNotFoundError || + error instanceof SvgMultiPageNotSpecifiedError || + error instanceof SvgPageNotFoundError ) { return EXIT_NEEDS_INFO; } diff --git a/src/tui/format/export-pdf.ts b/src/tui/format/export-pdf.ts index 12fd756..51a3030 100644 --- a/src/tui/format/export-pdf.ts +++ b/src/tui/format/export-pdf.ts @@ -1,5 +1,5 @@ import { writeFile } from 'node:fs/promises'; -import { docxToPdf, encodeMarkdownText, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pptxToPdf, xlsxToPdf, type DocumentToPdfOptions, type ProvidedFont } from 'documents.js'; +import { csvToPdf, docxToPdf, encodeMarkdownText, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pptxToPdf, svgToPdf, xlsxToPdf, type DocumentToPdfOptions, type ProvidedFont } from 'documents.js'; import { loadProvidedFonts } from '../../runtime/fonts.js'; import type { Diagnostic, OpenDocument } from '../state/types.js'; @@ -40,12 +40,22 @@ export async function exportToPdf(openDocument: OpenDocument, destinationPath: s await writeFile(destinationPath, pdfBytes); return; } - // xlsx has no editor to read current bytes from (see state/types.ts's own XlsxOpenDocument doc comment) -- the original bytes captured at open time are re-converted here, with this call's own real fonts/diagnostics options, rather than reusing the fixed preview conversion `openDocumentAtPath` computed to build the read-only viewer. + // xlsx has no editor to read current bytes from (see state/types.ts's own XlsxOpenDocument doc comment) -- the original bytes captured at open time are re-converted here, with this call's own real fonts/diagnostics options, rather than reusing the fixed preview conversion `openDocumentAtPath` computed to build the read-only viewer. csv and svg are the identical no-editor story (their own OpenDocument doc comments), each re-converted through its own to-Pdf function the same way. if (openDocument.format === 'xlsx') { const pdfBytes = xlsxToPdf(openDocument.bytes, pdfOptions); await writeFile(destinationPath, pdfBytes); return; } + if (openDocument.format === 'csv') { + const pdfBytes = csvToPdf(openDocument.bytes, pdfOptions); + await writeFile(destinationPath, pdfBytes); + return; + } + if (openDocument.format === 'svg') { + const pdfBytes = svgToPdf(openDocument.bytes, pdfOptions); + await writeFile(destinationPath, pdfBytes); + return; + } const bytes = openDocument.editor.toBytes(); const pdfBytes = convert(openDocument.format, bytes, pdfOptions); await writeFile(destinationPath, pdfBytes); diff --git a/src/tui/format/open-document.test.ts b/src/tui/format/open-document.test.ts index 9576daa..fe86c5f 100644 --- a/src/tui/format/open-document.test.ts +++ b/src/tui/format/open-document.test.ts @@ -53,6 +53,48 @@ describe('openDocumentAtPath for .xlsx', () => { }); }); +describe('openDocumentAtPath for .csv and .svg', () => { + it('opens a csv read-only as a converted PDF preview, exactly like an xlsx', async () => { + const path = join(workspace, 'table.csv'); + const bytes = new TextEncoder().encode('name,amount\nalice,1\n'); + await writeFile(path, bytes); + + const doc = await openDocumentAtPath(path); + if (doc.format !== 'csv') { + throw new Error(`expected an open csv document, got ${doc.format}`); + } + expect(doc.path).toBe(path); + expect(doc.layout.pages.length).toBeGreaterThan(0); + expect(doc.bytes).toStrictEqual(bytes); + }); + + it('opens an svg read-only as a converted PDF preview, exactly like an xlsx', async () => { + const path = join(workspace, 'drawing.svg'); + const bytes = new TextEncoder().encode(''); + await writeFile(path, bytes); + + const doc = await openDocumentAtPath(path); + if (doc.format !== 'svg') { + throw new Error(`expected an open svg document, got ${doc.format}`); + } + expect(doc.path).toBe(path); + expect(doc.layout.pages.length).toBeGreaterThan(0); + expect(doc.bytes).toStrictEqual(bytes); + }); + + it('cannot write either format back to disk -- both open read-only, the same group as .odb and .xlsx', async () => { + const csvPath = join(workspace, 'table.csv'); + await writeFile(csvPath, new TextEncoder().encode('a,b\n1,2\n')); + const csvDoc = await openDocumentAtPath(csvPath); + await expect(saveDocumentTo(csvDoc, join(workspace, 'copy.csv'))).rejects.toThrow(/read-only/); + + const svgPath = join(workspace, 'drawing.svg'); + await writeFile(svgPath, new TextEncoder().encode('')); + const svgDoc = await openDocumentAtPath(svgPath); + await expect(saveDocumentTo(svgDoc, join(workspace, 'copy.svg'))).rejects.toThrow(/read-only/); + }); +}); + describe('openDocumentAtPath / saveDocumentTo for .md', () => { it('opens a real markdown file into a structured MarkdownEditor, edits it through the shared paragraph-family actions, and saves back out as valid, re-parseable markdown containing the edit', async () => { const path = join(workspace, 'notes.md'); diff --git a/src/tui/format/open-document.ts b/src/tui/format/open-document.ts index 0016484..a9f1b67 100644 --- a/src/tui/format/open-document.ts +++ b/src/tui/format/open-document.ts @@ -1,5 +1,5 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { createDocx, createOdg, createOdp, createOds, createOdt, createPdf, createPptx, decodeMarkdownText, decodeOdbPackage, encodeMarkdownText, openDocx, openMarkdown, openOdg, openOdp, openOds, openOdt, openPdf, openPptx, readOdbForms, readOdbReports, readOdbTables, readPdf, xlsxToPdf } from 'documents.js'; +import { createDocx, createOdg, createOdp, createOds, createOdt, createPdf, createPptx, csvToPdf, decodeMarkdownText, decodeOdbPackage, encodeMarkdownText, openDocx, openMarkdown, openOdg, openOdp, openOds, openOdt, openPdf, openPptx, readOdbForms, readOdbReports, readOdbTables, readPdf, svgToPdf, xlsxToPdf } from 'documents.js'; import type { Diagnostic, EditableFormat, OpenDocument } from '../state/types.js'; import { detectFormat } from './detect-format.js'; @@ -57,6 +57,11 @@ export async function openDocumentAtPath(path: string, options: OpenDocumentAtPa // documents.js has no XlsxEditor and no readXlsxContent re-exported from its own public surface (see the doc comment on XlsxOpenDocument in state/types.ts), so a .xlsx opens read-only as a converted PDF preview: xlsxToPdf once here for the LayoutDocument the pdf page-list/page-items/item-detail screens already know how to browse, plus the original bytes kept alongside for a real export to re-run xlsxToPdf with the caller's own fonts/diagnostics later (see export-pdf.ts). case 'xlsx': return { format, layout: readPdf(xlsxToPdf(bytes)), bytes, path }; + // csv and svg are the same read-only-preview shape as xlsx (see their own OpenDocument doc comments in state/types.ts): no editor exists for either text format, so each opens through its own to-Pdf conversion once and browses the result through the shared pdf screen family, with the original bytes kept for a real export to re-convert with the caller's own fonts/diagnostics later. + case 'csv': + return { format, layout: readPdf(csvToPdf(bytes)), bytes, path }; + case 'svg': + return { format, layout: readPdf(svgToPdf(bytes)), bytes, path }; case 'odf': throw new Error('A standalone .odf formula document has no editor; convert it to PDF (odfToPdf) instead'); } @@ -84,7 +89,7 @@ export function createNewDocument(format: EditableFormat): OpenDocument { } export async function saveDocumentTo(openDocument: OpenDocument, path: string): Promise { - if (openDocument.format === 'odb' || openDocument.format === 'xlsx') { + if (openDocument.format === 'odb' || openDocument.format === 'xlsx' || openDocument.format === 'csv' || openDocument.format === 'svg') { throw new Error(`A ${openDocument.format} document is opened read-only and cannot be written back`); } // MarkdownEditor has no toBytes() (see MarkdownOpenDocument's own doc comment) -- every save re-serialises the whole document fresh through buildMarkdownText (via toMarkdownText()), even one with no edits at all this session. This is a deliberate, permanent consequence of structured editing, not something to work around: a live-view paragraph/run tree has no "untouched bytes" to leave alone the way a docx's XmlElement tree does, so the written text can legitimately differ from whatever was last on disk (heading style, bullet marker, line-ending normalisation -- see README.md's own markdown Gotchas). diff --git a/src/tui/format/read-metadata.ts b/src/tui/format/read-metadata.ts index 31d6f17..58b6215 100644 --- a/src/tui/format/read-metadata.ts +++ b/src/tui/format/read-metadata.ts @@ -1,7 +1,7 @@ import { type LayoutMetadata, readDocxContent, readMarkdownContent, readOdgContent, readOdpContent, readOdsContent, readOdtContent, readPptxContent } from 'documents.js'; import type { OpenDocument } from '../state/types.js'; -// The single place in the TUI that turns an OpenDocument into its own LayoutMetadata, mirroring export-pdf.ts's own per-format dispatch style. docx/pptx/odt/odp/ods/odg all read `doc.editor.toPackage()` -- the live view's own already-decoded package (ooxml.js's for docx/pptx, odf.js's for odt/odp/ods/odg; see state/types.ts's own RULE at the top of the file for why this is called fresh on every render rather than cached), fed straight into that format's own readXContent. markdown has a live-view MarkdownEditor but no package at all -- `readMarkdownContent` runs on `doc.editor.toMarkdownText()` (re-serialised fresh, reflecting in-progress edits, matching export-pdf.ts's own convention), the same role `.editor.toPackage()` plays elsewhere. pdf and xlsx both already carry a LayoutMetadata directly on `.layout` (xlsx's own `.layout` is the throwaway xlsxToPdf-then-readPdf preview open-document.ts already computed at open time -- see that module's own XlsxOpenDocument doc comment), so neither needs a read call here at all. odb has no document-level metadata concept anywhere in this codebase (it is a table/form/report container, not a single document with its own title/author/etc.) -- this throws rather than fabricating an empty LayoutMetadata, and the metadata screen itself is the one place that catches it and shows a plain message instead of crashing. +// The single place in the TUI that turns an OpenDocument into its own LayoutMetadata, mirroring export-pdf.ts's own per-format dispatch style. docx/pptx/odt/odp/ods/odg all read `doc.editor.toPackage()` -- the live view's own already-decoded package (ooxml.js's for docx/pptx, odf.js's for odt/odp/ods/odg; see state/types.ts's own RULE at the top of the file for why this is called fresh on every render rather than cached), fed straight into that format's own readXContent. markdown has a live-view MarkdownEditor but no package at all -- `readMarkdownContent` runs on `doc.editor.toMarkdownText()` (re-serialised fresh, reflecting in-progress edits, matching export-pdf.ts's own convention), the same role `.editor.toPackage()` plays elsewhere. pdf, xlsx, csv, and svg all already carry a LayoutMetadata directly on `.layout` (each read-only preview format's own `.layout` is the throwaway to-Pdf-then-readPdf conversion open-document.ts already computed at open time -- see that module's own XlsxOpenDocument/CsvOpenDocument/SvgOpenDocument doc comments), so none of them needs a read call here at all. odb has no document-level metadata concept anywhere in this codebase (it is a table/form/report container, not a single document with its own title/author/etc.) -- this throws rather than fabricating an empty LayoutMetadata, and the metadata screen itself is the one place that catches it and shows a plain message instead of crashing. export function metadataFor(doc: OpenDocument): LayoutMetadata { switch (doc.format) { case 'docx': @@ -20,6 +20,8 @@ export function metadataFor(doc: OpenDocument): LayoutMetadata { return readMarkdownContent(doc.editor.toMarkdownText()).metadata; case 'pdf': case 'xlsx': + case 'csv': + case 'svg': return doc.layout.metadata; case 'odb': throw new Error('A .odb database has no document-level metadata -- it is a table/form/report container, not a single document with its own title/author/etc.'); diff --git a/src/tui/screens/editors/pdf/shared.ts b/src/tui/screens/editors/pdf/shared.ts index 226b97e..66d35a2 100644 --- a/src/tui/screens/editors/pdf/shared.ts +++ b/src/tui/screens/editors/pdf/shared.ts @@ -1,20 +1,20 @@ import type { LayoutColor, LayoutSubpath } from 'documents.js'; -import type { OpenDocument, PdfOpenDocument, XlsxOpenDocument } from '../../../state/types.js'; +import type { CsvOpenDocument, OpenDocument, PdfOpenDocument, SvgOpenDocument, XlsxOpenDocument } from '../../../state/types.js'; import { parseColorField, parseStrokeField } from '../../shared/vector-fields.js'; export { parseColorField, parseStrokeField }; export { parseNumberField } from '../../shared/text.js'; -// Every screen in this directory is only ever reached from `pdfPageList`, the root screen `rootScreenForFormat` produces for either an open PDF document or an open xlsx workbook (opened read-only as a converted PDF preview -- see state/types.ts's own XlsxOpenDocument doc comment) -- so `state.openDocument` is always one of these two by the time any screen here renders. Both carry the identical `.layout: LayoutDocument` field this whole screen group reads from, and nothing else, which is exactly what lets one screen family serve both formats with no xlsx-specific branch anywhere in page-list.tsx/page-items.tsx/item-detail.tsx. This throws rather than falling back to an empty view because a mismatch would mean the app router itself is broken, not a recoverable, user-facing condition. -export function requirePdfDocument(openDocument: OpenDocument | undefined): PdfOpenDocument | XlsxOpenDocument { - if (openDocument?.format !== 'pdf' && openDocument?.format !== 'xlsx') { - throw new Error('A PDF inspection screen rendered without an open PDF or xlsx document; the app router only reaches this screen group from pdfPageList, which is only ever the root screen of one of those two formats.'); +// Every screen in this directory is only ever reached from `pdfPageList`, the root screen `rootScreenForFormat` produces for an open PDF document or for one of the three formats opened read-only as a converted PDF preview (an xlsx workbook, a csv sheet, an svg drawing -- see state/types.ts's own XlsxOpenDocument/CsvOpenDocument/SvgOpenDocument doc comments) -- so `state.openDocument` is always one of these four by the time any screen here renders. All four carry the identical `.layout: LayoutDocument` field this whole screen group reads from, and nothing else, which is exactly what lets one screen family serve all of them with no per-format branch anywhere in page-list.tsx/page-items.tsx/item-detail.tsx. This throws rather than falling back to an empty view because a mismatch would mean the app router itself is broken, not a recoverable, user-facing condition. +export function requirePdfDocument(openDocument: OpenDocument | undefined): PdfOpenDocument | XlsxOpenDocument | CsvOpenDocument | SvgOpenDocument { + if (openDocument?.format !== 'pdf' && openDocument?.format !== 'xlsx' && openDocument?.format !== 'csv' && openDocument?.format !== 'svg') { + throw new Error('A PDF inspection screen rendered without an open PDF, xlsx, csv, or svg document; the app router only reaches this screen group from pdfPageList, which is only ever the root screen of one of those four formats.'); } return openDocument; } -// The editing-capable narrowing of the above: an xlsx workbook opens as a fixed, one-shot PDF preview with no live `PdfEditor` behind it at all (see XlsxOpenDocument's own doc comment -- it carries `layout`/`bytes`, never an `editor`), so add/edit/delete only ever make sense for a genuine `'pdf'`-format document. Screens call this only from the code paths that mutate (the add-item flow, item-detail's field editor); the plain read-only list/dump views keep using `requirePdfDocument` above so an opened xlsx preview still browses exactly like a real PDF. -export function isEditablePdfDocument(doc: PdfOpenDocument | XlsxOpenDocument): doc is PdfOpenDocument { +// The editing-capable narrowing of the above: an xlsx workbook, csv sheet, or svg drawing opens as a fixed, one-shot PDF preview with no live `PdfEditor` behind it at all (see those formats' own OpenDocument doc comments -- each carries `layout`/`bytes`, never an `editor`), so add/edit/delete only ever make sense for a genuine `'pdf'`-format document. Screens call this only from the code paths that mutate (the add-item flow, item-detail's field editor); the plain read-only list/dump views keep using `requirePdfDocument` above so an opened preview format still browses exactly like a real PDF. +export function isEditablePdfDocument(doc: PdfOpenDocument | XlsxOpenDocument | CsvOpenDocument | SvgOpenDocument): doc is PdfOpenDocument { return doc.format === 'pdf'; } diff --git a/src/tui/screens/launcher.tsx b/src/tui/screens/launcher.tsx index 049d510..c982d5a 100644 --- a/src/tui/screens/launcher.tsx +++ b/src/tui/screens/launcher.tsx @@ -25,7 +25,7 @@ export function LauncherScreen(): ReactElement { return ( document-cli - A terminal editor for docx, pptx, odt, odp, ods, odg, markdown, odb and pdf -- xlsx opens as a read-only PDF preview. + A terminal editor for docx, pptx, odt, odp, ods, odg, markdown, odb and pdf -- xlsx, csv and svg open as read-only PDF previews. o Open a document diff --git a/src/tui/state/reducer.ts b/src/tui/state/reducer.ts index 1b8f30d..27ad048 100644 --- a/src/tui/state/reducer.ts +++ b/src/tui/state/reducer.ts @@ -114,6 +114,10 @@ function documentWithPath(doc: OpenDocument, path: string): OpenDocument { return { format: 'markdown', editor: doc.editor, originalText: doc.originalText, path }; case 'xlsx': return { format: 'xlsx', layout: doc.layout, bytes: doc.bytes, path }; + case 'csv': + return { format: 'csv', layout: doc.layout, bytes: doc.bytes, path }; + case 'svg': + return { format: 'svg', layout: doc.layout, bytes: doc.bytes, path }; } } @@ -532,8 +536,8 @@ export function appReducer(state: AppState, action: Action): AppState { stack: [rootScreenForFormat(action.doc.format)], }, 'info', - // xlsx has no editor to open at all -- action.doc is already a read-only PDF-preview conversion by the time it reaches here (see format/open-document.ts) -- so this is the one format whose "opened" message doubles as pointing the way to the one thing that can actually be done with it next. - action.doc.format === 'xlsx' ? `Opened ${action.path} as a read-only PDF preview -- press ':' then 'export pdf' to save it as a real PDF` : `Opened ${action.path}`, + // xlsx, csv, and svg have no editor to open at all -- action.doc is already a read-only PDF-preview conversion by the time it reaches here (see format/open-document.ts) -- so these are the formats whose "opened" message doubles as pointing the way to the one thing that can actually be done with them next. + action.doc.format === 'xlsx' || action.doc.format === 'csv' || action.doc.format === 'svg' ? `Opened ${action.path} as a read-only PDF preview -- press ':' then 'export pdf' to save it as a real PDF` : `Opened ${action.path}`, ); case 'OPEN_FILE_ERROR': @@ -1305,7 +1309,7 @@ export function appReducer(state: AppState, action: Action): AppState { if (doc === undefined) { return withStatus(state, 'info', 'There is nothing to undo'); } - if (doc.format === 'odb' || doc.format === 'xlsx') { + if (doc.format === 'odb' || doc.format === 'xlsx' || doc.format === 'csv' || doc.format === 'svg') { return withStatus(state, 'warning', `A ${doc.format} document is read-only, so it has no history to undo`); } const snapshot = state.undoStack.at(-1); diff --git a/src/tui/state/types.ts b/src/tui/state/types.ts index e226d3d..2dde7a7 100644 --- a/src/tui/state/types.ts +++ b/src/tui/state/types.ts @@ -113,13 +113,29 @@ export interface XlsxOpenDocument { readonly path: string; } -// The seven formats that have a live-view editor, and therefore support every mutating action, `editor.toBytes()` saving, undo snapshots. `odb`/`xlsx` are read-only sources; `pdf` joined this union once documents.js gained a real live-view `PdfEditor` -- see PdfOpenDocument's own doc comment. `pdf` is deliberately excluded from exportToPdf's own conversion set even though it is editable now: there is no docxToPdf-equivalent "convert a PDF to a PDF" function, and there does not need to be one -- editing and saving a PDF in place needs no conversion step at all. +// csv is the same read-only-preview story as xlsx one variant over: documents.js has no csv editor (a csv file is one sheet of raw RFC 4180 text, not a package with an XML tree to hold a live view into), but it does have `csvToPdf`, so a .csv opens as that conversion's own `readPdf` result and browses through the identical pdf page-list family. A multi-sheet source never reaches here -- a csv file is exactly one sheet by construction, so no sheet selection is needed at open time the way a spreadsheet-to-csv conversion needs one at write time. +export interface CsvOpenDocument { + readonly format: 'csv'; + readonly layout: LayoutDocument; + readonly bytes: Uint8Array; + readonly path: string; +} + +// svg mirrors csv: no editor (an SVG source is one drawing page of XML text with no live-view object), but a genuine `svgToPdf` conversion, opened read-only as its own `readPdf` result through the shared pdf screen family. An SVG source is likewise exactly one page by construction, so the page selection a multi-page drawing's own svg write edge demands never applies on the read side. +export interface SvgOpenDocument { + readonly format: 'svg'; + readonly layout: LayoutDocument; + readonly bytes: Uint8Array; + readonly path: string; +} + +// The seven formats that have a live-view editor, and therefore support every mutating action, `editor.toBytes()` saving, undo snapshots. `odb`/`xlsx`/`csv`/`svg` are read-only sources; `pdf` joined this union once documents.js gained a real live-view `PdfEditor` -- see PdfOpenDocument's own doc comment. `pdf` is deliberately excluded from exportToPdf's own conversion set even though it is editable now: there is no docxToPdf-equivalent "convert a PDF to a PDF" function, and there does not need to be one -- editing and saving a PDF in place needs no conversion step at all. export type EditableOpenDocument = DocxOpenDocument | PptxOpenDocument | OdtOpenDocument | OdpOpenDocument | OdsOpenDocument | OdgOpenDocument | PdfOpenDocument; // Every format that can be written back to disk at all: the seven live-view-editor formats above, plus markdown through its own live-view MarkdownEditor. This is a strictly broader question than "does this have a `.editor` object" -- markdown genuinely does have one now, but `MarkdownEditor` has no `toBytes()` (it re-serialises the whole document fresh via `toMarkdownText()` instead, see MarkdownOpenDocument's own doc comment), which is exactly why markdown is NOT folded into EditableOpenDocument itself: every EditableOpenDocument call site (`reopenEditable` in reducer.ts, the `.editor.toBytes()` branches in exportToPdf/saveDocumentTo) assumes `.editor.toBytes()` exists verbatim. `mutate`/`mutateGuarded` (reducer.ts) DO take the wider `WritableOpenDocument`, via a small `toUndoSnapshot` helper that branches on the one place the two byte<->text boundaries genuinely differ. Screens that only need "can this be saved, and what extension does it get" (file-picker.tsx, save-as-prompt.tsx) should check WritableOpenDocument/isWritableDocument instead of EditableOpenDocument/isEditableDocument. export type WritableOpenDocument = EditableOpenDocument | MarkdownOpenDocument; -export type OpenDocument = WritableOpenDocument | OdbOpenDocument | XlsxOpenDocument; +export type OpenDocument = WritableOpenDocument | OdbOpenDocument | XlsxOpenDocument | CsvOpenDocument | SvgOpenDocument; export type EditableFormat = EditableOpenDocument['format']; @@ -279,6 +295,8 @@ export function rootScreenForFormat(format: OpenDocumentFormat): Screen { return { kind: 'odbTableList' }; case 'pdf': case 'xlsx': + case 'csv': + case 'svg': return { kind: 'pdfPageList' }; } } diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index fbe52da..23574bc 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -5,9 +5,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { createDocx, createFontRegistry } from 'documents.js'; +import { createDocx, createFontRegistry, createOdg, createOds } from 'documents.js'; // dist/index.js is this package's own deliberately-importable barrel (see its own top-of-file comment: "so an external consumer -- or a test -- can call this CLI's conversion logic directly"), unlike dist/cli.js -- pulling the exit-code constants from the built artifact avoids hardcoding magic exit-code numbers in this file while still proving the barrel build itself is sound. -import { EXIT_INPUT_ERROR, EXIT_SUCCESS, EXIT_USAGE_ERROR } from '../dist/index.js'; +import { EXIT_INPUT_ERROR, EXIT_NEEDS_INFO, EXIT_SUCCESS, EXIT_USAGE_ERROR } from '../dist/index.js'; const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); @@ -278,6 +278,91 @@ describe('dist/cli.js docx-to-pdf --font-file', () => { }); }); +describe('dist/cli.js csv and svg conversions', () => { + // The same two fixtures the unit suite's src/commands/convert-selection.test.ts builds: a multi-sheet .ods (a csv target has to be told which sheet) and a multi-page .odg whose pages carry one rect each at disjoint coordinates (buildSvgText draws vectors only, so coordinates are how the emitted SVG tells the pages apart). + function buildMultiSheetOdsBytes() { + const editor = createOds(); + const first = editor.sheets()[0]; + first.cell(0, 0).value = { kind: 'string', value: 'AlphaCell' }; + first.cell(0, 1).value = { kind: 'number', value: 42 }; + const beta = editor.addSheet('Beta'); + beta.cell(0, 0).value = { kind: 'string', value: 'BetaCell' }; + beta.cell(0, 1).value = { kind: 'number', value: 7 }; + return editor.toBytes(); + } + + function buildMultiPageOdgBytes() { + const editor = createOdg(); + editor.addPage().addRect({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 50 } }); + editor.addPage().addRect({ frame: { xPt: 300, yPt: 400, widthPt: 100, heightPt: 50 } }); + return editor.toBytes(); + } + + it('formats --json lists the csv and svg conversion pairs', async () => { + const { code, stdout } = await spawnCli(['formats', '--json']); + expect(code).toBe(EXIT_SUCCESS); + const pairs = JSON.parse(stdout.toString('utf8')).map((entry) => `${entry.source}->${entry.target}`); + expect(pairs).toContain('ods->csv'); + expect(pairs).toContain('csv->pdf'); + expect(pairs).toContain('odg->svg'); + expect(pairs).toContain('svg->pdf'); + }); + + it('converts a csv source to a real PDF through the generic convert command', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'document-cli-smoke-')); + try { + const inputPath = join(tmpDir, 'table.csv'); + const outputPath = join(tmpDir, 'table.pdf'); + await writeFile(inputPath, 'Left,Right\nfirst,second\n'); + + const { code } = await spawnCli(['convert', inputPath, outputPath]); + expect(code).toBe(EXIT_SUCCESS); + expect(isPdfBytes(await readFile(outputPath))).toBe(true); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('exits 3 on an ambiguous csv target, then writes the picked sheet with --sheet', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'document-cli-smoke-')); + try { + const inputPath = join(tmpDir, 'multi.ods'); + await writeFile(inputPath, buildMultiSheetOdsBytes()); + + const unpicked = await spawnCli(['ods-to-csv', inputPath, join(tmpDir, 'unpicked.csv')]); + expect(unpicked.code).toBe(EXIT_NEEDS_INFO); + expect(unpicked.stderr.toString('utf8')).toContain('Beta'); + + const outputPath = join(tmpDir, 'beta.csv'); + const picked = await spawnCli(['ods-to-csv', inputPath, outputPath, '--sheet', 'Beta', '--delimiter', ';']); + expect(picked.code).toBe(EXIT_SUCCESS); + await expect(readFile(outputPath, 'utf8')).resolves.toContain('BetaCell;7'); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('exits 3 on an ambiguous svg target, then draws the picked page with --page', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'document-cli-smoke-')); + try { + const inputPath = join(tmpDir, 'multi.odg'); + await writeFile(inputPath, buildMultiPageOdgBytes()); + + const unpicked = await spawnCli(['odg-to-svg', inputPath, join(tmpDir, 'unpicked.svg')]); + expect(unpicked.code).toBe(EXIT_NEEDS_INFO); + + const outputPath = join(tmpDir, 'page1.svg'); + const picked = await spawnCli(['odg-to-svg', inputPath, outputPath, '--page', '1']); + expect(picked.code).toBe(EXIT_SUCCESS); + const svg = await readFile(outputPath, 'utf8'); + expect(svg).toContain(' { 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']);