uvpdftool 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # uvpdftool
2
2
 
3
- The [UV pdfTool API](https://uvpdftool.com/developers) from Node.js 18 and later: compress,
4
- convert, OCR and check PDFs on UV pdfTool's servers. No dependencies; ES modules; types
5
- included.
3
+ The [UV pdfTool API](https://uvpdftool.com/developers) from Node.js 18.3 and later, and from the
4
+ command line: compress, convert, OCR and check PDFs on UV pdfTool's servers. No dependencies; ES
5
+ modules; types included.
6
6
 
7
7
  ```sh
8
8
  npm install uvpdftool
@@ -50,5 +50,26 @@ A refused request throws `ApiError` (`status`, `code`, `message`, `details`,
50
50
  with `job.error`). A file is `{ name, data, contentType }` where `data` is a `Buffer`,
51
51
  `Uint8Array`, `ArrayBuffer`, `Blob` or a `ReadableStream` with `size`.
52
52
 
53
+ ## The command line
54
+
55
+ The package installs `uvpdftool`, which uploads the files, runs the tool and saves the results
56
+ beside them:
57
+
58
+ ```sh
59
+ npm install -g uvpdftool
60
+ export UVPDFTOOL_API_KEY=uvp_test_...
61
+
62
+ uvpdftool compress report.pdf --set method=ghostscript --set preset=ebook
63
+ uvpdftool office-to-pdf proposal.docx contract.docx -o pdfs
64
+ uvpdftool ocr scan.pdf --set 'languages=["eng","hin"]' --json
65
+ uvpdftool tools # what each tool takes, and its options
66
+ uvpdftool account # the key in use, and the API tasks left
67
+ ```
68
+
69
+ The key comes from `--key`, `UVPDFTOOL_API_KEY` or `UVPDF_KEY`. Each `--set` is one option of
70
+ the tool, read as JSON where it can be; `--options '{...}'` gives them all at once. A result never
71
+ replaces a file of the same name unless `--force` is given; a number is added instead. The exit
72
+ code is 0 when done, 1 when the API refused or the job failed, and 2 when the command was wrong.
73
+
53
74
  The tools, their options and the errors are documented at
54
75
  <https://uvpdftool.com/developers>.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './command.js';
3
+ // The command line itself: what it does is in command.ts, where the tests reach it.
4
+ void main(process.argv.slice(2), {
5
+ env: process.env,
6
+ cwd: process.cwd(),
7
+ stdout: (text) => process.stdout.write(text),
8
+ stderr: (text) => process.stderr.write(text),
9
+ }).then((code) => {
10
+ process.exitCode = code;
11
+ });
@@ -0,0 +1,13 @@
1
+ /** One `--set name=value`: the value as JSON where it parses, as text otherwise. */
2
+ export declare function parseSetting(setting: string): [string, unknown];
3
+ export interface Io {
4
+ env: Record<string, string | undefined>;
5
+ cwd: string;
6
+ stdout: (text: string) => void;
7
+ stderr: (text: string) => void;
8
+ /** Another fetch, for tests. */
9
+ fetch?: typeof fetch;
10
+ }
11
+ /** Runs the command line; resolves with the exit code: 0 done, 1 refused or failed, 2 misused. */
12
+ export { contentTypeOf, safeName } from './files.js';
13
+ export declare function main(argv: readonly string[], io: Io): Promise<number>;
@@ -0,0 +1,211 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { mkdir, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { parseArgs } from 'node:util';
6
+ import { contentTypeOf, safeName, saveResult } from './files.js';
7
+ import { ApiError, JobFailedError, UvPdfTool, VERSION } from './index.js';
8
+ /**
9
+ * The UV pdfTool API from the command line: `uvpdftool <tool> <file>...` uploads the files,
10
+ * runs the tool and saves what it made; `uvpdftool tools` and `uvpdftool account` answer the
11
+ * two questions a script asks first. Nothing but Node itself and this package.
12
+ */
13
+ const HELP = `uvpdftool ${VERSION}: the UV pdfTool API from the command line.
14
+
15
+ Usage:
16
+ uvpdftool <tool> <file>... [options] Run a tool on the files and save the results
17
+ uvpdftool tools The tools, what they take and their options
18
+ uvpdftool account The key in use and the tasks left
19
+
20
+ Options:
21
+ -k, --key <key> The API key; or set UVPDFTOOL_API_KEY (or UVPDF_KEY)
22
+ -o, --out <folder> Where to save the results (default: the current folder)
23
+ -s, --set <name=value> One option of the tool; repeat for more. Values are read as
24
+ JSON where they can be: --set preset=ebook --set linearize=true
25
+ --options <json> All the tool's options as one JSON object
26
+ --keep <hours> Keep the files on the server this long after the job, 1 to 24
27
+ --force Overwrite files of the same name instead of adding a number
28
+ --json Print the finished job as JSON
29
+ -q, --quiet No progress on standard error
30
+ -h, --help This help
31
+ -v, --version The version
32
+
33
+ Examples:
34
+ uvpdftool compress report.pdf --set method=ghostscript --set preset=ebook
35
+ uvpdftool office-to-pdf proposal.docx -o pdfs
36
+ uvpdftool ocr scan.pdf --set 'languages=["eng","hin"]'
37
+
38
+ Tools, options and errors: https://uvpdftool.com/developers
39
+ `;
40
+ /** One `--set name=value`: the value as JSON where it parses, as text otherwise. */
41
+ export function parseSetting(setting) {
42
+ const at = setting.indexOf('=');
43
+ if (at < 1)
44
+ throw new UsageError(`--set needs name=value, not "${setting}".`);
45
+ const name = setting.slice(0, at).trim();
46
+ const raw = setting.slice(at + 1);
47
+ try {
48
+ return [name, JSON.parse(raw)];
49
+ }
50
+ catch {
51
+ return [name, raw];
52
+ }
53
+ }
54
+ class UsageError extends Error {
55
+ }
56
+ /** Runs the command line; resolves with the exit code: 0 done, 1 refused or failed, 2 misused. */
57
+ export { contentTypeOf, safeName } from './files.js';
58
+ export async function main(argv, io) {
59
+ let parsed;
60
+ try {
61
+ parsed = parseArgs({
62
+ args: [...argv],
63
+ allowPositionals: true,
64
+ options: {
65
+ key: { type: 'string', short: 'k' },
66
+ out: { type: 'string', short: 'o' },
67
+ set: { type: 'string', short: 's', multiple: true },
68
+ options: { type: 'string' },
69
+ keep: { type: 'string' },
70
+ force: { type: 'boolean' },
71
+ json: { type: 'boolean' },
72
+ quiet: { type: 'boolean', short: 'q' },
73
+ help: { type: 'boolean', short: 'h' },
74
+ version: { type: 'boolean', short: 'v' },
75
+ 'base-url': { type: 'string' },
76
+ },
77
+ });
78
+ }
79
+ catch (error) {
80
+ io.stderr(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}`);
81
+ return 2;
82
+ }
83
+ const { values, positionals } = parsed;
84
+ if (values.version) {
85
+ io.stdout(`${VERSION}\n`);
86
+ return 0;
87
+ }
88
+ const [command, ...files] = positionals;
89
+ if (values.help || command === undefined || command === 'help') {
90
+ io.stdout(HELP);
91
+ return command === undefined && !values.help ? 2 : 0;
92
+ }
93
+ const apiKey = values.key ?? io.env.UVPDFTOOL_API_KEY ?? io.env.UVPDF_KEY ?? '';
94
+ if (apiKey === '') {
95
+ io.stderr('An API key is needed: --key, or UVPDFTOOL_API_KEY. Make one at https://uvpdftool.com/developers/keys\n');
96
+ return 2;
97
+ }
98
+ const client = new UvPdfTool({
99
+ apiKey,
100
+ userAgent: 'uvpdftool-cli',
101
+ ...(values['base-url'] ? { baseUrl: values['base-url'] } : {}),
102
+ ...(io.fetch ? { fetch: io.fetch } : {}),
103
+ });
104
+ try {
105
+ if (command === 'tools') {
106
+ const tools = await client.tools();
107
+ if (values.json)
108
+ io.stdout(`${JSON.stringify(tools, null, 2)}\n`);
109
+ else {
110
+ for (const tool of tools) {
111
+ io.stdout(`${tool.id}\n takes ${tool.accepts.join(', ')}; up to ${String(tool.maxFiles)} files\n`);
112
+ const names = Object.keys(tool.options.properties ?? {});
113
+ if (names.length > 0)
114
+ io.stdout(` options: ${names.join(', ')}\n`);
115
+ }
116
+ }
117
+ return 0;
118
+ }
119
+ if (command === 'account') {
120
+ const account = await client.account();
121
+ if (values.json)
122
+ io.stdout(`${JSON.stringify(account, null, 2)}\n`);
123
+ else {
124
+ io.stdout(`${account.key.name} (${account.key.mode}, ${account.key.prefix}…)\n` +
125
+ `API tasks left: ${String(account.tasks)}\n` +
126
+ `Test jobs left today: ${String(account.test.left)} of ${String(account.test.perDay)}\n`);
127
+ }
128
+ return 0;
129
+ }
130
+ if (files.length === 0) {
131
+ // A tool that takes no files (web-to-pdf) runs on its options alone.
132
+ const known = (await client.tools()).find((tool) => tool.id === command);
133
+ if (!known || known.accepts.length > 0) {
134
+ throw new UsageError(`Name the files for ${command}.`);
135
+ }
136
+ }
137
+ const options = {};
138
+ if (values.options !== undefined) {
139
+ let given;
140
+ try {
141
+ given = JSON.parse(values.options);
142
+ }
143
+ catch {
144
+ throw new UsageError('--options must be a JSON object.');
145
+ }
146
+ if (typeof given !== 'object' || given === null || Array.isArray(given)) {
147
+ throw new UsageError('--options must be a JSON object.');
148
+ }
149
+ Object.assign(options, given);
150
+ }
151
+ for (const setting of values.set ?? []) {
152
+ const [name, value] = parseSetting(setting);
153
+ options[name] = value;
154
+ }
155
+ const keep = values.keep === undefined ? undefined : Number(values.keep);
156
+ if (keep !== undefined && (!Number.isInteger(keep) || keep < 1 || keep > 24)) {
157
+ throw new UsageError('--keep takes a whole number of hours, 1 to 24.');
158
+ }
159
+ const inputs = [];
160
+ for (const file of files) {
161
+ const full = path.resolve(io.cwd, file);
162
+ const info = await stat(full).catch(() => null);
163
+ if (!info?.isFile())
164
+ throw new UsageError(`${file}: no such file.`);
165
+ inputs.push({
166
+ name: path.basename(full),
167
+ contentType: contentTypeOf(full),
168
+ size: info.size,
169
+ data: Readable.toWeb(createReadStream(full)),
170
+ });
171
+ }
172
+ const quiet = values.quiet === true;
173
+ if (!quiet)
174
+ io.stderr(`${command}: uploading ${String(inputs.length)} file(s)…\n`);
175
+ const job = await client.run({
176
+ tool: command,
177
+ files: inputs,
178
+ ...(Object.keys(options).length > 0 ? { options } : {}),
179
+ ...(keep !== undefined ? { retentionHours: keep } : {}),
180
+ onProgress: (fraction) => {
181
+ if (!quiet)
182
+ io.stderr(`${command}: ${String(Math.round(fraction * 100))}%\n`);
183
+ },
184
+ });
185
+ const folder = path.resolve(io.cwd, values.out ?? '.');
186
+ await mkdir(folder, { recursive: true });
187
+ const saved = [];
188
+ for (const [index, output] of job.outputs.entries()) {
189
+ const data = await client.download(output);
190
+ saved.push(await saveResult(folder, safeName(output.name, index), data, values.force === true));
191
+ }
192
+ if (values.json)
193
+ io.stdout(`${JSON.stringify({ job, saved }, null, 2)}\n`);
194
+ else
195
+ for (const file of saved)
196
+ io.stdout(`${file}\n`);
197
+ return 0;
198
+ }
199
+ catch (error) {
200
+ if (error instanceof UsageError) {
201
+ io.stderr(`${error.message}\n`);
202
+ return 2;
203
+ }
204
+ if (error instanceof JobFailedError || error instanceof ApiError) {
205
+ io.stderr(`${error.message}\n`);
206
+ return 1;
207
+ }
208
+ io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
209
+ return 1;
210
+ }
211
+ }
@@ -0,0 +1,14 @@
1
+ export declare const contentTypeOf: (file: string) => string;
2
+ /**
3
+ * A result's name as a file in the output folder: its last part only, with the characters no
4
+ * system allows in a name replaced, so a name from the server can never reach another folder.
5
+ */
6
+ export declare function safeName(name: string, index: number): string;
7
+ /** A free path for `name` in `folder`: the name itself, or with " (2)", " (3)"… before the extension. */
8
+ export declare function freePath(folder: string, name: string, force: boolean): Promise<string>;
9
+ /**
10
+ * Saves a result as `name` in `folder`, under a free name unless `force`, and says where. The file
11
+ * is created only if nothing is there, so a result saved at the same moment, or a link put at the
12
+ * name, is never written over or through: the next free name is taken instead.
13
+ */
14
+ export declare function saveResult(folder: string, name: string, data: Uint8Array, force: boolean): Promise<string>;
package/dist/files.js ADDED
@@ -0,0 +1,77 @@
1
+ import { lstat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Local files and the API: the content type to declare for a file, and a safe, free place to
5
+ * save a result, for the command line and for the MCP server alike.
6
+ */
7
+ /** Content types by file extension, for what the tools take. */
8
+ const TYPES = {
9
+ '.pdf': 'application/pdf',
10
+ '.doc': 'application/msword',
11
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
12
+ '.xls': 'application/vnd.ms-excel',
13
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
14
+ '.ppt': 'application/vnd.ms-powerpoint',
15
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
16
+ '.odt': 'application/vnd.oasis.opendocument.text',
17
+ '.ods': 'application/vnd.oasis.opendocument.spreadsheet',
18
+ '.odp': 'application/vnd.oasis.opendocument.presentation',
19
+ '.rtf': 'application/rtf',
20
+ '.txt': 'text/plain',
21
+ '.csv': 'text/csv',
22
+ '.html': 'text/html',
23
+ '.htm': 'text/html',
24
+ '.md': 'text/markdown',
25
+ '.markdown': 'text/markdown',
26
+ '.epub': 'application/epub+zip',
27
+ '.png': 'image/png',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ };
31
+ export const contentTypeOf = (file) => TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
32
+ const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/iu;
33
+ /**
34
+ * A result's name as a file in the output folder: its last part only, with the characters no
35
+ * system allows in a name replaced, so a name from the server can never reach another folder.
36
+ */
37
+ export function safeName(name, index) {
38
+ const last = name.split(/[\\/]/u).pop() ?? '';
39
+ // eslint-disable-next-line no-control-regex -- control characters are what is being removed
40
+ const cleaned = last.replace(/[<>:"|?*\u0000-\u001f]/gu, '_').replace(/^[.\s]+|[.\s]+$/gu, '');
41
+ if (cleaned === '')
42
+ return `result-${String(index + 1)}`;
43
+ return RESERVED.test(cleaned) ? `_${cleaned}` : cleaned;
44
+ }
45
+ // lstat: a link at the name counts as taken, even one that leads nowhere.
46
+ const exists = async (file) => lstat(file).then(() => true, () => false);
47
+ /** A free path for `name` in `folder`: the name itself, or with " (2)", " (3)"… before the extension. */
48
+ export async function freePath(folder, name, force) {
49
+ const first = path.join(folder, name);
50
+ if (force || !(await exists(first)))
51
+ return first;
52
+ const extension = path.extname(name);
53
+ const stem = name.slice(0, name.length - extension.length);
54
+ for (let count = 2;; count += 1) {
55
+ const candidate = path.join(folder, `${stem} (${String(count)})${extension}`);
56
+ if (!(await exists(candidate)))
57
+ return candidate;
58
+ }
59
+ }
60
+ /**
61
+ * Saves a result as `name` in `folder`, under a free name unless `force`, and says where. The file
62
+ * is created only if nothing is there, so a result saved at the same moment, or a link put at the
63
+ * name, is never written over or through: the next free name is taken instead.
64
+ */
65
+ export async function saveResult(folder, name, data, force) {
66
+ for (;;) {
67
+ const target = await freePath(folder, name, force);
68
+ try {
69
+ await writeFile(target, data, { flag: force ? 'w' : 'wx' });
70
+ return target;
71
+ }
72
+ catch (error) {
73
+ if (force || error.code !== 'EEXIST')
74
+ throw error;
75
+ }
76
+ }
77
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { contentTypeOf, freePath, safeName, saveResult } from './files.js';
1
2
  /**
2
3
  * The UV pdfTool API from Node.js 18 and later, with nothing to install besides this
3
4
  * package: create a job, upload the files, start it, wait for it, download the result, or
@@ -119,6 +120,7 @@ export declare class JobFailedError extends Error {
119
120
  readonly job: Job;
120
121
  constructor(job: Job);
121
122
  }
123
+ export declare const VERSION = "0.2.0";
122
124
  export declare class UvPdfTool {
123
125
  readonly jobs: {
124
126
  create: (request: CreateJobRequest) => Promise<{
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ export { contentTypeOf, freePath, safeName, saveResult } from './files.js';
2
3
  /**
3
4
  * The UV pdfTool API from Node.js 18 and later, with nothing to install besides this
4
5
  * package: create a job, upload the files, start it, wait for it, download the result, or
@@ -33,7 +34,7 @@ export class JobFailedError extends Error {
33
34
  }
34
35
  }
35
36
  const ENDED = ['done', 'failed', 'cancelled'];
36
- const VERSION = '0.1.0';
37
+ export const VERSION = '0.2.0';
37
38
  const sleep = (ms, signal) => new Promise((resolve, reject) => {
38
39
  const timer = setTimeout(resolve, ms);
39
40
  signal?.addEventListener('abort', () => {
@@ -236,5 +237,6 @@ export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds
236
237
  .update(body)
237
238
  .digest('hex');
238
239
  const given = parts.get('v1') ?? '';
239
- return (given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)));
240
+ // Hex only: then both are the same length in bytes, as timingSafeEqual needs.
241
+ return (/^[0-9a-f]{64}$/u.test(given) && timingSafeEqual(Buffer.from(given), Buffer.from(expected)));
240
242
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "uvpdftool",
3
- "version": "0.1.0",
4
- "description": "The UV pdfTool API from Node.js: compress, convert, OCR and check PDFs on UV pdfTool's servers.",
3
+ "version": "0.2.0",
4
+ "description": "The UV pdfTool API from Node.js and the command line: compress, convert, OCR and check PDFs on UV pdfTool's servers.",
5
5
  "license": "MIT",
6
6
  "author": "UV ResearchLabs",
7
7
  "homepage": "https://uvpdftool.com/developers",
@@ -17,32 +17,31 @@
17
17
  "convert",
18
18
  "pdfa",
19
19
  "api",
20
+ "cli",
20
21
  "uvpdftool"
21
22
  ],
22
23
  "type": "module",
23
24
  "sideEffects": false,
24
25
  "engines": {
25
- "node": ">=18"
26
+ "node": ">=18.3"
26
27
  },
27
28
  "exports": {
28
29
  ".": {
30
+ "@pdftool/source": "./src/index.ts",
29
31
  "types": "./dist/index.d.ts",
30
32
  "import": "./dist/index.js"
31
33
  },
32
34
  "./package.json": "./package.json"
33
35
  },
34
36
  "types": "./dist/index.d.ts",
37
+ "bin": {
38
+ "uvpdftool": "./dist/cli.js"
39
+ },
35
40
  "files": [
36
41
  "dist",
37
42
  "README.md",
38
43
  "LICENSE"
39
44
  ],
40
- "scripts": {
41
- "build": "tsc -p tsconfig.build.json",
42
- "test": "vitest run",
43
- "lint": "eslint .",
44
- "typecheck": "tsc -p tsconfig.json"
45
- },
46
45
  "devDependencies": {
47
46
  "@eslint/js": "^10.0.1",
48
47
  "@types/node": "^24.13.6",
@@ -50,5 +49,11 @@
50
49
  "typescript": "~6.0.3",
51
50
  "typescript-eslint": "^8.70.0",
52
51
  "vitest": "^4.1.12"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.build.json",
55
+ "test": "vitest run",
56
+ "lint": "eslint .",
57
+ "typecheck": "tsc -p tsconfig.json"
53
58
  }
54
- }
59
+ }