mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 2 - pdf depth (21 tools) (#220)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user