Sign In

docx2pdf-cli

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

docx2pdf-cli - npm Package Compare versions

Comparing version
0.2.2
to
0.2.3
src/demo-sample.docx

Sorry, the diff of this file is not supported yet

+1
-1

@@ -8,3 +8,3 @@ # Agents

- **Success**: `{ ok: true, ... }` to **stdout** (one JSON object per file; NDJSON in batch mode), exit `0`.
- **Failure**: `{ ok: false, error: { code, message, kind?, details? } }` to **stderr**, non-zero exit. `kind` lets you branch on error class (e.g. `"NO_BACKEND"`).
- **Failure**: `{ ok: false, input, error: "<message>", exitCode }` (flat — `error` is a string), non-zero exit. Branch on `exitCode` / the process exit (`2` usage · `3` no backend · `4` conversion failed), not on the human-readable message.
- Default output is plain text. Add `--json` for the structured envelope.

@@ -11,0 +11,0 @@

@@ -7,2 +7,11 @@ # Changelog

## [0.2.3] - 2026-05-31
### Fixed
- **Unwritable / missing output directory no longer dumps a raw Node stack trace.** Write-path `fs.*` calls are now wrapped so an `ENOENT`/`EACCES`/`EEXIST` becomes a clean `CliError` with a documented non-zero exit code instead of a traceback + exit 1:
- `validatePaths` now rejects an output whose parent is a regular file (`Output directory is not a directory`) and converts a failed `mkdir` into `Cannot create output directory <dir>: <code>` (exit 2, `USAGE`).
- The LibreOffice backend's final `rename`/`copy` into the destination and the textutil-cups `writeFileSync` calls now raise `Cannot write output <path>: <code>` (exit 4, `CONVERT_FAIL`).
- **`--out-dir <regular-file>`** now reports a clear "not a directory" error instead of a raw `EEXIST: mkdir` trace.
- **PDF-to-stdout (`-`) no longer truncates large output** on a slow pipe: the buffer is now written synchronously to fd 1 and fully drained before exit.
## [0.2.2] - 2026-05-15

@@ -9,0 +18,0 @@

@@ -38,27 +38,19 @@ # Exit codes & error envelope

"ok": false,
"error": {
"code": "NO_BACKEND",
"kind": "NO_BACKEND",
"message": "No conversion backend is available on this host.",
"exitCode": 3,
"details": {
"platform": "darwin",
"recommendation": { ... }
}
}
"input": "contract.docx",
"error": "No conversion backend is available on this host.",
"exitCode": 3
}
```
`kind` is the stable class — agents should branch on this, not on the human-readable `message`.
The JSON failure object is **flat**: `error` is a human-readable string and `exitCode` is the stable class. Branch on the exit code (or the `exitCode` field), not on the message.
## Stable error `kind`s
## Error causes by exit code
| Kind | Class | Notes |
| Cause | Exit | Notes |
|---|---|---|
| `INVALID_INPUT` | input | Exit `2`. Missing arg, bad flag, file not found. |
| `STRICT_FIDELITY_REFUSED` | input | Exit `2`. Asked for `--strict-fidelity` but the resolved backend is `textutil-cups`. |
| `NO_BACKEND` | infra | Exit `3`. No backend can convert. `details.recommendation` names the next install step. |
| `BACKEND_TIMEOUT` | runtime | Exit `4`. Backend hung past the per-invocation timeout. |
| `BACKEND_FAILED` | runtime | Exit `4`. Backend ran but produced no PDF or exited non-zero. `details.stderr` carries the backend's own output. |
| `FONT_MISSING` | runtime | Exit `4` (only when `--check-fonts --strict` is passed). The document references a font not on the host. |
| Invalid input | `2` | Missing arg, bad flag, or file not found. |
| Strict-fidelity refused | `2` | `--strict-fidelity` set but the only resolved backend is `textutil-cups`. |
| No backend | `3` | No backend can convert; run `docx2pdf --doctor` for the next install step. |
| Backend timeout | `4` | Backend hung past the per-invocation timeout. |
| Backend failed | `4` | Backend ran but produced no PDF or exited non-zero. |

@@ -65,0 +57,0 @@ ## Disabling the JSON envelope

@@ -24,3 +24,3 @@ # JSON / NDJSON output

On failure, the error envelope (see [exit-codes.md](exit-codes.md)) goes to stderr instead. Exit code carries the class.
On failure, a flat error object is emitted instead — `{ "ok": false, "input": "...", "error": "<message>", "exitCode": N }` (`error` is a human-readable string). Branch on the exit code (see [exit-codes.md](exit-codes.md)), which carries the class.

@@ -38,3 +38,3 @@ ## Batch (NDJSON) mode

{"ok": true, "backend": "libreoffice", "input": "/abs/contract-b.docx", "output": "/abs/pdfs/contract-b.pdf", "outputBytes": 18900, "durationMs": 312}
{"ok": false, "input": "/abs/broken.docx", "error": {"code": "BACKEND_FAILED", "kind": "BACKEND_FAILED", "exitCode": 4, "message": "LibreOffice exited 1", "details": {"stderr": "..."}}}
{"ok": false, "input": "/abs/broken.docx", "error": "LibreOffice exited 1", "exitCode": 4}
{"ok": true, "summary": {"total": 3, "succeeded": 2, "failed": 1, "durationMs": 920}}

@@ -41,0 +41,0 @@ ```

