add pdf watermark tool

This commit is contained in:
achraf
2026-06-07 20:13:05 +02:00
parent 365b98c7dd
commit 543122329e
9 changed files with 1105 additions and 7 deletions

View 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(),
};

View 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");
});
});

View 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);
}

View 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,
},
],
};
}

View 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.",
});
}
}
);