add pdf watermark tool
This commit is contained in:
80
src/tools/pdf-watermark/index.ts
Normal file
80
src/tools/pdf-watermark/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||
import { runPdfWatermark } from "./run";
|
||||
import { COLOR_PRESETS, MAX_WATERMARK_CHARS } from "./watermark";
|
||||
import PdfWorker from "./worker?worker";
|
||||
|
||||
export interface PdfWatermarkOptions {
|
||||
text: string;
|
||||
orientation: "horizontal" | "vertical" | "diagonal";
|
||||
opacity: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const pdfWatermarkPlugin: PlimiPlugin<PdfWatermarkOptions> = {
|
||||
manifest: {
|
||||
id: "pdf-watermark",
|
||||
name: "PDF Watermark",
|
||||
description:
|
||||
"Stamp custom text over every page of a PDF, entirely in your browser. The text auto-sizes to fit each page.",
|
||||
category: "pdf",
|
||||
version: "1.0.0",
|
||||
tags: ["pdf", "watermark", "stamp", "text"],
|
||||
input: {
|
||||
type: "files",
|
||||
accept: ["application/pdf"],
|
||||
multiple: false,
|
||||
},
|
||||
output: { type: "files" },
|
||||
offlineReady: true,
|
||||
},
|
||||
|
||||
optionsSchema: {
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
key: "text",
|
||||
label: `Watermark Text (max ${MAX_WATERMARK_CHARS} chars)`,
|
||||
defaultValue: "CONFIDENTIAL",
|
||||
placeholder: "e.g. CONFIDENTIAL",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "orientation",
|
||||
label: "Orientation",
|
||||
defaultValue: "diagonal",
|
||||
options: [
|
||||
{ label: "Diagonal", value: "diagonal" },
|
||||
{ label: "Horizontal", value: "horizontal" },
|
||||
{ label: "Vertical", value: "vertical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "slider",
|
||||
key: "opacity",
|
||||
label: "Opacity (%)",
|
||||
defaultValue: 30,
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "color",
|
||||
label: "Color",
|
||||
defaultValue: "light-grey",
|
||||
options: Object.entries(COLOR_PRESETS).map(([value, { label }]) => ({
|
||||
label,
|
||||
value,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
cancelable: true,
|
||||
worker: true,
|
||||
},
|
||||
|
||||
run: runPdfWatermark,
|
||||
worker: () => new PdfWorker(),
|
||||
};
|
||||
95
src/tools/pdf-watermark/run.test.ts
Normal file
95
src/tools/pdf-watermark/run.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { pdfWatermarkPlugin, type PdfWatermarkOptions } from "./index";
|
||||
import { runPdfWatermark } from "./run";
|
||||
import { MAX_WATERMARK_CHARS } from "./watermark";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
|
||||
const mockContext: ToolContext = {
|
||||
signal: new AbortController().signal,
|
||||
reportProgress: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
};
|
||||
|
||||
const defaultOptions: PdfWatermarkOptions = {
|
||||
text: "CONFIDENTIAL",
|
||||
orientation: "diagonal",
|
||||
opacity: 30,
|
||||
color: "light-grey",
|
||||
};
|
||||
|
||||
async function makePdfFile(): Promise<File> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage([595, 842]);
|
||||
const bytes = await doc.save();
|
||||
return new File([bytes as unknown as BlobPart], "sample.pdf", {
|
||||
type: "application/pdf",
|
||||
});
|
||||
}
|
||||
|
||||
describe("PDF Watermark Plugin", () => {
|
||||
it("should have correct manifest", () => {
|
||||
expect(pdfWatermarkPlugin.manifest.id).toBe("pdf-watermark");
|
||||
expect(pdfWatermarkPlugin.manifest.category).toBe("pdf");
|
||||
expect(pdfWatermarkPlugin.manifest.input.type).toBe("files");
|
||||
expect(pdfWatermarkPlugin.manifest.output.type).toBe("files");
|
||||
});
|
||||
|
||||
it("should throw if no file provided", async () => {
|
||||
await expect(
|
||||
runPdfWatermark({ files: [] }, defaultOptions, mockContext)
|
||||
).rejects.toThrow("No PDF file provided.");
|
||||
});
|
||||
|
||||
it("should throw if watermark text is empty", async () => {
|
||||
const file = await makePdfFile();
|
||||
await expect(
|
||||
runPdfWatermark({ files: [file] }, { ...defaultOptions, text: " " }, mockContext)
|
||||
).rejects.toThrow("Watermark text is required.");
|
||||
});
|
||||
|
||||
it("should produce a watermarked PDF", async () => {
|
||||
const file = await makePdfFile();
|
||||
const result = await runPdfWatermark(
|
||||
{ files: [file] },
|
||||
defaultOptions,
|
||||
mockContext
|
||||
);
|
||||
|
||||
expect(result.type).toBe("files");
|
||||
if (result.type !== "files") throw new Error("unexpected result");
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].name).toBe("sample_watermarked.pdf");
|
||||
expect(result.files[0].mimeType).toBe("application/pdf");
|
||||
expect(result.files[0].blob.size).toBeGreaterThan(0);
|
||||
|
||||
// Output should still be a loadable PDF with the same page count.
|
||||
const out = await result.files[0].blob.arrayBuffer();
|
||||
const reloaded = await PDFDocument.load(new Uint8Array(out));
|
||||
expect(reloaded.getPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it.each(["horizontal", "vertical", "diagonal"] as const)(
|
||||
"should handle %s orientation",
|
||||
async (orientation) => {
|
||||
const file = await makePdfFile();
|
||||
const result = await runPdfWatermark(
|
||||
{ files: [file] },
|
||||
{ ...defaultOptions, orientation },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("files");
|
||||
}
|
||||
);
|
||||
|
||||
it("should truncate text beyond the max length", async () => {
|
||||
const file = await makePdfFile();
|
||||
const longText = "A".repeat(MAX_WATERMARK_CHARS + 50);
|
||||
const result = await runPdfWatermark(
|
||||
{ files: [file] },
|
||||
{ ...defaultOptions, text: longText },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("files");
|
||||
});
|
||||
});
|
||||
22
src/tools/pdf-watermark/run.ts
Normal file
22
src/tools/pdf-watermark/run.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ToolInput } from "../../core/io/input-types";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import type { PdfWatermarkOptions } from "./index";
|
||||
import { applyWatermark } from "./watermark";
|
||||
|
||||
export async function runPdfWatermark(
|
||||
input: ToolInput,
|
||||
options: PdfWatermarkOptions,
|
||||
context: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const files = input.files;
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
throw new Error("No PDF file provided.");
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return applyWatermark(file.name, buffer, options, context.reportProgress);
|
||||
}
|
||||
149
src/tools/pdf-watermark/watermark.ts
Normal file
149
src/tools/pdf-watermark/watermark.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolProgress } from "../../core/plugins/plugin-types";
|
||||
|
||||
export type WatermarkOrientation = "horizontal" | "vertical" | "diagonal";
|
||||
|
||||
export interface PdfWatermarkOptions {
|
||||
text: string;
|
||||
orientation: WatermarkOrientation;
|
||||
/** Opacity from 0 (invisible) to 100 (fully opaque). */
|
||||
opacity: number;
|
||||
/** Preset color key, see COLOR_PRESETS. */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const MAX_WATERMARK_CHARS = 60;
|
||||
|
||||
export const COLOR_PRESETS: Record<string, { label: string; rgb: [number, number, number] }> = {
|
||||
"light-grey": { label: "Light Grey", rgb: [0.75, 0.75, 0.75] },
|
||||
grey: { label: "Grey", rgb: [0.5, 0.5, 0.5] },
|
||||
black: { label: "Black", rgb: [0, 0, 0] },
|
||||
red: { label: "Red", rgb: [0.86, 0.21, 0.21] },
|
||||
blue: { label: "Blue", rgb: [0.23, 0.4, 0.85] },
|
||||
green: { label: "Green", rgb: [0.2, 0.6, 0.33] },
|
||||
};
|
||||
|
||||
/** Fraction of the page the watermark may occupy, leaving a safe margin. */
|
||||
const FIT_MARGIN = 0.82;
|
||||
|
||||
/**
|
||||
* Compute the largest font size at which the rotated text still fits inside the
|
||||
* page, so the watermark is always shown in full without touching the edges.
|
||||
*
|
||||
* A line of text rotated by `angle` occupies an axis-aligned bounding box of:
|
||||
* width = |textW·cos| + |textH·sin|
|
||||
* height = |textW·sin| + |textH·cos|
|
||||
* Both textW and textH scale linearly with font size, so we measure at a probe
|
||||
* size and scale down to satisfy both the width and height constraints.
|
||||
*/
|
||||
function fitFontSize(
|
||||
measureWidth: (size: number) => number,
|
||||
measureHeight: (size: number) => number,
|
||||
pageW: number,
|
||||
pageH: number,
|
||||
angleRad: number
|
||||
): number {
|
||||
const probe = 100;
|
||||
const w = measureWidth(probe);
|
||||
const h = measureHeight(probe);
|
||||
if (w <= 0 || h <= 0) return 12;
|
||||
|
||||
const cos = Math.abs(Math.cos(angleRad));
|
||||
const sin = Math.abs(Math.sin(angleRad));
|
||||
|
||||
const bboxW = w * cos + h * sin;
|
||||
const bboxH = w * sin + h * cos;
|
||||
|
||||
const scaleW = (pageW * FIT_MARGIN) / bboxW;
|
||||
const scaleH = (pageH * FIT_MARGIN) / bboxH;
|
||||
|
||||
const size = probe * Math.min(scaleW, scaleH);
|
||||
return Math.max(6, Math.min(size, 300));
|
||||
}
|
||||
|
||||
export async function applyWatermark(
|
||||
fileName: string,
|
||||
buffer: ArrayBuffer | Uint8Array,
|
||||
options: PdfWatermarkOptions,
|
||||
reportProgress?: (progress: ToolProgress) => void
|
||||
): Promise<ToolResult> {
|
||||
const text = (options.text ?? "").trim().slice(0, MAX_WATERMARK_CHARS);
|
||||
if (!text) {
|
||||
throw new Error("Watermark text is required.");
|
||||
}
|
||||
|
||||
const pdfDoc = await PDFDocument.load(
|
||||
buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
||||
);
|
||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const preset = COLOR_PRESETS[options.color] ?? COLOR_PRESETS["light-grey"];
|
||||
const [r, g, b] = preset.rgb;
|
||||
const opacity = Math.max(0, Math.min(1, options.opacity / 100));
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
const rotation =
|
||||
options.orientation === "vertical"
|
||||
? 90
|
||||
: options.orientation === "diagonal"
|
||||
? 45
|
||||
: 0;
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
const angle = (rotation * Math.PI) / 180;
|
||||
|
||||
const fontSize = fitFontSize(
|
||||
(s) => font.widthOfTextAtSize(text, s),
|
||||
(s) => font.heightAtSize(s),
|
||||
width,
|
||||
height,
|
||||
angle
|
||||
);
|
||||
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
// Place so the (rotated) text is centered on the page.
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const x = cx - (textWidth / 2) * Math.cos(angle) + (textHeight / 2) * Math.sin(angle);
|
||||
const y = cy - (textWidth / 2) * Math.sin(angle) - (textHeight / 2) * Math.cos(angle);
|
||||
|
||||
page.drawText(text, {
|
||||
x,
|
||||
y,
|
||||
size: fontSize,
|
||||
font,
|
||||
color: rgb(r, g, b),
|
||||
opacity,
|
||||
rotate: degrees(rotation),
|
||||
});
|
||||
|
||||
reportProgress?.({
|
||||
percentage: ((i + 1) / pages.length) * 100,
|
||||
message: `Watermarking page ${i + 1}/${pages.length}...`,
|
||||
});
|
||||
}
|
||||
|
||||
const bytes = await pdfDoc.save();
|
||||
const blob = new Blob([bytes as unknown as BlobPart], {
|
||||
type: "application/pdf",
|
||||
});
|
||||
|
||||
const outName = fileName.replace(/\.pdf$/i, "") + "_watermarked.pdf";
|
||||
|
||||
return {
|
||||
type: "files",
|
||||
files: [
|
||||
{
|
||||
name: outName,
|
||||
mimeType: "application/pdf",
|
||||
blob,
|
||||
sizeAfter: blob.size,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
43
src/tools/pdf-watermark/worker.ts
Normal file
43
src/tools/pdf-watermark/worker.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
ToolWorkerRequest,
|
||||
ToolWorkerResponse,
|
||||
} from "../../core/plugins/worker-protocol";
|
||||
import type { PdfWatermarkOptions } from "./index";
|
||||
import { applyWatermark } from "./watermark";
|
||||
|
||||
function post(response: ToolWorkerResponse) {
|
||||
self.postMessage(response);
|
||||
}
|
||||
|
||||
self.addEventListener(
|
||||
"message",
|
||||
async (e: MessageEvent<ToolWorkerRequest<PdfWatermarkOptions>>) => {
|
||||
const { id, input, options } = e.data;
|
||||
const files = input.files;
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
post({ type: "error", id, error: "No PDF file provided." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
const result = await applyWatermark(file.name, buffer, options, (progress) =>
|
||||
post({ type: "progress", id, progress })
|
||||
);
|
||||
|
||||
post({ type: "success", id, result });
|
||||
} catch (error) {
|
||||
post({
|
||||
type: "error",
|
||||
id,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error occurred while watermarking the PDF.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user