@@ -24,3 +24,3 @@ # Gotenberg backend

Then point every client at the shared URL via env or a per-call `--backend-config gotenbergUrl=https://gotenberg.internal`.
Then point every client at the shared URL by setting `GOTENBERG_URL=https://gotenberg.internal` in each client's environment.

@@ -27,0 +27,0 @@ ## Retries

{
"name": "docx2pdf-cli",
"version": "0.2.2",
"description": "Honest, batch-aware DOCX to PDF CLI with hybrid backends (LibreOffice, Gotenberg, ConvertAPI, Pages, Word, textutil).",
"version": "0.2.3",
"description": "Honest, batch-aware DOCX to PDF CLI with hybrid backends (LibreOffice, Gotenberg, ConvertAPI, Pages, Word, textutil). Part of the contract-ops CLI suite.",
"type": "commonjs",

@@ -30,3 +30,3 @@ "bin": {

},
"homepage": "https://github.com/DrBaher/docx2pdf-cli#readme",
"homepage": "https://cli.drbaher.com",
"author": "Baher Al Hakim",

@@ -41,2 +41,5 @@ "scripts": {

"cli",
"contract-ops",
"agent-first",
"legal-tech",
"ai-agent",

@@ -43,0 +46,0 @@ "automation",

@@ -0,4 +1,8 @@

<p align="center">
<img src="assets/icon.svg" width="120" alt="docx2pdf-cli">
</p>
# docx2pdf-cli
> Part of the three-CLI contract suite. [**nda-review-cli**](https://github.com/DrBaher/nda-review-cli) (draft, review, negotiate) → **docx2pdf-cli** (DOCX → PDF) → [**sign-cli**](https://github.com/DrBaher/sign-cli) (signing + audit). [Showcase site](https://cli.drbaher.com/).
> Part of the contract-ops CLI suite. [**draft-cli**](https://github.com/DrBaher/draft-cli) (fill placeholders) → [**nda-review-cli**](https://github.com/DrBaher/nda-review-cli) (review, redline, negotiate) → **docx2pdf-cli** (DOCX → PDF) → [**sign-cli**](https://github.com/DrBaher/sign-cli) (signing + audit). Storage layer: [**template-vault-cli**](https://github.com/DrBaher/template-vault-cli). Drift detection: [**compare-cli**](https://github.com/DrBaher/compare-cli). [Showcase site](https://cli.drbaher.com/).

@@ -32,2 +36,3 @@ [![npm version](https://img.shields.io/npm/v/docx2pdf-cli.svg)](https://www.npmjs.com/package/docx2pdf-cli)

| **A contributor** | [docs/reference/](docs/reference/) (concept docs), the npm package, the CI workflows |
| **Adding a new CLI to the suite** | The [build-a-CLI playbook](https://cli.drbaher.com/build-a-cli) — the conventions every suite CLI follows |

@@ -34,0 +39,0 @@ Concept deep-dives live in [docs/reference/](docs/reference/); per-backend setup in [docs/setup/](docs/setup/).

@@ -219,2 +219,4 @@ #!/usr/bin/env node

function main(argv) {
if (argv[0] === "demo") return runDemo();
const options = parseArgs(argv);

@@ -302,2 +304,15 @@

// Pipe mode: `-` as input reads a DOCX from stdin; `-` as output (or a bare
// `docx2pdf -`) streams the PDF to stdout. Single-file only.
const inputIsStdin = options.input === "-";
const outputIsStdout = options.output === "-" || (inputIsStdin && !options.output);
if (!options.outDir && (inputIsStdin || outputIsStdout)) {
if ((options.inputs || []).length > 1) {
process.stderr.write("stdin '-' supports a single input only.\n");
return EXIT.USAGE;
}
if (options.why) printWhy(options);
return runPipe(options, inputIsStdin, outputIsStdout);
}
if (options.why) printWhy(options);

@@ -386,2 +401,85 @@

// Zero-config first run: convert a bundled sample DOCX to PDF using whatever
// backend is installed. If none is, explain what to install (the conversion
// itself is backend-dependent, so the demo is best-effort by design).
function runDemo() {
const os = require("node:os");
const sampleSrc = path.join(__dirname, "demo-sample.docx");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docx2pdf-demo-"));
const input = path.join(tmpDir, "sample.docx");
fs.copyFileSync(sampleSrc, input);
process.stderr.write("docx2pdf demo — converting a bundled sample DOCX to PDF.\n");
if (!getAvailableBackends().length) {
process.stderr.write("\nNo PDF backend is installed yet, so the demo can't render the sample.\n");
printSetupHelp();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
return EXIT.MISSING_DEP;
}
const output = path.join(tmpDir, "sample.pdf");
try {
const result = convertDocxToPdf({ input, output, backend: "auto", overwrite: true });
const bytes = fs.statSync(result.output).size;
process.stdout.write(`\n✓ Converted the sample DOCX to PDF (${bytes} bytes) via the '${result.backend}' backend.\n`);
process.stdout.write(` ${result.output}\n`);
process.stdout.write("\nNow try your own file:\n docx2pdf your.docx out.pdf\n docx2pdf --doctor # see all detected backends\n");
return 0;
} catch (err) {
process.stderr.write(`\nDemo conversion failed: ${err.message}\n`);
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
return err.exitCode || EXIT.CONVERT_FAIL;
}
}
// Single-file conversion with stdin and/or stdout. Backends need a real file
// on disk, so stdin is buffered to a temp .docx and stdout output is rendered
// to a temp .pdf then streamed out. Temp files are always cleaned up.
function runPipe(options, inputIsStdin, outputIsStdout) {
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docx2pdf-pipe-"));
try {
let input = options.input;
if (inputIsStdin) {
const buf = fs.readFileSync(0); // fd 0 = stdin, read synchronously
if (!buf.length) {
process.stderr.write("No data on stdin (expected a .docx stream for '-').\n");
return EXIT.USAGE;
}
input = path.join(tmpDir, "input.docx");
fs.writeFileSync(input, buf);
}
const output = outputIsStdout ? path.join(tmpDir, "output.pdf") : path.resolve(options.output);
const result = convertDocxToPdf({
...options,
input,
output,
overwrite: outputIsStdout ? true : options.overwrite
});
if (outputIsStdout) {
// Write synchronously to fd 1 so the full PDF drains before the process
// exits — process.stdout.write() can truncate a large buffer on a slow pipe.
const pdfBuf = fs.readFileSync(result.output);
let offset = 0;
while (offset < pdfBuf.length) {
offset += fs.writeSync(1, pdfBuf, offset, pdfBuf.length - offset);
}
if (!options.quiet) {
process.stderr.write(`Converted ${inputIsStdin ? "(stdin)" : path.basename(result.input)} -> stdout using ${result.backend}\n`);
}
} else {
const bytes = fs.statSync(result.output).size;
if (options.json) {
process.stdout.write(`${JSON.stringify({ ok: true, backend: result.backend, input: inputIsStdin ? "-" : result.input, output: result.output, outputBytes: bytes })}\n`);
} else if (!options.quiet) {
process.stdout.write(`Converted ${inputIsStdin ? "(stdin)" : path.basename(result.input)} -> ${result.output} using ${result.backend}\n`);
}
}
return 0;
} finally {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
if (require.main === module) {

@@ -388,0 +486,0 @@ Promise.resolve()

@@ -347,3 +347,11 @@ "use strict";

if (fs.existsSync(output) && !overwrite) throw new CliError(`Output file already exists: ${output}. Use --overwrite to replace it.`, EXIT.USAGE);
fs.mkdirSync(path.dirname(output), { recursive: true });
const outDir = path.dirname(output);
if (fs.existsSync(outDir) && !fs.statSync(outDir).isDirectory()) {
throw new CliError(`Output directory is not a directory: ${outDir}`, EXIT.USAGE);
}
try {
fs.mkdirSync(outDir, { recursive: true });
} catch (err) {
throw new CliError(`Cannot create output directory ${outDir}: ${err.code || err.message}`, EXIT.USAGE);
}
}

@@ -372,9 +380,13 @@

try {
fs.renameSync(generated, output);
try {
fs.renameSync(generated, output);
} catch (err) {
if (err && err.code === "EXDEV") {
fs.copyFileSync(generated, output);
} else {
throw err;
}
}
} catch (err) {
if (err && err.code === "EXDEV") {
fs.copyFileSync(generated, output);
} else {
throw err;
}
throw new CliError(`Cannot write output ${output}: ${err.code || err.message}`, EXIT.CONVERT_FAIL);
}

@@ -523,6 +535,14 @@ } finally {

if (t.status !== 0) throw new CliError(`textutil failed: ${String(t.stderr || "").trim()}`, EXIT.CONVERT_FAIL);
fs.writeFileSync(tempText, t.stdout, "utf8");
try {
fs.writeFileSync(tempText, t.stdout, "utf8");
} catch (err) {
throw new CliError(`Cannot write intermediate file ${tempText}: ${err.code || err.message}`, EXIT.CONVERT_FAIL);
}
const p = runner("cupsfilter", ["-m", "application/pdf", tempText], { encoding: "buffer", timeoutMs });
if (p.status !== 0) throw new CliError(`cupsfilter failed: ${String(Buffer.isBuffer(p.stderr)?p.stderr.toString("utf8"):p.stderr||"").trim()}`, EXIT.CONVERT_FAIL);
fs.writeFileSync(output, p.stdout);
try {
fs.writeFileSync(output, p.stdout);
} catch (err) {
throw new CliError(`Cannot write output ${output}: ${err.code || err.message}`, EXIT.CONVERT_FAIL);
}
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }

@@ -642,2 +662,5 @@ }

docx2pdf [options] --out-dir <dir> <input.docx>...
docx2pdf demo zero-config: convert a bundled sample
cat in.docx | docx2pdf - out.pdf read DOCX from stdin
docx2pdf in.docx - > out.pdf write PDF to stdout ('-')

@@ -644,0 +667,0 @@ Options: