feat(tools): 2.0 phase 5 wave 2 - pdf depth (21 tools) (#220)

This commit is contained in:
SnapOtter
2026-06-13 10:18:55 +08:00
parent ae1337901d
commit 2f39e38162
128 changed files with 11381 additions and 778 deletions
+6
View File
@@ -25,6 +25,9 @@ export function resolveSoffice(): string | null {
export function resolveGs(): string | null {
return resolveBin("GS_PATH", "gs");
}
export function resolvePdfcpu(): string | null {
return resolveBin("PDFCPU_PATH", "pdfcpu");
}
export function qpdfAvailable(): boolean {
return resolveQpdf() !== null;
}
@@ -34,3 +37,6 @@ export function sofficeAvailable(): boolean {
export function gsAvailable(): boolean {
return resolveGs() !== null;
}
export function pdfcpuAvailable(): boolean {
return resolvePdfcpu() !== null;
}
+56 -24
View File
@@ -3,38 +3,20 @@ import { resolveGs } from "./binaries.js";
export type PdfCompressionPreset = "screen" | "ebook" | "printer";
/** Ghostscript re-distillation with a quality preset; 120s hard kill. */
export async function gsCompressPdf(
inputPath: string,
outPath: string,
preset: PdfCompressionPreset,
): Promise<void> {
/** @internal Shared gs CLI runner; not part of the public package API. */
function runGs(args: string[], timeoutMs = 120_000): Promise<void> {
const bin = resolveGs();
if (!bin) throw new Error("gs binary not found (set GS_PATH or install ghostscript)");
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(
bin,
[
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sDEVICE=pdfwrite",
`-dPDFSETTINGS=/${preset}`,
"-dCompatibilityLevel=1.6",
`-sOutputFile=${outPath}`,
inputPath,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
return new Promise<void>((resolvePromise, reject) => {
const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
let err = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
reject(new Error("ghostscript timed out after 120s"));
}, 120_000);
reject(new Error(`ghostscript timed out after ${Math.round(timeoutMs / 1000)}s`));
}, timeoutMs);
child.stderr.on("data", (c: Buffer) => {
err = (err + c.toString("utf8")).slice(-4096);
});
@@ -53,3 +35,53 @@ export async function gsCompressPdf(
});
});
}
/** Ghostscript re-distillation with a quality preset. */
export async function gsCompressPdf(
inputPath: string,
outPath: string,
preset: PdfCompressionPreset,
): Promise<void> {
await runGs([
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sDEVICE=pdfwrite",
`-dPDFSETTINGS=/${preset}`,
"-dCompatibilityLevel=1.6",
`-sOutputFile=${outPath}`,
inputPath,
]);
}
/** Grayscale re-distillation via DeviceGray color conversion. */
export async function gsGrayscalePdf(inputPath: string, outPath: string): Promise<void> {
await runGs([
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sDEVICE=pdfwrite",
"-sColorConversionStrategy=Gray",
"-dProcessColorModel=/DeviceGray",
`-sOutputFile=${outPath}`,
inputPath,
]);
}
/** PDF/A-2b candidate via ghostscript's PDFA switch (no veraPDF validation this wave). */
export async function gsPdfaConvert(inputPath: string, outPath: string): Promise<void> {
await runGs([
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-dPDFA=2",
"-dPDFACompatibilityPolicy=1",
"-sColorConversionStrategy=RGB",
"-sDEVICE=pdfwrite",
`-sOutputFile=${outPath}`,
inputPath,
]);
}
+40 -4
View File
@@ -1,13 +1,49 @@
export {
gsAvailable,
pdfcpuAvailable,
qpdfAvailable,
resolveGs,
resolvePdfcpu,
resolveQpdf,
resolveSoffice,
sofficeAvailable,
} from "./binaries.js";
export { gsCompressPdf, type PdfCompressionPreset } from "./ghostscript.js";
export {
gsCompressPdf,
gsGrayscalePdf,
gsPdfaConvert,
type PdfCompressionPreset,
} from "./ghostscript.js";
export { type ConvertOptions, convertDocument } from "./libreoffice.js";
export { assertValidRange, qpdfMerge, qpdfRotate, qpdfSplitRanges } from "./pdf-ops.js";
export { pdfPageCountPy } from "./python-docs.js";
export { qpdfCheck, qpdfPageCount } from "./qpdf.js";
export {
assertValidRange,
qpdfDecrypt,
qpdfEncrypt,
qpdfLinearize,
qpdfMerge,
qpdfPagesSpec,
qpdfPagesSpecUnchecked,
qpdfRepair,
qpdfRotate,
qpdfSplitRanges,
} from "./pdf-ops.js";
export {
type BookletValue,
type NupValue,
pdfcpuBooklet,
pdfcpuCropMargin,
pdfcpuNup,
pdfcpuTextStamp,
type TextStampOptions,
} from "./pdfcpu.js";
export {
htmlToPdfPy,
pdfFlattenPy,
pdfMetadataGetPy,
pdfMetadataSetPy,
pdfPageCountPy,
pdfRedactPy,
pdfTextPy,
pdfToWordPy,
} from "./python-docs.js";
export { qpdfCheck, qpdfPageCount, qpdfRequiresPassword } from "./qpdf.js";
+85
View File
@@ -9,6 +9,10 @@ export function assertValidRange(range: string): void {
}
}
function assertPassword(pw: string): void {
if (pw.length === 0 || pw.length > 256) throw new Error("Password must be 1-256 characters");
}
/** Merge inputs (>= 2) into outPath, full pages, input order. */
export async function qpdfMerge(inputPaths: string[], outPath: string): Promise<void> {
if (inputPaths.length < 2) throw new Error("qpdfMerge needs at least two inputs");
@@ -35,3 +39,84 @@ export async function qpdfRotate(
assertValidRange(range);
await runQpdf([`--rotate=+${angle}:${range}`, inputPath, outPath], 60_000);
}
/*
* Security note: passwords are passed as argv elements to spawn() (no shell).
* They are visible in /proc/<pid>/cmdline for the ~1s process lifetime. This
* is acceptable for the single-tenant container threat model. If multi-tenant
* isolation is ever needed, switch to qpdf's --password-file or @argfile
* syntax with a 0600 temp file in the scratch dir, deleted in a finally block.
*/
/** AES-256 encrypt with user + owner passwords (qpdf --encrypt user owner 256 --). */
export async function qpdfEncrypt(
inputPath: string,
userPassword: string,
ownerPassword: string,
outPath: string,
): Promise<void> {
assertPassword(userPassword);
assertPassword(ownerPassword);
await runQpdf(
[inputPath, "--encrypt", userPassword, ownerPassword, "256", "--", outPath],
60_000,
);
}
/** Decrypt with a known password; qpdf rejects wrong passwords with exit 2. */
export async function qpdfDecrypt(
inputPath: string,
password: string,
outPath: string,
): Promise<void> {
assertPassword(password);
await runQpdf([`--password=${password}`, "--decrypt", inputPath, outPath], 60_000);
}
/**
* Arbitrary qpdf pages spec against a single input (extract "1-3", explicit
* reorder "3,1,2", inverse keep-sets computed by callers). Same validated
* grammar as the wave-1 range ops.
*/
export async function qpdfPagesSpec(
inputPath: string,
spec: string,
outPath: string,
): Promise<void> {
assertValidRange(spec);
await runQpdf([inputPath, "--pages", ".", spec, "--", outPath], 60_000);
}
/**
* Internal variant of qpdfPagesSpec that validates the grammar (charset) but
* NOT the 200-character length cap. Use ONLY for specs built programmatically
* from validated integers (e.g. keepPages derived from parsePageSpec output),
* never for raw user input.
*
* Trust boundary: the caller guarantees every number in the spec originated
* from parsePageSpec (which validates bounds against the real page count).
* We still verify the characters are safe for the qpdf CLI.
*/
export async function qpdfPagesSpecUnchecked(
inputPath: string,
spec: string,
outPath: string,
): Promise<void> {
if (!RANGE_RE.test(spec)) {
throw new Error(`Invalid page range: ${spec.slice(0, 50)}`);
}
await runQpdf([inputPath, "--pages", ".", spec, "--", outPath], 60_000);
}
export async function qpdfLinearize(inputPath: string, outPath: string): Promise<void> {
await runQpdf(["--linearize", inputPath, outPath], 60_000);
}
/**
* Repair: qpdf's reader recovers damaged xref/structure where possible and
* the rewrite produces a clean file. Damaged-beyond-recovery inputs reject
* with qpdf's diagnostics.
*/
export async function qpdfRepair(inputPath: string, outPath: string): Promise<void> {
await runQpdf([inputPath, outPath], 60_000);
}
+171
View File
@@ -0,0 +1,171 @@
import { spawn } from "node:child_process";
import { resolvePdfcpu } from "./binaries.js";
/**
* Shared pdfcpu CLI runner. Mirrors runQpdf's spawn/settled/(code, signal)
* shape. The `-c disable` flag prevents config-dir writes on read-only
* container filesystems.
*/
function runPdfcpu(args: string[], timeoutMs = 60_000): Promise<string> {
const bin = resolvePdfcpu();
if (!bin) throw new Error("pdfcpu binary not found (set PDFCPU_PATH or install pdfcpu)");
return new Promise<string>((resolvePromise, reject) => {
const child = spawn(bin, ["-c", "disable", ...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(`pdfcpu 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(`pdfcpu exited ${code ?? signal}: ${err.slice(-1000) || out.slice(-1000)}`),
);
});
});
}
/**
* Crop all pages using a uniform margin in points.
*
* Verified CLI shape (pdfcpu v0.13.0):
* pdfcpu crop '<margin>' inFile outFile
* A single number sets all four margins (top, right, bottom, left) uniformly.
*
* Sanctioned simplification: the plan's four-sided margins object is replaced
* by a single uniform margin in points, matching pdfcpu's native single-number
* form. The route schema (Task 8) follows this shape.
*/
export async function pdfcpuCropMargin(
inputPath: string,
marginPoints: number,
outPath: string,
): Promise<void> {
if (!Number.isFinite(marginPoints) || marginPoints < 0 || marginPoints > 2000) {
throw new Error("Crop margin must be 0-2000 points");
}
await runPdfcpu(["crop", String(marginPoints), inputPath, outPath]);
}
/** Valid n-up values per pdfcpu v0.13.0 help. */
export type NupValue = 2 | 3 | 4 | 8 | 9 | 12 | 16;
const VALID_NUP = new Set<number>([2, 3, 4, 8, 9, 12, 16]);
/**
* N-up imposition: arrange multiple pages per sheet.
*
* Verified CLI shape (pdfcpu v0.13.0):
* pdfcpu nup outFile n inFile
* Note: outFile comes BEFORE n and inFile.
*/
export async function pdfcpuNup(inputPath: string, n: NupValue, outPath: string): Promise<void> {
if (!VALID_NUP.has(n)) {
throw new Error(`Invalid n-up value: ${n}. Must be one of: 2, 3, 4, 8, 9, 12, 16`);
}
await runPdfcpu(["nup", outPath, String(n), inputPath]);
}
/** Valid booklet values per pdfcpu v0.13.0 help. */
export type BookletValue = 2 | 4 | 6 | 8;
const VALID_BOOKLET = new Set<number>([2, 4, 6, 8]);
/**
* Booklet imposition: arrange pages for folding into a small book.
*
* Verified CLI shape (pdfcpu v0.13.0):
* pdfcpu booklet outFile n inFile
* Note: outFile comes BEFORE n and inFile.
*/
export async function pdfcpuBooklet(
inputPath: string,
n: BookletValue,
outPath: string,
): Promise<void> {
if (!VALID_BOOKLET.has(n)) {
throw new Error(`Invalid booklet value: ${n}. Must be one of: 2, 4, 6, 8`);
}
await runPdfcpu(["booklet", outPath, String(n), inputPath]);
}
const STAMP_POSITIONS = new Set(["tl", "tc", "tr", "l", "c", "r", "bl", "bc", "br"]);
export interface TextStampOptions {
text: string;
/** Position anchor: tl|tc|tr|l|c|r|bl|bc|br */
position: string;
/** Font size in points: 6..72 */
fontSize: number;
/** Opacity: 0.05..1 */
opacity: number;
/** Rotation in degrees: -180..180 */
rotation: number;
}
/**
* Text stamp/watermark on every page. Image stamps deferred to a later wave.
*
* Verified CLI shape (pdfcpu v0.13.0):
* pdfcpu stamp add -- 'text' 'description' inFile outFile
* --mode text is the default; no need to pass it explicitly.
*
* Description key names verified against pdfcpu v0.13.0 help:
* pos: position anchor
* points: font size (NOT "fontsize:")
* op: opacity (NOT "opacity:")
* rotation: rotation in degrees
*
* %p and %P are supported for page-number expansion:
* %p = current page number
* %P = total pages
* Verified in-container: stamp text "Page %p of %P" on a 3-page PDF produces
* "Page 1 of 3", "Page 2 of 3", "Page 3 of 3" on pages 1-3 respectively.
*/
export async function pdfcpuTextStamp(
inputPath: string,
opts: TextStampOptions,
outPath: string,
): Promise<void> {
if (opts.text.length === 0 || opts.text.length > 200) {
throw new Error("Stamp text must be 1-200 characters");
}
if (!STAMP_POSITIONS.has(opts.position)) {
throw new Error(
`Invalid stamp position: ${opts.position}. Must be one of: tl, tc, tr, l, c, r, bl, bc, br`,
);
}
if (!Number.isFinite(opts.fontSize) || opts.fontSize < 6 || opts.fontSize > 72) {
throw new Error("Font size must be 6-72");
}
if (!Number.isFinite(opts.opacity) || opts.opacity < 0.05 || opts.opacity > 1) {
throw new Error("Opacity must be 0.05-1");
}
if (!Number.isFinite(opts.rotation) || opts.rotation < -180 || opts.rotation > 180) {
throw new Error("Rotation must be -180..180");
}
const desc = `pos:${opts.position}, points:${opts.fontSize}, op:${opts.opacity}, rotation:${opts.rotation}`;
await runPdfcpu(["stamp", "add", "--", opts.text, desc, inputPath, outPath]);
}
+110
View File
@@ -9,3 +9,113 @@ export async function pdfPageCountPy(absPath: string): Promise<number> {
}
return parsed.pages;
}
/** Flatten forms/annotations into page content (PyMuPDF bake). */
export async function pdfFlattenPy(inPath: string, outPath: string): Promise<void> {
const stdout = await runDocsScript("doc_flatten", { path: inPath, out: outPath });
const parsed = JSON.parse(stdout.trim()) as { ok?: boolean; error?: string };
if (parsed.error) {
throw new Error(`doc_flatten failed: ${parsed.error}`);
}
}
/** True redaction with verification pass (PyMuPDF search + apply_redactions). */
export async function pdfRedactPy(
inPath: string,
outPath: string,
terms: string[],
caseSensitive: boolean,
): Promise<{ found: number }> {
const stdout = await runDocsScript("doc_redact", {
path: inPath,
out: outPath,
terms,
caseSensitive,
});
const parsed = JSON.parse(stdout.trim()) as {
found?: number;
verified?: boolean;
error?: string;
};
if (parsed.error) {
throw new Error(`doc_redact failed: ${parsed.error}`);
}
if (typeof parsed.found !== "number") {
throw new Error(`doc_redact failed: ${stdout.slice(0, 200)}`);
}
return { found: parsed.found };
}
/** Extract plain text from a PDF (PyMuPDF get_text). */
export async function pdfTextPy(inPath: string, outTxtPath: string): Promise<{ chars: number }> {
const stdout = await runDocsScript("doc_text", { path: inPath, out: outTxtPath });
const parsed = JSON.parse(stdout.trim()) as { chars?: number; error?: string };
if (parsed.error) {
throw new Error(`doc_text failed: ${parsed.error}`);
}
if (typeof parsed.chars !== "number") {
throw new Error(`doc_text failed: ${stdout.slice(0, 200)}`);
}
return { chars: parsed.chars };
}
/** PDF to DOCX conversion (pdf2docx). Long-running: 5 min timeout. */
export async function pdfToWordPy(inPath: string, outPath: string): Promise<void> {
const stdout = await runDocsScript(
"doc_to_word",
{ path: inPath, out: outPath },
{ timeoutMs: 300_000 },
);
const parsed = JSON.parse(stdout.trim()) as { ok?: boolean; error?: string };
if (parsed.error) {
throw new Error(`doc_to_word failed: ${parsed.error}`);
}
}
/** Read PDF document metadata (pikepdf docinfo). */
export async function pdfMetadataGetPy(inPath: string): Promise<Record<string, string>> {
const stdout = await runDocsScript("doc_metadata", { path: inPath, mode: "get" });
const parsed = JSON.parse(stdout.trim()) as { metadata?: Record<string, string>; error?: string };
if (parsed.error) {
throw new Error(`doc_metadata get failed: ${parsed.error}`);
}
if (!parsed.metadata || typeof parsed.metadata !== "object") {
throw new Error(`doc_metadata get failed: ${stdout.slice(0, 200)}`);
}
return parsed.metadata;
}
/** Write PDF document metadata (pikepdf docinfo). */
export async function pdfMetadataSetPy(
inPath: string,
outPath: string,
metadata: Record<string, string>,
): Promise<void> {
const stdout = await runDocsScript("doc_metadata", {
path: inPath,
out: outPath,
mode: "set",
metadata,
});
const parsed = JSON.parse(stdout.trim()) as { ok?: boolean; error?: string };
if (parsed.error) {
throw new Error(`doc_metadata set failed: ${parsed.error}`);
}
}
/** HTML or Markdown to PDF (WeasyPrint, SSRF-hardened). 2 min timeout. */
export async function htmlToPdfPy(
inPath: string,
outPath: string,
mode: "html" | "markdown",
): Promise<void> {
const stdout = await runDocsScript(
"doc_html_pdf",
{ path: inPath, out: outPath, mode },
{ timeoutMs: 120_000 },
);
const parsed = JSON.parse(stdout.trim()) as { ok?: boolean; error?: string };
if (parsed.error) {
throw new Error(`doc_html_pdf failed: ${parsed.error}`);
}
}
+41
View File
@@ -44,6 +44,47 @@ 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());