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
+24
View File
@@ -71,6 +71,13 @@ interface ParsedStep {
pool: Pool;
}
/**
* Tools whose settings carry passwords. They are blocked from ALL pipeline
* paths so secrets never persist in step rows (the single-tool route redacts
* via dbSettings).
*/
const PASSWORD_TOOLS = new Set(["protect-pdf", "unlock-pdf"]);
/**
* Build a FlowJob tree for a single-file pipeline.
*
@@ -313,6 +320,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (PASSWORD_TOOLS.has(step.toolId)) {
return reply.status(400).send({
error: `Step ${i + 1}: This tool cannot be used in pipelines because it requires a password`,
});
}
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
@@ -483,6 +496,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// Validate all tool IDs exist
for (let i = 0; i < steps.length; i++) {
if (PASSWORD_TOOLS.has(steps[i].toolId)) {
return reply.status(400).send({
error: `This tool cannot be used in saved pipelines because it requires a password`,
});
}
const toolConfig = getToolConfig(steps[i].toolId);
if (!toolConfig) {
return reply.status(400).send({
@@ -696,6 +714,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (PASSWORD_TOOLS.has(step.toolId)) {
return reply.status(400).send({
error: `Step ${i + 1}: This tool cannot be used in pipelines because it requires a password`,
});
}
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
+22
View File
@@ -80,6 +80,20 @@ export interface ToolRouteConfig<T> {
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
/** Optional v2 process function. When set, the worker calls this instead of the legacy process. */
processV2?: ToolProcessV2;
/**
* When set, the factory passes `{ scratchDir, lenient: true }` to the
* input handler's prepare(). DocumentInputHandler skips qpdfCheck and
* page-cap when lenient, keeping only the %PDF- header check. Set on
* tools that intentionally accept damaged inputs (e.g. repair-pdf).
*/
skipStructuralValidation?: boolean;
/**
* When set, produces a redacted copy of settings for the durable DB
* row. Passwords and other secrets are replaced so they do not persist
* in the jobs table (retention keeps rows for days). The BullMQ job
* data keeps the real settings; the worker reads from job data.
*/
redactSettingsForAudit?: (settings: unknown) => Record<string, unknown>;
}
/** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */
@@ -94,6 +108,8 @@ export interface AnyToolRouteConfig {
ctx?: ToolProcessCtx,
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
processV2?: ToolProcessV2;
skipStructuralValidation?: boolean;
redactSettingsForAudit?: (settings: unknown) => Record<string, unknown>;
}
// ── Legacy adapter ────────────────────────────────────────────
@@ -289,6 +305,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
try {
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, {
scratchDir,
lenient: config.skipStructuralValidation,
});
fileBuffer = prepared.buffer;
fname = prepared.filename;
@@ -366,6 +383,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const pool = resolveToolPool(config.toolId);
// Enqueue for the BullMQ worker
const dbSettings = config.redactSettingsForAudit
? config.redactSettingsForAudit(settings)
: undefined;
await enqueueToolJob({
jobId,
toolId: config.toolId,
@@ -374,6 +394,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
inputRefs,
filename,
settings,
dbSettings,
fileId: fileId ?? undefined,
clientJobId: clientJobId ?? undefined,
kind: "tool",
@@ -404,6 +425,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
originalSize: result.originalSize,
processedSize: result.processedSize,
savedFileId: result.savedFileId,
...result.resultPayload,
});
}
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
+39
View File
@@ -0,0 +1,39 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { BookletValue } from "@snapotter/doc-engine";
import { pdfcpuBooklet } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
perSheet: z.union([z.literal(2), z.literal(4), z.literal(6), z.literal(8)]).default(2),
});
export function registerBookletPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "booklet-pdf",
settingsSchema,
process: async () => {
throw new Error("booklet-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_booklet.pdf`);
ctx.report(10, "Creating booklet");
await pdfcpuBooklet(inPath, settings.perSheet as BookletValue, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_booklet.pdf`,
contentType: "application/pdf",
};
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfcpuCropMargin } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
margin: z.number().min(0).max(2000).default(20),
});
export function registerCropPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "crop-pdf",
settingsSchema,
process: async () => {
throw new Error("crop-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_cropped.pdf`);
ctx.report(10, "Cropping");
await pdfcpuCropMargin(inPath, settings.margin, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_cropped.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPagesSpec } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const rangeField = z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range");
const settingsSchema = z.object({
range: rangeField,
});
export function registerExtractPages(app: FastifyInstance) {
createToolRoute(app, {
toolId: "extract-pages",
settingsSchema,
process: async () => {
throw new Error("extract-pages is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_extracted.pdf`);
ctx.report(10, "Extracting pages");
await qpdfPagesSpec(inPath, settings.range, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_extracted.pdf`,
contentType: "application/pdf",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfFlattenPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerFlattenPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "flatten-pdf",
settingsSchema,
process: async () => {
throw new Error("flatten-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_flattened.pdf`);
ctx.report(10, "Flattening");
await pdfFlattenPy(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_flattened.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { gsGrayscalePdf } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerGrayscalePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "grayscale-pdf",
settingsSchema,
process: async () => {
throw new Error("grayscale-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_grayscale.pdf`);
ctx.report(10, "Converting to grayscale");
await gsGrayscalePdf(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_grayscale.pdf`,
contentType: "application/pdf",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { htmlToPdfPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerHtmlToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "html-to-pdf",
settingsSchema,
process: async () => {
throw new Error("html-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.pdf`);
ctx.report(10, "Converting");
await htmlToPdfPy(inPath, outPath, "html");
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+42
View File
@@ -7,6 +7,7 @@ import { registerAiCanvasExpand } from "./ai-canvas-expand.js";
import { registerBarcodeRead } from "./barcode-read.js";
import { registerBeautify } from "./beautify.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerBookletPdf } from "./booklet-pdf.js";
import { registerBorder } from "./border.js";
import { registerBulkRename } from "./bulk-rename.js";
import { registerCollage } from "./collage.js";
@@ -23,32 +24,50 @@ import { registerConvert } from "./convert.js";
import { registerConvertAudio } from "./convert-audio.js";
import { registerConvertVideo } from "./convert-video.js";
import { registerCrop } from "./crop.js";
import { registerCropPdf } from "./crop-pdf.js";
import { registerCsvExcel } from "./csv-excel.js";
import { registerCsvJson } from "./csv-json.js";
import { registerEditMetadata } from "./edit-metadata.js";
import { registerEnhanceFaces } from "./enhance-faces.js";
import { registerEraseObject } from "./erase-object.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerExtractPages } from "./extract-pages.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerFlattenPdf } from "./flatten-pdf.js";
import { registerGifTools } from "./gif-tools.js";
import { registerGrayscalePdf } from "./grayscale-pdf.js";
import { registerHtmlToImage } from "./html-to-image.js";
import { registerHtmlToPdf } from "./html-to-pdf.js";
import { registerImageEnhancement } from "./image-enhancement.js";
import { registerImageToBase64 } from "./image-to-base64.js";
import { registerImageToPdf } from "./image-to-pdf.js";
import { registerInfo } from "./info.js";
import { registerJsonXml } from "./json-xml.js";
import { registerLinearizePdf } from "./linearize-pdf.js";
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMuteVideo } from "./mute-video.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerNupPdf } from "./nup-pdf.js";
import { registerOcr } from "./ocr.js";
import { registerOptimizeForWeb } from "./optimize-for-web.js";
import { registerOrganizePdf } from "./organize-pdf.js";
import { registerPassportPhoto } from "./passport-photo.js";
import { registerPdfMetadata } from "./pdf-metadata.js";
import { registerPdfPageNumbers } from "./pdf-page-numbers.js";
import { registerPdfToImage } from "./pdf-to-image.js";
import { registerPdfToText } from "./pdf-to-text.js";
import { registerPdfToWord } from "./pdf-to-word.js";
import { registerPdfaConvert } from "./pdfa-convert.js";
import { registerProtectPdf } from "./protect-pdf.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerRedEyeRemoval } from "./red-eye-removal.js";
import { registerRedactPdf } from "./redact-pdf.js";
import { registerRemoveBackground } from "./remove-background.js";
import { registerRemovePages } from "./remove-pages.js";
import { registerRepairPdf } from "./repair-pdf.js";
import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerRestorePhoto } from "./restore-photo.js";
@@ -66,10 +85,12 @@ import { registerTextOverlay } from "./text-overlay.js";
import { registerTransparencyFixer } from "./transparency-fixer.js";
import { registerTrimAudio } from "./trim-audio.js";
import { registerTrimVideo } from "./trim-video.js";
import { registerUnlockPdf } from "./unlock-pdf.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerVideoToGif } from "./video-to-gif.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerWatermarkPdf } from "./watermark-pdf.js";
import { registerWatermarkText } from "./watermark-text.js";
import { registerWordToPdf } from "./word-to-pdf.js";
@@ -174,6 +195,27 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "compress-pdf", register: registerCompressPdf },
{ id: "rotate-pdf", register: registerRotatePdf },
{ id: "word-to-pdf", register: registerWordToPdf },
{ id: "extract-pages", register: registerExtractPages },
{ id: "remove-pages", register: registerRemovePages },
{ id: "organize-pdf", register: registerOrganizePdf },
{ id: "protect-pdf", register: registerProtectPdf },
{ id: "unlock-pdf", register: registerUnlockPdf },
{ id: "repair-pdf", register: registerRepairPdf },
{ id: "linearize-pdf", register: registerLinearizePdf },
{ id: "grayscale-pdf", register: registerGrayscalePdf },
{ id: "pdfa-convert", register: registerPdfaConvert },
{ id: "crop-pdf", register: registerCropPdf },
{ id: "nup-pdf", register: registerNupPdf },
{ id: "booklet-pdf", register: registerBookletPdf },
{ id: "watermark-pdf", register: registerWatermarkPdf },
{ id: "pdf-page-numbers", register: registerPdfPageNumbers },
{ id: "flatten-pdf", register: registerFlattenPdf },
{ id: "redact-pdf", register: registerRedactPdf },
{ id: "pdf-to-text", register: registerPdfToText },
{ id: "pdf-to-word", register: registerPdfToWord },
{ id: "pdf-metadata", register: registerPdfMetadata },
{ id: "html-to-pdf", register: registerHtmlToPdf },
{ id: "markdown-to-pdf", register: registerMarkdownToPdf },
// Data Files
{ id: "csv-excel", register: registerCsvExcel },
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfLinearize } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerLinearizePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "linearize-pdf",
settingsSchema,
process: async () => {
throw new Error("linearize-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_linearized.pdf`);
ctx.report(10, "Linearizing");
await qpdfLinearize(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_linearized.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { htmlToPdfPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMarkdownToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "markdown-to-pdf",
settingsSchema,
process: async () => {
throw new Error("markdown-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.pdf`);
ctx.report(10, "Converting");
await htmlToPdfPy(inPath, outPath, "markdown");
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+49
View File
@@ -0,0 +1,49 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { NupValue } from "@snapotter/doc-engine";
import { pdfcpuNup } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
perSheet: z
.union([
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(8),
z.literal(9),
z.literal(12),
z.literal(16),
])
.default(2),
});
export function registerNupPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "nup-pdf",
settingsSchema,
process: async () => {
throw new Error("nup-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_nup.pdf`);
ctx.report(10, "Arranging pages");
await pdfcpuNup(inPath, settings.perSheet as NupValue, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_nup.pdf`,
contentType: "application/pdf",
};
},
});
}
+43
View File
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPagesSpec } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const rangeField = z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range");
const settingsSchema = z.object({
order: rangeField,
});
export function registerOrganizePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "organize-pdf",
settingsSchema,
process: async () => {
throw new Error("organize-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_organized.pdf`);
ctx.report(10, "Reordering pages");
await qpdfPagesSpec(inPath, settings.order, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_organized.pdf`,
contentType: "application/pdf",
};
},
});
}
+51
View File
@@ -0,0 +1,51 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfMetadataGetPy, pdfMetadataSetPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
title: z.string().max(500).optional(),
author: z.string().max(500).optional(),
subject: z.string().max(500).optional(),
keywords: z.string().max(500).optional(),
});
export function registerPdfMetadata(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-metadata",
settingsSchema,
process: async () => {
throw new Error("pdf-metadata is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_metadata.pdf`);
ctx.report(10, "Setting metadata");
// Build metadata record from DEFINED keys only (empty string = clear).
const meta: Record<string, string> = {};
if (settings.title !== undefined) meta.Title = settings.title;
if (settings.author !== undefined) meta.Author = settings.author;
if (settings.subject !== undefined) meta.Subject = settings.subject;
if (settings.keywords !== undefined) meta.Keywords = settings.keywords;
await pdfMetadataSetPy(inPath, outPath, meta);
const metadata = await pdfMetadataGetPy(outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_metadata.pdf`,
contentType: "application/pdf",
resultPayload: { metadata },
};
},
});
}
@@ -0,0 +1,49 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfcpuTextStamp } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
position: z.enum(["bl", "bc", "br", "tl", "tc", "tr"]).default("bc"),
fontSize: z.number().int().min(6).max(24).default(10),
});
export function registerPdfPageNumbers(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-page-numbers",
settingsSchema,
process: async () => {
throw new Error("pdf-page-numbers is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_numbered.pdf`);
ctx.report(10, "Adding page numbers");
await pdfcpuTextStamp(
inPath,
{
text: "Page %p of %P",
position: settings.position,
fontSize: settings.fontSize,
opacity: 1,
rotation: 0,
},
outPath,
);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_numbered.pdf`,
contentType: "application/pdf",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfTextPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPdfToText(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-to-text",
settingsSchema,
process: async () => {
throw new Error("pdf-to-text is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.txt`);
ctx.report(10, "Extracting text");
await pdfTextPy(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.txt`,
contentType: "text/plain",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfToWordPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPdfToWord(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-to-word",
settingsSchema,
process: async () => {
throw new Error("pdf-to-word is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.docx`);
ctx.report(10, "Converting");
await pdfToWordPy(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.docx`,
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { gsPdfaConvert } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPdfaConvert(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdfa-convert",
settingsSchema,
process: async () => {
throw new Error("pdfa-convert is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_pdfa.pdf`);
ctx.report(10, "Converting to PDF/A");
await gsPdfaConvert(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_pdfa.pdf`,
contentType: "application/pdf",
};
},
});
}
+52
View File
@@ -0,0 +1,52 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfEncrypt } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
userPassword: z.string().min(1).max(256),
ownerPassword: z.string().min(1).max(256).optional(),
});
export function registerProtectPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "protect-pdf",
settingsSchema,
redactSettingsForAudit: (settings) => {
const s = settings as z.infer<typeof settingsSchema>;
return {
...s,
userPassword: "<redacted>",
ownerPassword: "<redacted>",
};
},
process: async () => {
throw new Error("protect-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_protected.pdf`);
ctx.report(10, "Encrypting");
await qpdfEncrypt(
inPath,
settings.userPassword,
settings.ownerPassword ?? settings.userPassword,
outPath,
);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_protected.pdf`,
contentType: "application/pdf",
};
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfRedactPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
terms: z.array(z.string().min(1).max(200)).min(1).max(50),
caseSensitive: z.boolean().default(false),
});
export function registerRedactPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "redact-pdf",
settingsSchema,
process: async () => {
throw new Error("redact-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_redacted.pdf`);
ctx.report(10, "Redacting");
const { found } = await pdfRedactPy(inPath, outPath, settings.terms, settings.caseSensitive);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_redacted.pdf`,
contentType: "application/pdf",
resultPayload: { found },
};
},
});
}
+61
View File
@@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPageCount, qpdfPagesSpecUnchecked } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { compressPageRuns, parsePageSpec } from "../../lib/page-spec.js";
import { createToolRoute } from "../tool-factory.js";
const rangeField = z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range");
const settingsSchema = z.object({
pages: rangeField,
});
export function registerRemovePages(app: FastifyInstance) {
createToolRoute(app, {
toolId: "remove-pages",
settingsSchema,
process: async () => {
throw new Error("remove-pages is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Analyzing pages");
const total = await qpdfPageCount(inPath);
const removeSet = parsePageSpec(settings.pages, total);
// Build the keep list: all pages NOT in the remove set
const keepPages: number[] = [];
for (let i = 1; i <= total; i++) {
if (!removeSet.has(i)) {
keepPages.push(i);
}
}
if (keepPages.length === 0) {
throw new Error("Cannot remove every page from the document");
}
const keepSpec = compressPageRuns(keepPages);
const outPath = join(ctx.scratchDir, `${base}_removed.pdf`);
ctx.report(30, "Removing pages");
await qpdfPagesSpecUnchecked(inPath, keepSpec, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_removed.pdf`,
contentType: "application/pdf",
};
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfRepair } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerRepairPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "repair-pdf",
settingsSchema,
skipStructuralValidation: true,
process: async () => {
throw new Error("repair-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_repaired.pdf`);
ctx.report(10, "Repairing");
await qpdfRepair(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_repaired.pdf`,
contentType: "application/pdf",
};
},
});
}
+45
View File
@@ -0,0 +1,45 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfDecrypt } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
password: z.string().min(1).max(256),
});
export function registerUnlockPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "unlock-pdf",
settingsSchema,
redactSettingsForAudit: (settings) => {
const s = settings as z.infer<typeof settingsSchema>;
return {
...s,
password: "<redacted>",
};
},
process: async () => {
throw new Error("unlock-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_unlocked.pdf`);
ctx.report(10, "Decrypting");
await qpdfDecrypt(inPath, settings.password, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_unlocked.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,52 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfcpuTextStamp } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
text: z.string().min(1).max(200),
position: z.enum(["tl", "tc", "tr", "l", "c", "r", "bl", "bc", "br"]).default("c"),
fontSize: z.number().int().min(6).max(72).default(48),
opacity: z.number().min(0.05).max(1).default(0.3),
rotation: z.number().min(-180).max(180).default(45),
});
export function registerWatermarkPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "watermark-pdf",
settingsSchema,
process: async () => {
throw new Error("watermark-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_watermarked.pdf`);
ctx.report(10, "Stamping watermark");
await pdfcpuTextStamp(
inPath,
{
text: settings.text,
position: settings.position,
fontSize: settings.fontSize,
opacity: settings.opacity,
rotation: settings.rotation,
},
outPath,
);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_watermarked.pdf`,
contentType: "application/pdf",
};
},
});
}