Files
SnapOtter/packages/doc-engine/src/qpdf.ts
T

94 lines
3.1 KiB
TypeScript

import { spawn } from "node:child_process";
import { resolveQpdf } from "./binaries.js";
/** @internal Shared qpdf CLI runner for doc-engine modules; not part of the public package API. */
export function runQpdf(args: string[], timeoutMs = 30_000): Promise<string> {
const bin = resolveQpdf();
if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)");
return new Promise<string>((resolvePromise, reject) => {
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
reject(new Error(`qpdf timed out after ${Math.round(timeoutMs / 1000)}s`));
}, timeoutMs);
child.stdout.on("data", (c: Buffer) => {
out += c.toString("utf8");
});
child.stderr.on("data", (c: Buffer) => {
err = (err + c.toString("utf8")).slice(-4096);
});
child.on("error", (e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e);
});
child.on("close", (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) resolvePromise(out);
else
reject(new Error(`qpdf exited ${code ?? signal}: ${err.slice(-1000) || out.slice(-1000)}`));
});
});
}
/** Structural check; throws with qpdf's diagnostics on damage. */
export async function qpdfCheck(filePath: string): Promise<void> {
await runQpdf(["--check", filePath]);
}
/**
* True when the PDF requires a password to open. qpdf --requires-password
* exits 0 when a password is required (2 = not encrypted, 3 = encrypted but
* the correct password was supplied), so this cannot be a throw-on-nonzero
* call.
*
* Verified against qpdf 12.1.0:
* encrypted fixture (no pw) -> exit 0
* plain fixture -> exit 2
* encrypted + correct pw -> exit 3
* encrypted + wrong pw -> exit 0
*/
export async function qpdfRequiresPassword(filePath: string): Promise<boolean> {
const bin = resolveQpdf();
if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)");
return new Promise<boolean>((resolvePromise, reject) => {
const child = spawn(bin, ["--requires-password", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
reject(new Error("qpdf timed out after 30s"));
}, 30_000);
child.on("error", (e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolvePromise(code === 0);
});
});
}
export async function qpdfPageCount(filePath: string): Promise<number> {
const out = await runQpdf(["--show-npages", filePath]);
const n = Number(out.trim());
if (!Number.isFinite(n)) throw new Error(`qpdf returned a non-numeric page count: ${out.trim()}`);
return n;
}