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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 12 additions & 5 deletions pnpm-lock.yaml

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

160 changes: 160 additions & 0 deletions src/commands/convert-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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<CapturedRun> {
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<ArrayBuffer> {
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 <rect>.
function multiPageOdgBytes(): Uint8Array<ArrayBuffer> {
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('<rect x="300" y="400"');
expect(svg).not.toContain('<rect x="20" y="20"');
});

it('fails with exit 3 when --page indexes past the last page', async () => {
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('<rect x="20" y="20"');
expect(svg).not.toContain('<rect x="300" y="400"');
});
});
21 changes: 19 additions & 2 deletions src/commands/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { createLocalDocumentConverter } from 'documents.js';
import { inferFormatFromExtension } from '../format';
import { EXIT_USAGE_ERROR } from '../runtime/exit-codes';
import { KNOWN_DOCUMENT_FORMATS, type ConversionCommandOptions, buildConversionAction, resolveTargetFormat } from './shared';
import { addConversionFlags, addDumpPackageOption, addFontOptions, type ConversionCliFlags, type FontCliFlags } from './options';
import { addConversionFlags, addDelimiterOption, addDumpPackageOption, addFontOptions, addPageOption, addSheetOption, type ConversionCliFlags, type FontCliFlags, type SelectionCliFlags } from './options';

interface ConvertCliOptions extends ConversionCliFlags, FontCliFlags {
interface ConvertCliOptions extends ConversionCliFlags, FontCliFlags, SelectionCliFlags {
readonly dumpPackage?: string;
}

Expand All @@ -24,6 +24,9 @@ function toConversionCommandOptions(options: ConvertCliOptions): ConversionComma
dumpPackage: options.dumpPackage,
fontFiles: options.fontFile,
reportFontSubstitutions: options.reportFontSubstitutions,
delimiter: options.delimiter,
sheet: options.sheet,
page: options.page,
};
}

Expand Down Expand Up @@ -67,6 +70,16 @@ export function registerConversionCommands(program: Command): void {
if (target === 'pdf') {
addFontOptions(command);
}
// The csv/svg edge selections (see commands/options.ts's own SelectionCliFlags comment): --delimiter reaches both a csv source's read edge and a csv target's write edge, --sheet only a csv target's write edge, --page only an svg target's write edge. Everything else has no edge that reads them, so the flags are absent rather than advertised as no-ops.
if (source === 'csv' || target === 'csv') {
addDelimiterOption(command);
}
if (target === 'csv') {
addSheetOption(command);
}
if (target === 'svg') {
addPageOption(command);
}
command.action(async (input: string, output: string | undefined, options: ConvertCliOptions) => {
process.exitCode = await buildConversionAction(source, target)(input, output, toConversionCommandOptions(options));
});
Expand All @@ -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 <format>', `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);
Expand Down
18 changes: 14 additions & 4 deletions src/commands/from-package.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down Expand Up @@ -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 });
Expand All @@ -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 <format>', `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);
Expand Down
20 changes: 20 additions & 0 deletions src/commands/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <char>', 'field delimiter a csv source reads with, or a csv target writes with (default \',\')');
}

export function addSheetOption(command: Command): Command {
return command.option('--sheet <name>', 'the sheet a csv target writes, when the source document has more than one');
}

export function addPageOption(command: Command): Command {
return command.option('--page <index>', '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;
}
Loading
Loading