mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add Sign PDF tool (draw/type/upload signatures, place on a PDF) (#370)
Draw, type, or upload a signature and place resizable/rotatable copies across PDF pages; output flattened server-side with PyMuPDF. Visual electronic signature, not cryptographic. New interactive-sign display mode (pdf.js + Konva) and a custom docs-pool route.
This commit is contained in:
@@ -124,6 +124,7 @@ import { registerRotate } from "./rotate.js";
|
||||
import { registerRotatePdf } from "./rotate-pdf.js";
|
||||
import { registerRotateVideo } from "./rotate-video.js";
|
||||
import { registerSharpening } from "./sharpening.js";
|
||||
import { registerSignPdf } from "./sign-pdf.js";
|
||||
import { registerSilenceRemoval } from "./silence-removal.js";
|
||||
import { registerSmartCrop } from "./smart-crop.js";
|
||||
import { registerSplit } from "./split.js";
|
||||
@@ -364,6 +365,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "ocr-pdf", register: registerOcrPdf },
|
||||
{ id: "blur-faces", register: registerBlurFaces },
|
||||
{ id: "erase-object", register: registerEraseObject },
|
||||
{ id: "sign-pdf", register: registerSignPdf },
|
||||
{ id: "smart-crop", register: registerSmartCrop },
|
||||
{ id: "image-enhancement", register: registerImageEnhancement },
|
||||
{ id: "content-aware-resize", register: registerContentAwareResize },
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pdfSignPy } from "@snapotter/doc-engine";
|
||||
import type { SignPlacement } from "@snapotter/shared";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob, waitForJob } from "../../jobs/enqueue.js";
|
||||
import { stripInternalPaths } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { getObjectBuffer } from "../../lib/object-storage.js";
|
||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||
import { inputHandlerFor } from "../../modality/input-handler.js";
|
||||
import { getAuthUser } from "../../plugins/auth.js";
|
||||
|
||||
const TOOL_ID = "sign-pdf";
|
||||
const MAX_PLACEMENTS = 100;
|
||||
const MAX_SIGS = 100;
|
||||
const SIG_FIELD = /^sig(\d+)$/;
|
||||
|
||||
const placementSchema = z.object({
|
||||
sig: z.number().int().min(0),
|
||||
page: z.number().int().min(0),
|
||||
// Page fractions, top-left origin. Off-page bleed is tolerated in every
|
||||
// direction (a signature nudged past an edge or sized larger than the page);
|
||||
// PyMuPDF clips the rect to the page. Bounded to keep the rect sane.
|
||||
x: z.number().min(-2).max(2),
|
||||
y: z.number().min(-2).max(2),
|
||||
w: z.number().min(0).max(4),
|
||||
h: z.number().min(0).max(4),
|
||||
});
|
||||
const settingsSchema = z.object({
|
||||
placements: z.array(placementSchema).min(1).max(MAX_PLACEMENTS),
|
||||
});
|
||||
|
||||
/**
|
||||
* Sign PDF route.
|
||||
* Accepts a PDF plus one or more signature PNGs (fieldnames sig0, sig1, ...)
|
||||
* and a `placements` field (a JSON array of {sig,page,x,y,w,h} in normalized
|
||||
* 0..1 coordinates). Stamps each signature onto the PDF via PyMuPDF.
|
||||
*
|
||||
* Hand-written route (not createToolRoute): it takes a secondary file part and
|
||||
* registers worker logic via registerAiJobHandler, which keeps the tool out of
|
||||
* the pipeline/batch process-fn registry. Enqueues to the docs pool with
|
||||
* kind "ai-tool"; the worker auto-generates the PDF first-page preview.
|
||||
*/
|
||||
export function registerSignPdf(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/pdf/sign-pdf", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let pdfKey: string | null = null;
|
||||
let filename = "document.pdf";
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let placementsRaw: string | null = null;
|
||||
const sigParts: Array<{ index: number; key: string }> = [];
|
||||
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "file") {
|
||||
const m = part.fieldname.match(SIG_FIELD);
|
||||
if (m) {
|
||||
if (sigParts.length >= MAX_SIGS) {
|
||||
part.file.resume();
|
||||
continue;
|
||||
}
|
||||
const upload = await receiveUpload(part, jobId);
|
||||
sigParts.push({ index: Number(m[1]), key: upload.key });
|
||||
} else {
|
||||
const upload = await receiveUpload(part, jobId);
|
||||
pdfKey = upload.key;
|
||||
filename = upload.filename;
|
||||
}
|
||||
} else if (part.fieldname === "placements") {
|
||||
placementsRaw = part.value as string;
|
||||
} else if (part.fieldname === "clientJobId") {
|
||||
const raw = part.value as string;
|
||||
if (/^[0-9a-f-]{36}$/i.test(raw)) clientJobId = raw;
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
|
||||
if (!pdfKey) return reply.status(400).send({ error: "No PDF file provided" });
|
||||
if (!placementsRaw) return reply.status(400).send({ error: "No placements provided" });
|
||||
|
||||
let parsed: z.infer<typeof settingsSchema>;
|
||||
try {
|
||||
parsed = settingsSchema.parse({ placements: JSON.parse(placementsRaw) });
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid placements",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
|
||||
const sigIndexes = new Set(sigParts.map((s) => s.index));
|
||||
for (const p of parsed.placements) {
|
||||
if (!sigIndexes.has(p.sig)) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: `Missing signature image for placement (sig ${p.sig})` });
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBuffer = await getObjectBuffer(pdfKey);
|
||||
try {
|
||||
await inputHandlerFor("document").prepare(pdfBuffer, filename, { scratchDir: tmpdir() });
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid PDF",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
|
||||
const orderedSigKeys: string[] = [];
|
||||
for (const s of [...sigParts].sort((a, b) => a.index - b.index)) {
|
||||
const buf = await getObjectBuffer(s.key);
|
||||
const v = await validateImageBuffer(buf, `sig${s.index}.png`);
|
||||
if (!v.valid) {
|
||||
return reply.status(400).send({ error: `Invalid signature image: ${v.reason}` });
|
||||
}
|
||||
orderedSigKeys.push(s.key);
|
||||
}
|
||||
|
||||
await enqueueToolJob({
|
||||
jobId,
|
||||
toolId: TOOL_ID,
|
||||
userId,
|
||||
pool: "docs",
|
||||
inputRefs: [pdfKey, ...orderedSigKeys],
|
||||
filename,
|
||||
settings: { placements: parsed.placements },
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForJob("docs", jobId);
|
||||
if (result) {
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||
previewUrl: result.previewRef
|
||||
? `/api/v1/download/${jobId}/${result.previewRef.split("/").pop()}`
|
||||
: undefined,
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
savedFileId: result.savedFileId,
|
||||
});
|
||||
}
|
||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: TOOL_ID }, "sign-pdf processing failed");
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerAiJobHandler(TOOL_ID, async (input, data, ctx) => {
|
||||
const { placements } = settingsSchema.parse(data.settings);
|
||||
const dir = await mkdtemp(join(ctx.scratchDir, "sign-"));
|
||||
try {
|
||||
const inPath = join(dir, "in.pdf");
|
||||
const outPath = join(dir, "out.pdf");
|
||||
await writeFile(inPath, input);
|
||||
const sigPaths: string[] = [];
|
||||
for (let i = 1; i < data.inputRefs.length; i++) {
|
||||
const buf = await getObjectBuffer(data.inputRefs[i]);
|
||||
const p = join(dir, `sig${i - 1}.png`);
|
||||
await writeFile(p, buf);
|
||||
sigPaths.push(p);
|
||||
}
|
||||
ctx.report(20, "stamping");
|
||||
await pdfSignPy(inPath, outPath, sigPaths, placements as SignPlacement[]);
|
||||
ctx.report(90, "saving");
|
||||
const buffer = await readFile(outPath);
|
||||
const outName = `${data.filename.replace(/\.[^.]+$/, "")}_signed.pdf`;
|
||||
return { buffer, filename: outName, contentType: "application/pdf" };
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -273,6 +273,8 @@
|
||||
/tools/flatten-pdf /tools/pdf/flatten-pdf/ 301
|
||||
/tools/redact-pdf/ /tools/pdf/redact-pdf/ 301
|
||||
/tools/redact-pdf /tools/pdf/redact-pdf/ 301
|
||||
/tools/sign-pdf/ /tools/pdf/sign-pdf/ 301
|
||||
/tools/sign-pdf /tools/pdf/sign-pdf/ 301
|
||||
/tools/pdf-to-text/ /tools/pdf/pdf-to-text/ 301
|
||||
/tools/pdf-to-text /tools/pdf/pdf-to-text/ 301
|
||||
/tools/pdf-to-word/ /tools/pdf/pdf-to-word/ 301
|
||||
|
||||
@@ -7,7 +7,7 @@ const { display: pullsDisplay } = await getImagePulls();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: "240",
|
||||
value: "241",
|
||||
label: "Processing Tools",
|
||||
sublabel: "Across 5 file modalities",
|
||||
},
|
||||
|
||||
@@ -91,11 +91,11 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to Smallpdf",
|
||||
h1: "The open-source, self-hosted alternative to Smallpdf",
|
||||
metaDescription:
|
||||
"Smallpdf uploads your documents to its servers. SnapOtter runs the same PDF tools on yours. 28 PDF tools plus 212 more for image, video, audio, and files. Open source, AGPLv3.",
|
||||
"Smallpdf uploads your documents to its servers. SnapOtter runs the same PDF tools on yours. 29 PDF tools plus 212 more for image, video, audio, and files. Open source, AGPLv3.",
|
||||
intro:
|
||||
"Smallpdf is a cloud PDF suite, so every file you touch goes up to its servers. SnapOtter runs the same kinds of tools on hardware you own, and the file never leaves it.",
|
||||
breadth:
|
||||
"Smallpdf does PDFs. SnapOtter ships 28 PDF tools (merge, split, compress, convert, redact, OCR, and more) and another 212 across image, video, audio, and files, so one stack covers what you'd otherwise spread across several accounts.",
|
||||
"Smallpdf does PDFs. SnapOtter ships 29 PDF tools (merge, split, compress, convert, redact, OCR, and more) and another 212 across image, video, audio, and files, so one stack covers what you'd otherwise spread across several accounts.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("PDFs", "Subscription, free tier with daily limits", "PDF only"),
|
||||
faqs: [
|
||||
@@ -124,7 +124,7 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
intro:
|
||||
"iLovePDF is a hosted PDF service, which means your documents are uploaded to process them. SnapOtter gives you the same toolset to run yourself, with the file staying on your server.",
|
||||
breadth:
|
||||
"iLovePDF stays inside PDFs. SnapOtter pairs 28 PDF tools with image, video, audio, and file tools, so the same deployment handles a contract, a screen recording, and a podcast edit.",
|
||||
"iLovePDF stays inside PDFs. SnapOtter pairs 29 PDF tools with image, video, audio, and file tools, so the same deployment handles a contract, a screen recording, and a podcast edit.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("PDFs", "Subscription, free tier with limits", "PDF only"),
|
||||
faqs: [
|
||||
@@ -174,7 +174,7 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
intro:
|
||||
"CloudConvert is a hosted converter, so files are uploaded and conversions are metered. SnapOtter converts across every modality on hardware you own, with no metering and no upload.",
|
||||
breadth:
|
||||
"CloudConvert converts. SnapOtter converts too, across image, video, audio, PDF, and data formats, and then adds compression, editing, OCR, transcription, and pipelines, so conversion is one of 240 things it does.",
|
||||
"CloudConvert converts. SnapOtter converts too, across image, video, audio, PDF, and data formats, and then adds compression, editing, OCR, transcription, and pipelines, so conversion is one of 241 things it does.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("file conversion", "Pay per conversion / minutes", "Conversion only"),
|
||||
faqs: [
|
||||
@@ -284,7 +284,7 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "240",
|
||||
snapotter: "241",
|
||||
competitor: "50+ PDF tools",
|
||||
snapotterWins: true,
|
||||
},
|
||||
@@ -358,7 +358,7 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "240",
|
||||
snapotter: "241",
|
||||
competitor: "Conversion-focused",
|
||||
snapotterWins: true,
|
||||
},
|
||||
|
||||
@@ -6795,6 +6795,38 @@ export const TOOL_SEO: Record<string, ToolSeo> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"sign-pdf": {
|
||||
searchTitle: "Sign a PDF Online Free",
|
||||
longDescription:
|
||||
"Add a signature to a PDF by drawing it, typing it, or uploading a signature image, then drag it onto the page and resize it to fit. This is a visual electronic signature for approvals and sign-off, not a cryptographic certificate-based digital signature. Runs locally on your SnapOtter instance, so contracts and agreements never leave your network.",
|
||||
useCases: [
|
||||
"Signing a contract or agreement before sending it back",
|
||||
"Filling in and signing an onboarding or HR form",
|
||||
"Placing initials on each page of a multi-page document",
|
||||
"Signing an invoice without printing and scanning it",
|
||||
],
|
||||
features: [
|
||||
"Draw, type, or upload a signature image",
|
||||
"Drag to position and resize the signature anywhere on a page",
|
||||
"Add signatures or initials across one page or many",
|
||||
"Reuse saved signatures stored locally in your browser",
|
||||
"Visual electronic signature applied as a non-destructive overlay",
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "How do I sign a PDF?",
|
||||
a: "Draw your signature, type it, or upload an image of it, then drag it onto the page and resize it to fit. Everything is processed locally on your SnapOtter instance.",
|
||||
},
|
||||
{
|
||||
q: "Is this a cryptographic digital signature?",
|
||||
a: "No. SnapOtter adds a visual electronic signature that you draw, type, or upload and place on the page. It does not apply a cryptographic certificate-based digital signature or embed a PKI certificate, so use a dedicated PKI tool if you need a tamper-evident certified signature.",
|
||||
},
|
||||
{
|
||||
q: "Are my signed documents kept private?",
|
||||
a: "Yes. SnapOtter signs PDFs entirely on your self-hosted instance, so your documents and signatures are never uploaded to an external server or cloud service.",
|
||||
},
|
||||
],
|
||||
},
|
||||
"pdf-page-numbers": {
|
||||
searchTitle: "Add Page Numbers to PDF Free",
|
||||
longDescription:
|
||||
|
||||
@@ -50,7 +50,7 @@ const allJsonLd = [breadcrumbJsonLd, itemListJsonLd];
|
||||
</h1>
|
||||
<p class="mx-auto mt-5 max-w-2xl text-lg leading-relaxed text-muted md:text-xl">
|
||||
Most file tools upload your work to someone else's server. SnapOtter runs the same kinds of
|
||||
tools on yours: 240 of them across image, video, audio, PDF, and files, in one self-hosted
|
||||
tools on yours: 241 of them across image, video, audio, PDF, and files, in one self-hosted
|
||||
stack.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import type { SignPlacement } from "@snapotter/shared";
|
||||
import Konva from "konva";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { toNormalizedRect } from "@/lib/sign-geometry";
|
||||
import type { SavedSignature } from "@/lib/signature-store";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const RENDER_SCALE = 1.5; // on-screen scale; placements are normalized so this is cosmetic
|
||||
const EXPORT_QUALITY = 2; // raster the baked PNG at ~2x page points for crispness
|
||||
const KONVA_CONTAINER_ID = "sign-konva-container";
|
||||
|
||||
/** Rendered size (px) and page size (points) for one page, captured at render time. */
|
||||
interface PageMeta {
|
||||
sizeW: number;
|
||||
sizeH: number;
|
||||
ptsW: number;
|
||||
ptsH: number;
|
||||
}
|
||||
|
||||
/** A placed signature node, tagged with the page it belongs to. */
|
||||
interface PlacedSig {
|
||||
id: string;
|
||||
page: number;
|
||||
node: Konva.Image;
|
||||
}
|
||||
|
||||
export interface SignCanvasRef {
|
||||
addSignature: (sig: SavedSignature) => void;
|
||||
deleteSelected: () => void;
|
||||
exportPlacements: () => Promise<{ pngs: Blob[]; placements: SignPlacement[] }>;
|
||||
hasPlacements: () => boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
fileUrl: string;
|
||||
onSelectionChange?: (hasSelection: boolean) => void;
|
||||
onCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
|
||||
{ fileUrl, onSelectionChange, onCountChange },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stageRef = useRef<Konva.Stage | null>(null);
|
||||
const layerRef = useRef<Konva.Layer | null>(null);
|
||||
const trRef = useRef<Konva.Transformer | null>(null);
|
||||
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
||||
// Flat list of every placed node across all pages. Nodes live here for the
|
||||
// component's lifetime; page navigation only attaches/detaches them from the
|
||||
// layer (never destroys), so revisiting a page keeps its signatures.
|
||||
const placementsRef = useRef<PlacedSig[]>([]);
|
||||
// Per-page render size (px) + page size (points), captured when each page renders.
|
||||
const pageMetaRef = useRef<Map<number, PageMeta>>(new Map());
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
// Flips true once the document is loaded. Drives the render effect to run for
|
||||
// the first page, since setPage(0) is a no-op when page is already 0 (and
|
||||
// pageCount never changes for single-page PDFs).
|
||||
const [docReady, setDocReady] = useState(false);
|
||||
|
||||
// Load the document once per file. A replaced file starts clean: drop the
|
||||
// previous document's placements and stage so they don't carry over.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset + reload run only on file change; the parent callbacks are stable setters
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDocReady(false);
|
||||
for (const placed of placementsRef.current) placed.node.destroy();
|
||||
placementsRef.current = [];
|
||||
pageMetaRef.current.clear();
|
||||
stageRef.current?.destroy();
|
||||
stageRef.current = null;
|
||||
layerRef.current = null;
|
||||
trRef.current = null;
|
||||
onCountChange?.(0);
|
||||
onSelectionChange?.(false);
|
||||
(async () => {
|
||||
const doc = await pdfjs.getDocument({ url: fileUrl }).promise;
|
||||
if (cancelled) return;
|
||||
docRef.current = doc;
|
||||
setPageCount(doc.numPages);
|
||||
setPage(0);
|
||||
setDocReady(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
docRef.current?.loadingTask.destroy();
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
// Render the current page and show that page's nodes. The Konva stage is
|
||||
// created once (lazily) and reused; switching pages resizes it and swaps which
|
||||
// signature nodes are attached.
|
||||
useEffect(() => {
|
||||
if (!docReady) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const doc = docRef.current;
|
||||
const canvas = pdfCanvasRef.current;
|
||||
if (!doc || !canvas) return;
|
||||
const pdfPage = await doc.getPage(page + 1);
|
||||
if (cancelled) return;
|
||||
const ptsViewport = pdfPage.getViewport({ scale: 1 });
|
||||
const viewport = pdfPage.getViewport({ scale: RENDER_SCALE });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
setSize({ w: viewport.width, h: viewport.height });
|
||||
await pdfPage.render({ canvas, viewport }).promise;
|
||||
if (cancelled) return;
|
||||
|
||||
pageMetaRef.current.set(page, {
|
||||
sizeW: viewport.width,
|
||||
sizeH: viewport.height,
|
||||
ptsW: ptsViewport.width,
|
||||
ptsH: ptsViewport.height,
|
||||
});
|
||||
|
||||
let stage = stageRef.current;
|
||||
if (!stage) {
|
||||
stage = new Konva.Stage({
|
||||
container: KONVA_CONTAINER_ID,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
const layer = new Konva.Layer();
|
||||
const tr = new Konva.Transformer({
|
||||
rotateEnabled: true,
|
||||
keepRatio: true,
|
||||
enabledAnchors: ["top-left", "top-right", "bottom-left", "bottom-right"],
|
||||
// Keep resize/rotate within the page bounds.
|
||||
boundBoxFunc: (oldBox, newBox) => {
|
||||
const s = stageRef.current;
|
||||
if (!s) return newBox;
|
||||
if (
|
||||
newBox.x < 0 ||
|
||||
newBox.y < 0 ||
|
||||
newBox.x + newBox.width > s.width() ||
|
||||
newBox.y + newBox.height > s.height()
|
||||
) {
|
||||
return oldBox;
|
||||
}
|
||||
return newBox;
|
||||
},
|
||||
});
|
||||
layer.add(tr);
|
||||
stage.add(layer);
|
||||
stageRef.current = stage;
|
||||
layerRef.current = layer;
|
||||
trRef.current = tr;
|
||||
stage.on("click tap", (e) => {
|
||||
if (e.target === stage) {
|
||||
tr.nodes([]);
|
||||
onSelectionChange?.(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
stage.width(viewport.width);
|
||||
stage.height(viewport.height);
|
||||
}
|
||||
|
||||
const layer = layerRef.current;
|
||||
const tr = trRef.current;
|
||||
if (!layer || !tr) return;
|
||||
// Detach the previously shown signatures (keep the transformer), then
|
||||
// attach this page's. Nodes are never destroyed here.
|
||||
tr.nodes([]);
|
||||
for (const child of [...layer.getChildren()]) {
|
||||
if (child instanceof Konva.Image) child.remove();
|
||||
}
|
||||
for (const placed of placementsRef.current) {
|
||||
if (placed.page === page) layer.add(placed.node);
|
||||
}
|
||||
layer.batchDraw();
|
||||
onSelectionChange?.(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, docReady, onSelectionChange]);
|
||||
|
||||
// Destroy the stage and all nodes on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const placed of placementsRef.current) placed.node.destroy();
|
||||
placementsRef.current = [];
|
||||
stageRef.current?.destroy();
|
||||
stageRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const emitCount = () => onCountChange?.(placementsRef.current.length);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the handle reads page/size snapshots; parent callbacks are stable and deliberately excluded to avoid rebuilding the handle every render
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
addSignature(sig) {
|
||||
const layer = layerRef.current;
|
||||
const tr = trRef.current;
|
||||
if (!layer || !tr) return;
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
const targetW = size.w * 0.28;
|
||||
const scale = targetW / img.width;
|
||||
const node = new Konva.Image({
|
||||
image: img,
|
||||
x: size.w * 0.36,
|
||||
y: size.h * 0.45,
|
||||
width: img.width * scale,
|
||||
height: img.height * scale,
|
||||
draggable: true,
|
||||
});
|
||||
// Keep the dragged signature's bounding box within the page.
|
||||
node.dragBoundFunc((pos) => {
|
||||
const s = stageRef.current;
|
||||
if (!s) return pos;
|
||||
const box = node.getClientRect({ relativeTo: s });
|
||||
const dx = box.x - node.x();
|
||||
const dy = box.y - node.y();
|
||||
let x = pos.x;
|
||||
let y = pos.y;
|
||||
if (x + dx < 0) x = -dx;
|
||||
if (y + dy < 0) y = -dy;
|
||||
if (x + dx + box.width > s.width()) x = s.width() - box.width - dx;
|
||||
if (y + dy + box.height > s.height()) y = s.height() - box.height - dy;
|
||||
return { x, y };
|
||||
});
|
||||
node.on("click tap", () => {
|
||||
tr.nodes([node]);
|
||||
onSelectionChange?.(true);
|
||||
});
|
||||
layer.add(node);
|
||||
tr.nodes([node]);
|
||||
layer.batchDraw();
|
||||
placementsRef.current.push({ id: crypto.randomUUID(), page, node });
|
||||
onSelectionChange?.(true);
|
||||
emitCount();
|
||||
};
|
||||
img.src = sig.dataUrl;
|
||||
},
|
||||
deleteSelected() {
|
||||
const tr = trRef.current;
|
||||
const layer = layerRef.current;
|
||||
if (!tr || !layer) return;
|
||||
for (const n of tr.nodes()) {
|
||||
placementsRef.current = placementsRef.current.filter((p) => p.node !== n);
|
||||
n.destroy();
|
||||
}
|
||||
tr.nodes([]);
|
||||
layer.batchDraw();
|
||||
onSelectionChange?.(false);
|
||||
emitCount();
|
||||
},
|
||||
hasPlacements() {
|
||||
return placementsRef.current.length > 0;
|
||||
},
|
||||
async exportPlacements() {
|
||||
const pngs: Blob[] = [];
|
||||
const placements: SignPlacement[] = [];
|
||||
const layer = layerRef.current;
|
||||
const tr = trRef.current;
|
||||
const selected = tr?.nodes() ?? [];
|
||||
tr?.nodes([]);
|
||||
// Detach all visible signatures; we re-attach each node one at a time so
|
||||
// getClientRect/toDataURL run on an attached node regardless of page.
|
||||
if (layer) {
|
||||
for (const child of [...layer.getChildren()]) {
|
||||
if (child instanceof Konva.Image) child.remove();
|
||||
}
|
||||
}
|
||||
let sigIndex = 0;
|
||||
for (const placed of placementsRef.current) {
|
||||
const meta = pageMetaRef.current.get(placed.page);
|
||||
if (!meta || !layer) continue;
|
||||
layer.add(placed.node);
|
||||
const box = placed.node.getClientRect({ relativeTo: layer });
|
||||
const norm = toNormalizedRect(
|
||||
{ x: box.x, y: box.y, w: box.width, h: box.height },
|
||||
meta.sizeW,
|
||||
meta.sizeH,
|
||||
);
|
||||
placements.push({
|
||||
sig: sigIndex,
|
||||
page: placed.page,
|
||||
x: norm.x,
|
||||
y: norm.y,
|
||||
w: norm.w,
|
||||
h: norm.h,
|
||||
});
|
||||
const ratio = (EXPORT_QUALITY * meta.ptsW) / meta.sizeW;
|
||||
const dataUrl = placed.node.toDataURL({ pixelRatio: ratio });
|
||||
pngs.push(await (await fetch(dataUrl)).blob());
|
||||
placed.node.remove();
|
||||
sigIndex++;
|
||||
}
|
||||
// Restore the current page's view.
|
||||
if (layer) {
|
||||
for (const placed of placementsRef.current) {
|
||||
if (placed.page === page) layer.add(placed.node);
|
||||
}
|
||||
if (selected.length) tr?.nodes(selected);
|
||||
layer.batchDraw();
|
||||
}
|
||||
return { pngs, placements };
|
||||
},
|
||||
}),
|
||||
[page, size],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center gap-3 p-4">
|
||||
<div className="relative" style={{ width: size.w, height: size.h }}>
|
||||
<canvas
|
||||
ref={pdfCanvasRef}
|
||||
data-testid="sign-pdf-canvas"
|
||||
className="rounded border border-border shadow"
|
||||
/>
|
||||
<div id={KONVA_CONTAINER_ID} className="absolute inset-0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
‹ {t.tools.documentView.previousPage}
|
||||
</button>
|
||||
<span className="font-medium text-foreground">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
{t.tools.documentView.nextPage} ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { format } from "@/lib/format";
|
||||
import {
|
||||
addSignature,
|
||||
deleteSignature,
|
||||
listSignatures,
|
||||
type SavedSignature,
|
||||
} from "@/lib/signature-store";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { SignCanvasRef } from "./sign-canvas";
|
||||
import { SignaturePad } from "./signature-pad";
|
||||
|
||||
const SSE_STALL_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
interface ProgressHandlers {
|
||||
onProgress?: (percent: number) => void;
|
||||
onComplete: (result: Record<string, unknown>) => void;
|
||||
onFailed: (error: string) => void;
|
||||
onStall: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to async (202) job progress with the same mobile-resilient recovery
|
||||
* as the standard tool processor (PRs #203/#204). Reconnects on tab refocus (the
|
||||
* progress endpoint replays the terminal frame from its Redis cache, so a job
|
||||
* that finished while SSE was dead still resolves) and arms a stall timeout that
|
||||
* fails gracefully instead of hanging at the last percent. Returns a cleanup the
|
||||
* caller must invoke on sync completion, error, or unmount.
|
||||
*/
|
||||
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
|
||||
let es: EventSource | null = null;
|
||||
let stall: ReturnType<typeof setTimeout> | null = null;
|
||||
let done = false;
|
||||
|
||||
const onVisible = () => {
|
||||
if (done || document.visibilityState !== "visible") return;
|
||||
if (es && es.readyState === EventSource.OPEN) return;
|
||||
setTimeout(open, 500);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (stall) clearTimeout(stall);
|
||||
stall = null;
|
||||
if (es) es.close();
|
||||
es = null;
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
|
||||
const resetStall = () => {
|
||||
if (stall) clearTimeout(stall);
|
||||
stall = setTimeout(() => {
|
||||
cleanup();
|
||||
handlers.onStall();
|
||||
}, SSE_STALL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
function open() {
|
||||
if (done) return;
|
||||
if (es && es.readyState === EventSource.OPEN) return;
|
||||
if (es) es.close();
|
||||
try {
|
||||
es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== "single") return;
|
||||
resetStall();
|
||||
if (data.phase === "complete" && data.result) {
|
||||
cleanup();
|
||||
handlers.onComplete(data.result as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
if (data.phase === "failed") {
|
||||
cleanup();
|
||||
handlers.onFailed(typeof data.error === "string" ? data.error : "Processing failed");
|
||||
return;
|
||||
}
|
||||
if (typeof data.percent === "number") handlers.onProgress?.(data.percent);
|
||||
} catch {
|
||||
// Ignore malformed SSE frames
|
||||
}
|
||||
};
|
||||
// A transient drop triggers the browser's built-in reconnect; on reconnect
|
||||
// the backend replays the terminal frame, so a completed job still resolves.
|
||||
es.onerror = () => {};
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
open();
|
||||
resetStall();
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
export interface SignProps {
|
||||
canvasRef: React.RefObject<SignCanvasRef | null>;
|
||||
hasSelection: boolean;
|
||||
placementCount: number;
|
||||
}
|
||||
|
||||
export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
|
||||
const { t } = useTranslation();
|
||||
const sp = t.toolSettings["sign-pdf"];
|
||||
const { currentEntry } = useFileStore();
|
||||
const [sigs, setSigs] = useState<SavedSignature[]>(() => listSignatures());
|
||||
const [padOpen, setPadOpen] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const progressCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Tear down any live SSE subscription if the panel unmounts mid-job.
|
||||
useEffect(() => () => progressCleanupRef.current?.(), []);
|
||||
|
||||
const refresh = () => setSigs(listSignatures());
|
||||
|
||||
const handleSavePad = (dataUrl: string, remember: boolean) => {
|
||||
const sig: SavedSignature = remember
|
||||
? addSignature(dataUrl)
|
||||
: { id: crypto.randomUUID(), dataUrl, createdAt: Date.now() };
|
||||
if (remember) refresh();
|
||||
signProps?.canvasRef.current?.addSignature(sig);
|
||||
setPadOpen(false);
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
const canvas = signProps?.canvasRef.current;
|
||||
const file = currentEntry?.file;
|
||||
if (!canvas || !file) return;
|
||||
if (!canvas.hasPlacements()) {
|
||||
setError(sp.addFirst);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
setProgress(0);
|
||||
setProcessing(true);
|
||||
|
||||
const { pngs, placements } = await canvas.exportPlacements();
|
||||
const clientJobId = generateId();
|
||||
|
||||
const finish = () => {
|
||||
progressCleanupRef.current = null;
|
||||
setProcessing(false);
|
||||
};
|
||||
|
||||
const stopProgress = subscribeJobProgress(clientJobId, {
|
||||
onProgress: (percent) => setProgress(percent),
|
||||
onComplete: (r) => {
|
||||
setDownloadUrl(r.downloadUrl as string);
|
||||
finish();
|
||||
},
|
||||
onFailed: (err) => {
|
||||
setError(err);
|
||||
finish();
|
||||
},
|
||||
onStall: () => {
|
||||
setError(
|
||||
"Processing timed out. The result may have saved to your files; otherwise, try again.",
|
||||
);
|
||||
finish();
|
||||
},
|
||||
});
|
||||
progressCleanupRef.current = stopProgress;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("placements", JSON.stringify(placements));
|
||||
form.append("clientJobId", clientJobId);
|
||||
// Forward the library file id (when the PDF came from the library) so the
|
||||
// worker auto-saves the signed result as a new version.
|
||||
if (currentEntry?.serverFileId) form.append("fileId", currentEntry.serverFileId);
|
||||
pngs.forEach((png, i) => {
|
||||
form.append(`sig${i}`, new File([png], `sig${i}.png`, { type: "image/png" }));
|
||||
});
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 600_000;
|
||||
xhr.onload = () => {
|
||||
// 202 = async: subscribeJobProgress drives completion via SSE.
|
||||
if (xhr.status === 202) return;
|
||||
stopProgress();
|
||||
progressCleanupRef.current = null;
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
setDownloadUrl(JSON.parse(xhr.responseText).downloadUrl);
|
||||
} catch {
|
||||
setError("Invalid response");
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const b = JSON.parse(xhr.responseText);
|
||||
setError(
|
||||
typeof b.error === "string"
|
||||
? b.error
|
||||
: typeof b.details === "string"
|
||||
? b.details
|
||||
: `Failed: ${xhr.status}`,
|
||||
);
|
||||
} catch {
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
}
|
||||
setProcessing(false);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
stopProgress();
|
||||
progressCleanupRef.current = null;
|
||||
setError("Network error");
|
||||
setProcessing(false);
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
stopProgress();
|
||||
progressCleanupRef.current = null;
|
||||
setError("Request timed out. Try again.");
|
||||
setProcessing(false);
|
||||
};
|
||||
xhr.open("POST", "/api/v1/tools/pdf/sign-pdf");
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{sp.yourSignatures}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{sigs.map((s) => (
|
||||
<div key={s.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signProps?.canvasRef.current?.addSignature(s)}
|
||||
className="h-9 min-w-[60px] rounded border border-border bg-background p-1"
|
||||
>
|
||||
<img
|
||||
src={s.dataUrl}
|
||||
alt="saved signature"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="delete signature"
|
||||
onClick={() => {
|
||||
deleteSignature(s.id);
|
||||
refresh();
|
||||
}}
|
||||
className="absolute -end-1 -top-1 hidden h-4 w-4 rounded-full bg-destructive text-[10px] text-white group-hover:block"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPadOpen(true)}
|
||||
className="h-9 min-w-[60px] rounded border border-dashed border-border text-xs text-muted-foreground"
|
||||
>
|
||||
+ {sp.newSignature}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{sp.clickToPlace}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{sp.selectedSignature}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!signProps?.hasSelection}
|
||||
onClick={() => signProps?.canvasRef.current?.deleteSelected()}
|
||||
className="mt-2 rounded border border-border px-2 py-1 text-xs text-destructive disabled:opacity-40"
|
||||
>
|
||||
✕ {t.common.delete}
|
||||
</button>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{sp.dragToAdjust}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">{sp.disclaimer}</p>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{downloadUrl ? (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="block w-full rounded-lg bg-primary py-2.5 text-center font-semibold text-primary-foreground"
|
||||
>
|
||||
{sp.downloadSigned}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={processing || (signProps?.placementCount ?? 0) === 0}
|
||||
onClick={handleApply}
|
||||
className="w-full rounded-lg bg-primary py-2.5 font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{processing
|
||||
? progress > 0
|
||||
? format(sp.signingPercent, { percent: Math.round(progress) })
|
||||
: sp.signing
|
||||
: t.toolPage.applyAndDownload}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{padOpen && <SignaturePad onSave={handleSavePad} onCancel={() => setPadOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
|
||||
const INK_COLORS = ["#13315c", "#1a1814", "#1f6feb"];
|
||||
const PEN_WIDTHS = { S: 2, M: 3.5, L: 6 } as const;
|
||||
const FONTS = [
|
||||
{ label: "Signature", css: "'Brush Script MT', 'Segoe Script', cursive" },
|
||||
{ label: "Cursive", css: "'Snell Roundhand', 'Apple Chancery', cursive" },
|
||||
{ label: "Italic", css: "Georgia, serif" },
|
||||
];
|
||||
|
||||
type Tab = "draw" | "type" | "upload";
|
||||
|
||||
export interface SignaturePadProps {
|
||||
onSave: (dataUrl: string, remember: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Trim transparent margins; returns a tightly-cropped PNG data URL or null. */
|
||||
function cropToInk(source: HTMLCanvasElement): string | null {
|
||||
const ctx = source.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
const { width, height } = source;
|
||||
const { data } = ctx.getImageData(0, 0, width, height);
|
||||
let minX = width;
|
||||
let minY = height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let found = false;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (data[(y * width + x) * 4 + 3] > 8) {
|
||||
found = true;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return null;
|
||||
const pad = 8;
|
||||
const w = Math.min(width, maxX - minX + pad * 2);
|
||||
const h = Math.min(height, maxY - minY + pad * 2);
|
||||
const out = document.createElement("canvas");
|
||||
out.width = w;
|
||||
out.height = h;
|
||||
out.getContext("2d")?.drawImage(source, minX - pad, minY - pad, w, h, 0, 0, w, h);
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
|
||||
export function SignaturePad({ onSave, onCancel }: SignaturePadProps) {
|
||||
const { t } = useTranslation();
|
||||
const pad = t.toolSettings["sign-pdf"].pad;
|
||||
const tabLabels: Record<Tab, string> = { draw: pad.draw, type: pad.type, upload: pad.upload };
|
||||
const [tab, setTab] = useState<Tab>("draw");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [color, setColor] = useState(INK_COLORS[0]);
|
||||
const [width, setWidth] = useState<keyof typeof PEN_WIDTHS>("M");
|
||||
const [hasInk, setHasInk] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [font, setFont] = useState(FONTS[0]);
|
||||
const [uploaded, setUploaded] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || tab !== "draw") return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const point = (e: PointerEvent) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - r.left) * (canvas.width / r.width),
|
||||
y: (e.clientY - r.top) * (canvas.height / r.height),
|
||||
};
|
||||
};
|
||||
const down = (e: PointerEvent) => {
|
||||
drawing.current = true;
|
||||
const p = point(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const move = (e: PointerEvent) => {
|
||||
if (!drawing.current) return;
|
||||
const p = point(e);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = PEN_WIDTHS[width];
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
setHasInk(true);
|
||||
};
|
||||
const up = () => {
|
||||
drawing.current = false;
|
||||
};
|
||||
canvas.addEventListener("pointerdown", down);
|
||||
canvas.addEventListener("pointermove", move);
|
||||
canvas.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
canvas.removeEventListener("pointerdown", down);
|
||||
canvas.removeEventListener("pointermove", move);
|
||||
canvas.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, [tab, color, width]);
|
||||
|
||||
const clearDraw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setHasInk(false);
|
||||
};
|
||||
|
||||
const renderTyped = (): string | null => {
|
||||
if (!typed.trim()) return null;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 600;
|
||||
c.height = 200;
|
||||
const ctx = c.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `64px ${font.css}`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(typed, 20, 100);
|
||||
return cropToInk(c);
|
||||
};
|
||||
|
||||
const onUpload = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setUploaded(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const canSave =
|
||||
(tab === "draw" && hasInk) ||
|
||||
(tab === "type" && typed.trim() !== "") ||
|
||||
(tab === "upload" && uploaded !== null);
|
||||
|
||||
const handleSave = () => {
|
||||
let dataUrl: string | null = null;
|
||||
if (tab === "draw" && canvasRef.current) dataUrl = cropToInk(canvasRef.current);
|
||||
else if (tab === "type") dataUrl = renderTyped();
|
||||
else if (tab === "upload") dataUrl = uploaded;
|
||||
if (dataUrl) onSave(dataUrl, remember);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={pad.title}
|
||||
>
|
||||
<div className="w-[460px] max-w-[92vw] rounded-xl border border-border bg-background shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border p-3">
|
||||
<h2 className="text-sm font-semibold">{pad.title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={t.common.close}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1 px-3 pt-3">
|
||||
{(["draw", "type", "upload"] as Tab[]).map((tb) => (
|
||||
<button
|
||||
key={tb}
|
||||
type="button"
|
||||
onClick={() => setTab(tb)}
|
||||
className={`rounded-t-lg border border-b-0 px-3 py-1.5 text-sm capitalize ${tab === tb ? "border-primary bg-background font-medium" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{tabLabels[tb]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border p-4">
|
||||
{tab === "draw" && (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={600}
|
||||
height={220}
|
||||
className="h-[180px] w-full rounded-lg border border-dashed border-border"
|
||||
style={{ touchAction: "none" }}
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{pad.color}</span>
|
||||
{INK_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={`ink ${c}`}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-primary ring-offset-1" : ""}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
<span className="ms-2 text-xs text-muted-foreground">{pad.pen}</span>
|
||||
{(Object.keys(PEN_WIDTHS) as Array<keyof typeof PEN_WIDTHS>).map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
className={`h-6 w-6 rounded border text-xs ${width === w ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDraw}
|
||||
className="ms-auto rounded border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
{t.common.clear}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{tab === "type" && (
|
||||
<>
|
||||
<input
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
placeholder={pad.namePlaceholder}
|
||||
className="w-full rounded-lg border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{FONTS.map((f) => (
|
||||
<button
|
||||
key={f.label}
|
||||
type="button"
|
||||
onClick={() => setFont(f)}
|
||||
className={`rounded-lg border px-3 py-2 text-start ${font.label === f.label ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
style={{ fontFamily: f.css, color }}
|
||||
>
|
||||
{typed || pad.yourName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{tab === "upload" && (
|
||||
<>
|
||||
<label className="flex cursor-pointer flex-col items-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
|
||||
{pad.uploadHint}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
|
||||
/>
|
||||
</label>
|
||||
{uploaded && (
|
||||
<img
|
||||
src={uploaded}
|
||||
alt="signature preview"
|
||||
className="mt-3 h-12 rounded border border-border object-contain"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-t border-border bg-muted/40 p-3">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>{" "}
|
||||
{pad.remember}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ms-auto rounded-lg border border-border px-3 py-1.5 text-sm"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSave}
|
||||
onClick={handleSave}
|
||||
className="rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{pad.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
Scissors,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Signature,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Split,
|
||||
@@ -203,6 +204,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
ScanText,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Signature,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Split,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface RectPx {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
export interface NormRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Convert a pixel rect (top-left origin) to page fractions (0..1). */
|
||||
export function toNormalizedRect(rect: RectPx, renderW: number, renderH: number): NormRect {
|
||||
return {
|
||||
x: rect.x / renderW,
|
||||
y: rect.y / renderH,
|
||||
w: rect.w / renderW,
|
||||
h: rect.h / renderH,
|
||||
};
|
||||
}
|
||||
|
||||
/** Axis-aligned bounding box of a w×h rect rotated by `deg` degrees. */
|
||||
export function rotatedBoundingBox(w: number, h: number, deg: number): { w: number; h: number } {
|
||||
const r = (deg * Math.PI) / 180;
|
||||
const c = Math.abs(Math.cos(r));
|
||||
const s = Math.abs(Math.sin(r));
|
||||
return { w: w * c + h * s, h: w * s + h * c };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface SavedSignature {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const MAX_SIGNATURES = 10;
|
||||
const KEY = "snapotter.signatures.v1";
|
||||
|
||||
function read(): SavedSignature[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as SavedSignature[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(list: SavedSignature[]): boolean {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(list));
|
||||
return true;
|
||||
} catch {
|
||||
return false; // QuotaExceededError -> caller falls back to session-only
|
||||
}
|
||||
}
|
||||
|
||||
export function listSignatures(): SavedSignature[] {
|
||||
return read().sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function addSignature(dataUrl: string): SavedSignature {
|
||||
const sig: SavedSignature = {
|
||||
id: crypto.randomUUID(),
|
||||
dataUrl,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const list = [...read(), sig].slice(-MAX_SIGNATURES); // keep newest MAX
|
||||
write(list);
|
||||
return sig;
|
||||
}
|
||||
|
||||
export function deleteSignature(id: string): void {
|
||||
write(read().filter((s) => s.id !== id));
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type DisplayMode =
|
||||
| "no-comparison"
|
||||
| "interactive-crop"
|
||||
| "interactive-eraser"
|
||||
| "interactive-sign"
|
||||
| "interactive-split"
|
||||
| "no-dropzone"
|
||||
| "custom-results"
|
||||
@@ -183,6 +184,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"pdfa-convert": "no-comparison",
|
||||
"flatten-pdf": "document",
|
||||
"redact-pdf": "document",
|
||||
"sign-pdf": "interactive-sign",
|
||||
"pdf-to-text": "no-comparison",
|
||||
"pdf-to-word": "no-comparison",
|
||||
"pdf-metadata": "no-comparison",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { Crop } from "react-image-crop";
|
||||
import type { BgPreviewState } from "@/components/common/image-viewer";
|
||||
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
|
||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||
import type { SignProps } from "@/components/tools/sign-pdf-settings";
|
||||
import { TOOL_DISPLAY_MODES } from "./tool-display-modes";
|
||||
|
||||
// ── Display modes ──────────────────────────────────────────────────
|
||||
@@ -70,6 +71,7 @@ export interface ToolRegistryEntry {
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
cropProps?: CropProps;
|
||||
eraserProps?: EraserProps;
|
||||
signProps?: SignProps;
|
||||
}>;
|
||||
/** Optional panel for tools that render custom content in the main area. */
|
||||
ResultsPanel?: React.ComponentType;
|
||||
@@ -721,6 +723,9 @@ const RedactPdfSettings = lazy(() =>
|
||||
default: m.RedactPdfSettings,
|
||||
})),
|
||||
);
|
||||
const SignPdfSettings = lazy(() =>
|
||||
import("@/components/tools/sign-pdf-settings").then((m) => ({ default: m.SignPdfSettings })),
|
||||
);
|
||||
const PdfToTextSettings = lazy(() =>
|
||||
import("@/components/tools/pdf-to-text-settings").then((m) => ({
|
||||
default: m.PdfToTextSettings,
|
||||
@@ -1113,6 +1118,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["pdfa-convert", { accept: ".pdf", Settings: PdfaConvertSettings }],
|
||||
["flatten-pdf", { accept: ".pdf", Settings: FlattenPdfSettings }],
|
||||
["redact-pdf", { accept: ".pdf", Settings: RedactPdfSettings }],
|
||||
["sign-pdf", { accept: ".pdf", Settings: SignPdfSettings }],
|
||||
["pdf-to-text", { accept: ".pdf", Settings: PdfToTextSettings }],
|
||||
["pdf-to-word", { accept: ".pdf", Settings: PdfToWordSettings }],
|
||||
["pdf-metadata", { accept: ".pdf", Settings: PdfMetadataSettings }],
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CropCanvas } from "@/components/tools/crop-canvas";
|
||||
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
|
||||
import { EraserCanvas } from "@/components/tools/eraser-canvas";
|
||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||
import { SignCanvas, type SignCanvasRef } from "@/components/tools/sign-canvas";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
@@ -359,6 +360,11 @@ export function ToolPage() {
|
||||
// Center of the painted mask as a 0-100 percentage — used to init the slider at the right spot
|
||||
const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null);
|
||||
|
||||
// Sign state
|
||||
const signCanvasRef = useRef<SignCanvasRef | null>(null);
|
||||
const [signHasSelection, setSignHasSelection] = useState(false);
|
||||
const [signPlacementCount, setSignPlacementCount] = useState(0);
|
||||
|
||||
// Page-level drag overlay state
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
@@ -651,6 +657,14 @@ export function ToolPage() {
|
||||
maskedFileCount: eraserMaskedCount,
|
||||
}
|
||||
: undefined,
|
||||
signProps:
|
||||
displayMode === "interactive-sign"
|
||||
? {
|
||||
canvasRef: signCanvasRef,
|
||||
hasSelection: signHasSelection,
|
||||
placementCount: signPlacementCount,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const ToolSettings = registryEntry.Settings;
|
||||
@@ -843,6 +857,17 @@ export function ToolPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "interactive-sign" && hasFile && originalBlobUrl) {
|
||||
return (
|
||||
<SignCanvas
|
||||
ref={signCanvasRef}
|
||||
fileUrl={originalBlobUrl}
|
||||
onSelectionChange={setSignHasSelection}
|
||||
onCountChange={setSignPlacementCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "interactive-split" && hasFile && originalBlobUrl) {
|
||||
if (registryEntry?.ResultsPanel) {
|
||||
const Panel = registryEntry.ResultsPanel;
|
||||
|
||||
@@ -97,6 +97,7 @@ DOCS_SCRIPTS = {
|
||||
"doc_to_word",
|
||||
"doc_metadata",
|
||||
"doc_html_pdf",
|
||||
"doc_sign",
|
||||
}
|
||||
|
||||
if DISPATCHER_PROFILE == "docs":
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Stamp signature images onto a PDF and save a flattened copy.
|
||||
Args: {"input": in, "output": out, "signatures": [paths], "placements": [
|
||||
{"sig": idx, "page": 0-based, "x": 0..1, "y": 0..1, "w": 0..1, "h": 0..1}
|
||||
]}. Coordinates are page fractions, top-left origin. Each signature PNG is
|
||||
already rotated; insert_image is always axis-aligned. Prints {"ok": true,
|
||||
"placed": N}. insert_image draws into page content, so output is flattened."""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
||||
path = args.get("input")
|
||||
out = args.get("output")
|
||||
signatures = args.get("signatures") or []
|
||||
placements = args.get("placements") or []
|
||||
if not path or not out or not isinstance(placements, list) or not placements:
|
||||
print(json.dumps({"error": "missing input/output/placements"}))
|
||||
sys.exit(1)
|
||||
try:
|
||||
import fitz
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "PyMuPDF not installed"}))
|
||||
sys.exit(1)
|
||||
try:
|
||||
doc = fitz.open(path)
|
||||
page_count = doc.page_count
|
||||
for p in placements:
|
||||
page_no = p.get("page")
|
||||
sig_idx = p.get("sig")
|
||||
if not isinstance(page_no, int) or page_no < 0 or page_no >= page_count:
|
||||
print(json.dumps({"error": f"page index {page_no} out of range (0..{page_count - 1})"}))
|
||||
sys.exit(1)
|
||||
if not isinstance(sig_idx, int) or sig_idx < 0 or sig_idx >= len(signatures):
|
||||
print(json.dumps({"error": f"signature index {sig_idx} out of range"}))
|
||||
sys.exit(1)
|
||||
page = doc[page_no]
|
||||
r = page.rect # rotation-aware; x0/y0 carry the CropBox origin
|
||||
x0, y0, w, h = r.x0, r.y0, r.width, r.height
|
||||
fx, fy, fw, fh = (float(p["x"]), float(p["y"]), float(p["w"]), float(p["h"]))
|
||||
rect = fitz.Rect(
|
||||
x0 + fx * w, y0 + fy * h, x0 + (fx + fw) * w, y0 + (fy + fh) * h
|
||||
)
|
||||
with open(signatures[sig_idx], "rb") as f:
|
||||
page.insert_image(rect, stream=f.read(), keep_proportion=False)
|
||||
doc.save(out, garbage=4, deflate=True)
|
||||
doc.close()
|
||||
print(json.dumps({"ok": True, "placed": len(placements)}))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"error": str(exc)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
pdfMetadataSetPy,
|
||||
pdfPageCountPy,
|
||||
pdfRedactPy,
|
||||
pdfSignPy,
|
||||
pdfTextPy,
|
||||
pdfToWordPy,
|
||||
} from "./python-docs.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { runDocsScript } from "@snapotter/ai";
|
||||
import type { SignPlacement } from "@snapotter/shared";
|
||||
|
||||
/** Page count via the docs-profile Python dispatcher (pikepdf). */
|
||||
export async function pdfPageCountPy(absPath: string): Promise<number> {
|
||||
@@ -119,3 +120,30 @@ export async function htmlToPdfPy(
|
||||
throw new Error(`doc_html_pdf failed: ${parsed.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stamp signature images onto a PDF (PyMuPDF insert_image), flattened. */
|
||||
export async function pdfSignPy(
|
||||
inPath: string,
|
||||
outPath: string,
|
||||
signatures: string[],
|
||||
placements: SignPlacement[],
|
||||
): Promise<{ placed: number }> {
|
||||
const stdout = await runDocsScript("doc_sign", {
|
||||
input: inPath,
|
||||
output: outPath,
|
||||
signatures,
|
||||
placements,
|
||||
});
|
||||
const parsed = JSON.parse(stdout.trim()) as {
|
||||
ok?: boolean;
|
||||
placed?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (parsed.error) {
|
||||
throw new Error(`doc_sign failed: ${parsed.error}`);
|
||||
}
|
||||
if (typeof parsed.placed !== "number") {
|
||||
throw new Error(`doc_sign failed: ${stdout.slice(0, 200)}`);
|
||||
}
|
||||
return { placed: parsed.placed };
|
||||
}
|
||||
|
||||
@@ -1580,6 +1580,18 @@ const BASE_TOOLS: Tool[] = [
|
||||
acceptedInputs: [".pdf"],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "sign-pdf",
|
||||
name: "Sign PDF",
|
||||
description: "Draw, type, or upload a signature and place it anywhere on a PDF",
|
||||
category: "pdf-edit",
|
||||
icon: "Signature",
|
||||
route: "/sign-pdf",
|
||||
modality: "document",
|
||||
acceptedInputs: [".pdf"],
|
||||
executionHint: "fast",
|
||||
keywords: ["sign", "signature", "e-sign", "esign", "fill and sign", "autograph"],
|
||||
},
|
||||
{
|
||||
id: "pdf-to-text",
|
||||
name: "PDF to Text",
|
||||
|
||||
@@ -2623,6 +2623,31 @@ export const ar: TranslationKeys = {
|
||||
submitBatch: "تسطيح ({count} ملفات)",
|
||||
progressLabel: "جارٍ التسطيح",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "توقيعاتك",
|
||||
newSignature: "جديد",
|
||||
clickToPlace: "انقر على توقيع لإسقاطه في الصفحة الحالية.",
|
||||
selectedSignature: "التوقيع المحدد",
|
||||
dragToAdjust: "اسحب المقابض لتغيير الحجم أو التدوير.",
|
||||
disclaimer: "يضيف صورة توقيع مرئية. هذا ليس توقيعًا رقميًا قائمًا على شهادة.",
|
||||
signing: "جارٍ التوقيع…",
|
||||
signingPercent: "جارٍ التوقيع… {percent}%",
|
||||
downloadSigned: "تنزيل ملف PDF الموقَّع",
|
||||
addFirst: "أضف توقيعًا واحدًا على الأقل قبل التنزيل.",
|
||||
pad: {
|
||||
title: "إنشاء توقيع",
|
||||
draw: "رسم",
|
||||
type: "كتابة",
|
||||
upload: "رفع",
|
||||
color: "اللون",
|
||||
pen: "القلم",
|
||||
namePlaceholder: "اكتب اسمك",
|
||||
yourName: "اسمك",
|
||||
remember: "التذكّر على هذا الجهاز",
|
||||
save: "حفظ ووضع",
|
||||
uploadHint: "أفلِت ملف PNG / JPG أو انقر للاختيار. تعمل صور PNG الشفافة بشكل أفضل.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "العبارات المراد تنقيحها",
|
||||
termsHint: "عبارة واحدة في كل سطر (50 كحد أقصى)",
|
||||
|
||||
@@ -2640,6 +2640,34 @@ export const de: TranslationKeys = {
|
||||
submitBatch: "Reduzieren ({count} Dateien)",
|
||||
progressLabel: "Wird reduziert",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Ihre Unterschriften",
|
||||
newSignature: "Neu",
|
||||
clickToPlace:
|
||||
"Klicken Sie auf eine Unterschrift, um sie auf der aktuellen Seite zu platzieren.",
|
||||
selectedSignature: "Ausgewählte Unterschrift",
|
||||
dragToAdjust: "Ziehen Sie an den Griffen, um die Größe zu ändern oder zu drehen.",
|
||||
disclaimer:
|
||||
"Fügt ein sichtbares Unterschriftsbild hinzu. Dies ist keine zertifikatbasierte digitale Signatur.",
|
||||
signing: "Wird unterschrieben…",
|
||||
signingPercent: "Wird unterschrieben… {percent}%",
|
||||
downloadSigned: "Unterschriebenes PDF herunterladen",
|
||||
addFirst: "Fügen Sie vor dem Herunterladen mindestens eine Unterschrift hinzu.",
|
||||
pad: {
|
||||
title: "Unterschrift erstellen",
|
||||
draw: "Zeichnen",
|
||||
type: "Tippen",
|
||||
upload: "Hochladen",
|
||||
color: "Farbe",
|
||||
pen: "Stift",
|
||||
namePlaceholder: "Geben Sie Ihren Namen ein",
|
||||
yourName: "Ihr Name",
|
||||
remember: "Auf diesem Gerät merken",
|
||||
save: "Speichern und platzieren",
|
||||
uploadHint:
|
||||
"Legen Sie ein PNG / JPG ab oder klicken Sie zum Auswählen. Ein transparentes PNG funktioniert am besten.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Zu schwärzende Begriffe",
|
||||
termsHint: "Ein Begriff pro Zeile (max. 50)",
|
||||
|
||||
@@ -2587,6 +2587,32 @@ export const en = {
|
||||
submitBatch: "Flatten ({count} files)",
|
||||
progressLabel: "Flattening",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Your signatures",
|
||||
newSignature: "New",
|
||||
clickToPlace: "Click a signature to drop it on the current page.",
|
||||
selectedSignature: "Selected signature",
|
||||
dragToAdjust: "Drag the handles to resize or rotate.",
|
||||
disclaimer:
|
||||
"Adds a visual signature image. This is not a certificate-based digital signature.",
|
||||
signing: "Signing…",
|
||||
signingPercent: "Signing… {percent}%",
|
||||
downloadSigned: "Download signed PDF",
|
||||
addFirst: "Add at least one signature before downloading.",
|
||||
pad: {
|
||||
title: "Create signature",
|
||||
draw: "Draw",
|
||||
type: "Type",
|
||||
upload: "Upload",
|
||||
color: "Color",
|
||||
pen: "Pen",
|
||||
namePlaceholder: "Type your name",
|
||||
yourName: "Your name",
|
||||
remember: "Remember on this device",
|
||||
save: "Save & place",
|
||||
uploadHint: "Drop a PNG / JPG, or click to choose. Transparent PNG works best.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Terms to redact",
|
||||
termsHint: "One term per line (max 50)",
|
||||
|
||||
@@ -2623,6 +2623,33 @@ export const es: TranslationKeys = {
|
||||
submitBatch: "Aplanar ({count} archivos)",
|
||||
progressLabel: "Aplanando",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Tus firmas",
|
||||
newSignature: "Nueva",
|
||||
clickToPlace: "Haz clic en una firma para colocarla en la página actual.",
|
||||
selectedSignature: "Firma seleccionada",
|
||||
dragToAdjust: "Arrastra los controladores para cambiar el tamaño o rotar.",
|
||||
disclaimer:
|
||||
"Añade una imagen de firma visible. No es una firma digital basada en certificado.",
|
||||
signing: "Firmando…",
|
||||
signingPercent: "Firmando… {percent}%",
|
||||
downloadSigned: "Descargar PDF firmado",
|
||||
addFirst: "Añade al menos una firma antes de descargar.",
|
||||
pad: {
|
||||
title: "Crear firma",
|
||||
draw: "Dibujar",
|
||||
type: "Escribir",
|
||||
upload: "Subir",
|
||||
color: "Color",
|
||||
pen: "Lápiz",
|
||||
namePlaceholder: "Escribe tu nombre",
|
||||
yourName: "Tu nombre",
|
||||
remember: "Recordar en este dispositivo",
|
||||
save: "Guardar y colocar",
|
||||
uploadHint:
|
||||
"Suelta un PNG / JPG o haz clic para elegir. Un PNG transparente funciona mejor.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Términos a censurar",
|
||||
termsHint: "Un término por línea (máx. 50)",
|
||||
|
||||
@@ -2646,6 +2646,33 @@ export const fr: TranslationKeys = {
|
||||
submitBatch: "Aplatir ({count} fichiers)",
|
||||
progressLabel: "Aplatissement",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Vos signatures",
|
||||
newSignature: "Nouvelle",
|
||||
clickToPlace: "Cliquez sur une signature pour la placer sur la page actuelle.",
|
||||
selectedSignature: "Signature sélectionnée",
|
||||
dragToAdjust: "Faites glisser les poignées pour redimensionner ou pivoter.",
|
||||
disclaimer:
|
||||
"Ajoute une image de signature visible. Ce n'est pas une signature numérique basée sur un certificat.",
|
||||
signing: "Signature en cours…",
|
||||
signingPercent: "Signature en cours… {percent}%",
|
||||
downloadSigned: "Télécharger le PDF signé",
|
||||
addFirst: "Ajoutez au moins une signature avant de télécharger.",
|
||||
pad: {
|
||||
title: "Créer une signature",
|
||||
draw: "Dessiner",
|
||||
type: "Saisir",
|
||||
upload: "Importer",
|
||||
color: "Couleur",
|
||||
pen: "Stylo",
|
||||
namePlaceholder: "Saisissez votre nom",
|
||||
yourName: "Votre nom",
|
||||
remember: "Mémoriser sur cet appareil",
|
||||
save: "Enregistrer et placer",
|
||||
uploadHint:
|
||||
"Déposez un PNG / JPG ou cliquez pour choisir. Un PNG transparent fonctionne le mieux.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Termes à masquer",
|
||||
termsHint: "Un terme par ligne (max 50)",
|
||||
|
||||
@@ -2454,6 +2454,31 @@ export const hi: TranslationKeys = {
|
||||
submitBatch: "फ़्लैटन करें ({count} फ़ाइलें)",
|
||||
progressLabel: "फ़्लैटन हो रहा है",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "आपके हस्ताक्षर",
|
||||
newSignature: "नया",
|
||||
clickToPlace: "इसे मौजूदा पृष्ठ पर रखने के लिए किसी हस्ताक्षर पर क्लिक करें।",
|
||||
selectedSignature: "चयनित हस्ताक्षर",
|
||||
dragToAdjust: "आकार बदलने या घुमाने के लिए हैंडल खींचें।",
|
||||
disclaimer: "एक दृश्य हस्ताक्षर छवि जोड़ता है। यह प्रमाणपत्र-आधारित डिजिटल हस्ताक्षर नहीं है।",
|
||||
signing: "हस्ताक्षर हो रहे हैं…",
|
||||
signingPercent: "हस्ताक्षर हो रहे हैं… {percent}%",
|
||||
downloadSigned: "हस्ताक्षरित PDF डाउनलोड करें",
|
||||
addFirst: "डाउनलोड करने से पहले कम से कम एक हस्ताक्षर जोड़ें।",
|
||||
pad: {
|
||||
title: "हस्ताक्षर बनाएं",
|
||||
draw: "बनाएं",
|
||||
type: "टाइप करें",
|
||||
upload: "अपलोड करें",
|
||||
color: "रंग",
|
||||
pen: "पेन",
|
||||
namePlaceholder: "अपना नाम लिखें",
|
||||
yourName: "आपका नाम",
|
||||
remember: "इस डिवाइस पर याद रखें",
|
||||
save: "सहेजें और रखें",
|
||||
uploadHint: "PNG / JPG छोड़ें, या चुनने के लिए क्लिक करें। पारदर्शी PNG सबसे अच्छा काम करता है।",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "छिपाने के लिए शब्द",
|
||||
termsHint: "प्रति पंक्ति एक शब्द (अधिकतम 50)",
|
||||
|
||||
@@ -2633,6 +2633,33 @@ export const id: TranslationKeys = {
|
||||
submitBatch: "Ratakan ({count} file)",
|
||||
progressLabel: "Meratakan",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Tanda tangan Anda",
|
||||
newSignature: "Baru",
|
||||
clickToPlace: "Klik tanda tangan untuk meletakkannya di halaman saat ini.",
|
||||
selectedSignature: "Tanda tangan terpilih",
|
||||
dragToAdjust: "Seret pegangan untuk mengubah ukuran atau memutar.",
|
||||
disclaimer:
|
||||
"Menambahkan gambar tanda tangan visual. Ini bukan tanda tangan digital berbasis sertifikat.",
|
||||
signing: "Menandatangani…",
|
||||
signingPercent: "Menandatangani… {percent}%",
|
||||
downloadSigned: "Unduh PDF yang ditandatangani",
|
||||
addFirst: "Tambahkan setidaknya satu tanda tangan sebelum mengunduh.",
|
||||
pad: {
|
||||
title: "Buat tanda tangan",
|
||||
draw: "Gambar",
|
||||
type: "Ketik",
|
||||
upload: "Unggah",
|
||||
color: "Warna",
|
||||
pen: "Pena",
|
||||
namePlaceholder: "Ketik nama Anda",
|
||||
yourName: "Nama Anda",
|
||||
remember: "Ingat di perangkat ini",
|
||||
save: "Simpan dan tempatkan",
|
||||
uploadHint:
|
||||
"Letakkan PNG / JPG, atau klik untuk memilih. PNG transparan bekerja paling baik.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Istilah untuk disensor",
|
||||
termsHint: "Satu istilah per baris (maks 50)",
|
||||
|
||||
@@ -2636,6 +2636,33 @@ export const it: TranslationKeys = {
|
||||
submitBatch: "Appiattisci ({count} file)",
|
||||
progressLabel: "Appiattimento",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Le tue firme",
|
||||
newSignature: "Nuova",
|
||||
clickToPlace: "Fai clic su una firma per posizionarla nella pagina corrente.",
|
||||
selectedSignature: "Firma selezionata",
|
||||
dragToAdjust: "Trascina le maniglie per ridimensionare o ruotare.",
|
||||
disclaimer:
|
||||
"Aggiunge un'immagine di firma visibile. Non è una firma digitale basata su certificato.",
|
||||
signing: "Firma in corso…",
|
||||
signingPercent: "Firma in corso… {percent}%",
|
||||
downloadSigned: "Scarica il PDF firmato",
|
||||
addFirst: "Aggiungi almeno una firma prima di scaricare.",
|
||||
pad: {
|
||||
title: "Crea firma",
|
||||
draw: "Disegna",
|
||||
type: "Scrivi",
|
||||
upload: "Carica",
|
||||
color: "Colore",
|
||||
pen: "Penna",
|
||||
namePlaceholder: "Scrivi il tuo nome",
|
||||
yourName: "Il tuo nome",
|
||||
remember: "Ricorda su questo dispositivo",
|
||||
save: "Salva e posiziona",
|
||||
uploadHint:
|
||||
"Trascina un PNG / JPG o fai clic per scegliere. Un PNG trasparente è l'ideale.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Termini da oscurare",
|
||||
termsHint: "Un termine per riga (max 50)",
|
||||
|
||||
@@ -2591,6 +2591,31 @@ export const ja: TranslationKeys = {
|
||||
submitBatch: "フラット化 ({count}ファイル)",
|
||||
progressLabel: "フラット化中",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "あなたの署名",
|
||||
newSignature: "新規",
|
||||
clickToPlace: "署名をクリックして現在のページに配置します。",
|
||||
selectedSignature: "選択中の署名",
|
||||
dragToAdjust: "ハンドルをドラッグしてサイズ変更や回転ができます。",
|
||||
disclaimer: "視覚的な署名画像を追加します。これは証明書ベースのデジタル署名ではありません。",
|
||||
signing: "署名中…",
|
||||
signingPercent: "署名中… {percent}%",
|
||||
downloadSigned: "署名済み PDF をダウンロード",
|
||||
addFirst: "ダウンロードする前に署名を 1 つ以上追加してください。",
|
||||
pad: {
|
||||
title: "署名を作成",
|
||||
draw: "手書き",
|
||||
type: "入力",
|
||||
upload: "アップロード",
|
||||
color: "色",
|
||||
pen: "ペン",
|
||||
namePlaceholder: "名前を入力",
|
||||
yourName: "あなたの名前",
|
||||
remember: "このデバイスに記憶する",
|
||||
save: "保存して配置",
|
||||
uploadHint: "PNG / JPG をドロップするか、クリックして選択します。透明な PNG が最適です。",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "墨消しする語句",
|
||||
termsHint: "1行に1語句(最大50件)",
|
||||
|
||||
@@ -2575,6 +2575,31 @@ export const ko: TranslationKeys = {
|
||||
submitBatch: "평면화 ({count}개 파일)",
|
||||
progressLabel: "평면화 중",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "내 서명",
|
||||
newSignature: "새로 만들기",
|
||||
clickToPlace: "서명을 클릭하여 현재 페이지에 배치하세요.",
|
||||
selectedSignature: "선택한 서명",
|
||||
dragToAdjust: "핸들을 드래그하여 크기를 조정하거나 회전하세요.",
|
||||
disclaimer: "시각적 서명 이미지를 추가합니다. 인증서 기반 디지털 서명이 아닙니다.",
|
||||
signing: "서명 중…",
|
||||
signingPercent: "서명 중… {percent}%",
|
||||
downloadSigned: "서명된 PDF 다운로드",
|
||||
addFirst: "다운로드하기 전에 서명을 하나 이상 추가하세요.",
|
||||
pad: {
|
||||
title: "서명 만들기",
|
||||
draw: "그리기",
|
||||
type: "입력",
|
||||
upload: "업로드",
|
||||
color: "색상",
|
||||
pen: "펜",
|
||||
namePlaceholder: "이름을 입력하세요",
|
||||
yourName: "이름",
|
||||
remember: "이 기기에 기억하기",
|
||||
save: "저장 후 배치",
|
||||
uploadHint: "PNG / JPG를 끌어다 놓거나 클릭하여 선택하세요. 투명한 PNG가 가장 좋습니다.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "마스킹할 용어",
|
||||
termsHint: "한 줄에 하나씩 (최대 50개)",
|
||||
|
||||
@@ -2638,6 +2638,33 @@ export const nl: TranslationKeys = {
|
||||
submitBatch: "Afvlakken ({count} bestanden)",
|
||||
progressLabel: "Afvlakken",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Je handtekeningen",
|
||||
newSignature: "Nieuw",
|
||||
clickToPlace: "Klik op een handtekening om deze op de huidige pagina te plaatsen.",
|
||||
selectedSignature: "Geselecteerde handtekening",
|
||||
dragToAdjust: "Sleep de handvatten om het formaat te wijzigen of te draaien.",
|
||||
disclaimer:
|
||||
"Voegt een zichtbare handtekeningafbeelding toe. Dit is geen op certificaten gebaseerde digitale handtekening.",
|
||||
signing: "Bezig met ondertekenen…",
|
||||
signingPercent: "Bezig met ondertekenen… {percent}%",
|
||||
downloadSigned: "Ondertekende PDF downloaden",
|
||||
addFirst: "Voeg ten minste één handtekening toe voordat je downloadt.",
|
||||
pad: {
|
||||
title: "Handtekening maken",
|
||||
draw: "Tekenen",
|
||||
type: "Typen",
|
||||
upload: "Uploaden",
|
||||
color: "Kleur",
|
||||
pen: "Pen",
|
||||
namePlaceholder: "Typ je naam",
|
||||
yourName: "Je naam",
|
||||
remember: "Onthouden op dit apparaat",
|
||||
save: "Opslaan en plaatsen",
|
||||
uploadHint:
|
||||
"Zet een PNG / JPG neer of klik om te kiezen. Een transparante PNG werkt het best.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Te censureren termen",
|
||||
termsHint: "Een term per regel (max. 50)",
|
||||
|
||||
@@ -2638,6 +2638,33 @@ export const pl: TranslationKeys = {
|
||||
submitBatch: "Spłaszcz ({count} plików)",
|
||||
progressLabel: "Spłaszczanie",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Twoje podpisy",
|
||||
newSignature: "Nowy",
|
||||
clickToPlace: "Kliknij podpis, aby umieścić go na bieżącej stronie.",
|
||||
selectedSignature: "Wybrany podpis",
|
||||
dragToAdjust: "Przeciągnij uchwyty, aby zmienić rozmiar lub obrócić.",
|
||||
disclaimer:
|
||||
"Dodaje widoczny obraz podpisu. To nie jest podpis cyfrowy oparty na certyfikacie.",
|
||||
signing: "Podpisywanie…",
|
||||
signingPercent: "Podpisywanie… {percent}%",
|
||||
downloadSigned: "Pobierz podpisany PDF",
|
||||
addFirst: "Dodaj co najmniej jeden podpis przed pobraniem.",
|
||||
pad: {
|
||||
title: "Utwórz podpis",
|
||||
draw: "Rysuj",
|
||||
type: "Wpisz",
|
||||
upload: "Prześlij",
|
||||
color: "Kolor",
|
||||
pen: "Pióro",
|
||||
namePlaceholder: "Wpisz swoje imię",
|
||||
yourName: "Twoje imię",
|
||||
remember: "Zapamiętaj na tym urządzeniu",
|
||||
save: "Zapisz i umieść",
|
||||
uploadHint:
|
||||
"Upuść plik PNG / JPG lub kliknij, aby wybrać. Przezroczysty PNG działa najlepiej.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Frazy do zredagowania",
|
||||
termsHint: "Jedna fraza na wiersz (maks. 50)",
|
||||
|
||||
@@ -2635,6 +2635,32 @@ export const ptBR: TranslationKeys = {
|
||||
submitBatch: "Achatar ({count} arquivos)",
|
||||
progressLabel: "Achatando",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Suas assinaturas",
|
||||
newSignature: "Nova",
|
||||
clickToPlace: "Clique em uma assinatura para colocá-la na página atual.",
|
||||
selectedSignature: "Assinatura selecionada",
|
||||
dragToAdjust: "Arraste as alças para redimensionar ou girar.",
|
||||
disclaimer:
|
||||
"Adiciona uma imagem de assinatura visível. Não é uma assinatura digital baseada em certificado.",
|
||||
signing: "Assinando…",
|
||||
signingPercent: "Assinando… {percent}%",
|
||||
downloadSigned: "Baixar PDF assinado",
|
||||
addFirst: "Adicione pelo menos uma assinatura antes de baixar.",
|
||||
pad: {
|
||||
title: "Criar assinatura",
|
||||
draw: "Desenhar",
|
||||
type: "Digitar",
|
||||
upload: "Enviar",
|
||||
color: "Cor",
|
||||
pen: "Caneta",
|
||||
namePlaceholder: "Digite seu nome",
|
||||
yourName: "Seu nome",
|
||||
remember: "Lembrar neste dispositivo",
|
||||
save: "Salvar e posicionar",
|
||||
uploadHint: "Solte um PNG / JPG ou clique para escolher. PNG transparente funciona melhor.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Termos para censurar",
|
||||
termsHint: "Um termo por linha (máx. 50)",
|
||||
|
||||
@@ -2633,6 +2633,33 @@ export const ru: TranslationKeys = {
|
||||
submitBatch: "Свести слои ({count} файлов)",
|
||||
progressLabel: "Сведение слоёв",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Ваши подписи",
|
||||
newSignature: "Создать",
|
||||
clickToPlace: "Нажмите на подпись, чтобы разместить её на текущей странице.",
|
||||
selectedSignature: "Выбранная подпись",
|
||||
dragToAdjust: "Перетаскивайте маркеры, чтобы изменить размер или повернуть.",
|
||||
disclaimer:
|
||||
"Добавляет видимое изображение подписи. Это не цифровая подпись на основе сертификата.",
|
||||
signing: "Подписание…",
|
||||
signingPercent: "Подписание… {percent}%",
|
||||
downloadSigned: "Скачать подписанный PDF",
|
||||
addFirst: "Добавьте хотя бы одну подпись перед скачиванием.",
|
||||
pad: {
|
||||
title: "Создать подпись",
|
||||
draw: "Нарисовать",
|
||||
type: "Ввести",
|
||||
upload: "Загрузить",
|
||||
color: "Цвет",
|
||||
pen: "Перо",
|
||||
namePlaceholder: "Введите ваше имя",
|
||||
yourName: "Ваше имя",
|
||||
remember: "Запомнить на этом устройстве",
|
||||
save: "Сохранить и разместить",
|
||||
uploadHint:
|
||||
"Перетащите PNG / JPG или нажмите, чтобы выбрать. Прозрачный PNG подходит лучше всего.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Слова для зачернения",
|
||||
termsHint: "По одному слову на строку (максимум 50)",
|
||||
|
||||
@@ -2631,6 +2631,33 @@ export const sv: TranslationKeys = {
|
||||
submitBatch: "Platta till ({count} filer)",
|
||||
progressLabel: "Plattar till",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Dina signaturer",
|
||||
newSignature: "Ny",
|
||||
clickToPlace: "Klicka på en signatur för att placera den på den aktuella sidan.",
|
||||
selectedSignature: "Vald signatur",
|
||||
dragToAdjust: "Dra i handtagen för att ändra storlek eller rotera.",
|
||||
disclaimer:
|
||||
"Lägger till en synlig signaturbild. Detta är inte en certifikatbaserad digital signatur.",
|
||||
signing: "Signerar…",
|
||||
signingPercent: "Signerar… {percent}%",
|
||||
downloadSigned: "Ladda ner signerad PDF",
|
||||
addFirst: "Lägg till minst en signatur innan du laddar ner.",
|
||||
pad: {
|
||||
title: "Skapa signatur",
|
||||
draw: "Rita",
|
||||
type: "Skriv",
|
||||
upload: "Ladda upp",
|
||||
color: "Färg",
|
||||
pen: "Penna",
|
||||
namePlaceholder: "Skriv ditt namn",
|
||||
yourName: "Ditt namn",
|
||||
remember: "Kom ihåg på den här enheten",
|
||||
save: "Spara och placera",
|
||||
uploadHint:
|
||||
"Släpp en PNG / JPG eller klicka för att välja. En transparent PNG fungerar bäst.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Termer att maskera",
|
||||
termsHint: "En term per rad (max 50)",
|
||||
|
||||
@@ -2608,6 +2608,31 @@ export const th: TranslationKeys = {
|
||||
submitBatch: "แผ่ราบ ({count} ไฟล์)",
|
||||
progressLabel: "กำลังแผ่ราบ",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "ลายเซ็นของคุณ",
|
||||
newSignature: "สร้างใหม่",
|
||||
clickToPlace: "คลิกที่ลายเซ็นเพื่อวางบนหน้าปัจจุบัน",
|
||||
selectedSignature: "ลายเซ็นที่เลือก",
|
||||
dragToAdjust: "ลากที่จับเพื่อปรับขนาดหรือหมุน",
|
||||
disclaimer: "เพิ่มภาพลายเซ็นที่มองเห็นได้ นี่ไม่ใช่ลายเซ็นดิจิทัลที่อิงใบรับรอง",
|
||||
signing: "กำลังลงนาม…",
|
||||
signingPercent: "กำลังลงนาม… {percent}%",
|
||||
downloadSigned: "ดาวน์โหลด PDF ที่ลงนามแล้ว",
|
||||
addFirst: "เพิ่มลายเซ็นอย่างน้อยหนึ่งรายการก่อนดาวน์โหลด",
|
||||
pad: {
|
||||
title: "สร้างลายเซ็น",
|
||||
draw: "วาด",
|
||||
type: "พิมพ์",
|
||||
upload: "อัปโหลด",
|
||||
color: "สี",
|
||||
pen: "ปากกา",
|
||||
namePlaceholder: "พิมพ์ชื่อของคุณ",
|
||||
yourName: "ชื่อของคุณ",
|
||||
remember: "จดจำบนอุปกรณ์นี้",
|
||||
save: "บันทึกและวาง",
|
||||
uploadHint: "วางไฟล์ PNG / JPG หรือคลิกเพื่อเลือก ไฟล์ PNG แบบโปร่งใสจะดีที่สุด",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "คำที่ต้องปกปิด",
|
||||
termsHint: "คำละหนึ่งบรรทัด (สูงสุด 50)",
|
||||
|
||||
@@ -2637,6 +2637,32 @@ export const tr: TranslationKeys = {
|
||||
submitBatch: "Düzleştir ({count} dosya)",
|
||||
progressLabel: "Düzleştiriliyor",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "İmzalarınız",
|
||||
newSignature: "Yeni",
|
||||
clickToPlace: "Geçerli sayfaya yerleştirmek için bir imzaya tıklayın.",
|
||||
selectedSignature: "Seçili imza",
|
||||
dragToAdjust: "Yeniden boyutlandırmak veya döndürmek için tutamaçları sürükleyin.",
|
||||
disclaimer: "Görsel bir imza resmi ekler. Bu, sertifika tabanlı dijital bir imza değildir.",
|
||||
signing: "İmzalanıyor…",
|
||||
signingPercent: "İmzalanıyor… {percent}%",
|
||||
downloadSigned: "İmzalı PDF'yi indir",
|
||||
addFirst: "İndirmeden önce en az bir imza ekleyin.",
|
||||
pad: {
|
||||
title: "İmza oluştur",
|
||||
draw: "Çiz",
|
||||
type: "Yaz",
|
||||
upload: "Yükle",
|
||||
color: "Renk",
|
||||
pen: "Kalem",
|
||||
namePlaceholder: "Adınızı yazın",
|
||||
yourName: "Adınız",
|
||||
remember: "Bu cihazda hatırla",
|
||||
save: "Kaydet ve yerleştir",
|
||||
uploadHint:
|
||||
"Bir PNG / JPG bırakın veya seçmek için tıklayın. Şeffaf PNG en iyi sonucu verir.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Karalanacak terimler",
|
||||
termsHint: "Her satıra bir terim (en fazla 50)",
|
||||
|
||||
@@ -2636,6 +2636,32 @@ export const uk: TranslationKeys = {
|
||||
submitBatch: "Звести ({count} файлів)",
|
||||
progressLabel: "Зведення",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Ваші підписи",
|
||||
newSignature: "Створити",
|
||||
clickToPlace: "Натисніть на підпис, щоб розмістити його на поточній сторінці.",
|
||||
selectedSignature: "Вибраний підпис",
|
||||
dragToAdjust: "Перетягуйте маркери, щоб змінити розмір або повернути.",
|
||||
disclaimer: "Додає видиме зображення підпису. Це не цифровий підпис на основі сертифіката.",
|
||||
signing: "Підписування…",
|
||||
signingPercent: "Підписування… {percent}%",
|
||||
downloadSigned: "Завантажити підписаний PDF",
|
||||
addFirst: "Додайте принаймні один підпис перед завантаженням.",
|
||||
pad: {
|
||||
title: "Створити підпис",
|
||||
draw: "Намалювати",
|
||||
type: "Ввести",
|
||||
upload: "Завантажити",
|
||||
color: "Колір",
|
||||
pen: "Перо",
|
||||
namePlaceholder: "Введіть ваше ім'я",
|
||||
yourName: "Ваше ім'я",
|
||||
remember: "Запам'ятати на цьому пристрої",
|
||||
save: "Зберегти та розмістити",
|
||||
uploadHint:
|
||||
"Перетягніть PNG / JPG або натисніть, щоб вибрати. Прозорий PNG працює найкраще.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Терміни для зачернення",
|
||||
termsHint: "Один термін на рядок (макс. 50)",
|
||||
|
||||
@@ -2633,6 +2633,32 @@ export const vi: TranslationKeys = {
|
||||
submitBatch: "Làm phẳng ({count} tệp)",
|
||||
progressLabel: "Đang làm phẳng",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "Chữ ký của bạn",
|
||||
newSignature: "Tạo mới",
|
||||
clickToPlace: "Nhấp vào một chữ ký để đặt nó lên trang hiện tại.",
|
||||
selectedSignature: "Chữ ký đã chọn",
|
||||
dragToAdjust: "Kéo các tay cầm để thay đổi kích thước hoặc xoay.",
|
||||
disclaimer:
|
||||
"Thêm một hình ảnh chữ ký trực quan. Đây không phải là chữ ký số dựa trên chứng chỉ.",
|
||||
signing: "Đang ký…",
|
||||
signingPercent: "Đang ký… {percent}%",
|
||||
downloadSigned: "Tải PDF đã ký",
|
||||
addFirst: "Thêm ít nhất một chữ ký trước khi tải xuống.",
|
||||
pad: {
|
||||
title: "Tạo chữ ký",
|
||||
draw: "Vẽ",
|
||||
type: "Nhập",
|
||||
upload: "Tải lên",
|
||||
color: "Màu",
|
||||
pen: "Bút",
|
||||
namePlaceholder: "Nhập tên của bạn",
|
||||
yourName: "Tên của bạn",
|
||||
remember: "Ghi nhớ trên thiết bị này",
|
||||
save: "Lưu và đặt",
|
||||
uploadHint: "Thả PNG / JPG, hoặc nhấp để chọn. PNG trong suốt hoạt động tốt nhất.",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "Từ cần che",
|
||||
termsHint: "Mỗi dòng một từ (tối đa 50)",
|
||||
|
||||
@@ -2398,6 +2398,31 @@ export const zhCN: TranslationKeys = {
|
||||
submitBatch: "拼合({count} 个文件)",
|
||||
progressLabel: "正在拼合",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "您的签名",
|
||||
newSignature: "新建",
|
||||
clickToPlace: "点击签名即可将其放置到当前页面。",
|
||||
selectedSignature: "选中的签名",
|
||||
dragToAdjust: "拖动控制点可调整大小或旋转。",
|
||||
disclaimer: "添加可见的签名图像。这不是基于证书的数字签名。",
|
||||
signing: "正在签名…",
|
||||
signingPercent: "正在签名… {percent}%",
|
||||
downloadSigned: "下载已签名的 PDF",
|
||||
addFirst: "下载前请至少添加一个签名。",
|
||||
pad: {
|
||||
title: "创建签名",
|
||||
draw: "手写",
|
||||
type: "输入",
|
||||
upload: "上传",
|
||||
color: "颜色",
|
||||
pen: "画笔",
|
||||
namePlaceholder: "输入您的姓名",
|
||||
yourName: "您的姓名",
|
||||
remember: "在此设备上记住",
|
||||
save: "保存并放置",
|
||||
uploadHint: "拖入 PNG / JPG,或点击选择。透明 PNG 效果最佳。",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "要涂黑的内容",
|
||||
termsHint: "每行一个词条(最多 50 个)",
|
||||
|
||||
@@ -2396,6 +2396,31 @@ export const zhTW: TranslationKeys = {
|
||||
submitBatch: "扁平化({count} 個檔案)",
|
||||
progressLabel: "正在扁平化",
|
||||
},
|
||||
"sign-pdf": {
|
||||
yourSignatures: "您的簽名",
|
||||
newSignature: "新增",
|
||||
clickToPlace: "點擊簽名即可將其放置到目前頁面。",
|
||||
selectedSignature: "選取的簽名",
|
||||
dragToAdjust: "拖曳控點可調整大小或旋轉。",
|
||||
disclaimer: "新增可見的簽名圖片。這並非以憑證為基礎的數位簽章。",
|
||||
signing: "正在簽署…",
|
||||
signingPercent: "正在簽署… {percent}%",
|
||||
downloadSigned: "下載已簽署的 PDF",
|
||||
addFirst: "下載前請至少新增一個簽名。",
|
||||
pad: {
|
||||
title: "建立簽名",
|
||||
draw: "手寫",
|
||||
type: "輸入",
|
||||
upload: "上傳",
|
||||
color: "顏色",
|
||||
pen: "筆",
|
||||
namePlaceholder: "輸入您的姓名",
|
||||
yourName: "您的姓名",
|
||||
remember: "在此裝置上記住",
|
||||
save: "儲存並放置",
|
||||
uploadHint: "拖入 PNG / JPG,或點擊選擇。透明 PNG 效果最佳。",
|
||||
},
|
||||
},
|
||||
"redact-pdf": {
|
||||
terms: "要塗黑的詞彙",
|
||||
termsHint: "每行一個詞彙(最多 50 個)",
|
||||
|
||||
@@ -64,3 +64,16 @@ export interface SocialMediaPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A signature placed on a PDF page by the sign-pdf tool. Coordinates are page
|
||||
* fractions (0..1), top-left origin; `sig` indexes the uploaded signature PNGs.
|
||||
*/
|
||||
export interface SignPlacement {
|
||||
sig: number;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
|
||||
const PDF_FIXTURE = path.join(
|
||||
process.cwd(),
|
||||
"tests",
|
||||
"fixtures",
|
||||
"document",
|
||||
"valid",
|
||||
"test-3page.pdf",
|
||||
);
|
||||
|
||||
// Reuse an existing small PNG as the uploaded signature image. Any PNG works
|
||||
// here; the pad's Upload tab accepts image/png,image/jpeg.
|
||||
const SIGNATURE_PNG = path.join(
|
||||
process.cwd(),
|
||||
"tests",
|
||||
"fixtures",
|
||||
"image",
|
||||
"valid",
|
||||
"test-200x150.png",
|
||||
);
|
||||
|
||||
// The PDF dropzone opens a native file chooser on click rather than exposing a
|
||||
// persistent <input type="file">, so upload through the chooser event (matches
|
||||
// pdf-to-image.spec.ts / document-mode.spec.ts).
|
||||
async function uploadPdf(page: Page): Promise<void> {
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first();
|
||||
if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await uploadButton.click();
|
||||
} else {
|
||||
await page.locator("[class*='border-dashed']").first().click();
|
||||
}
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(PDF_FIXTURE);
|
||||
}
|
||||
|
||||
// Open the signature pad, upload a PNG on the Upload tab, and place it. The pad
|
||||
// drops the signature at page center on "Save & place" (no Konva drag needed).
|
||||
// This whole path is pure client-side until the user clicks Apply & Download.
|
||||
async function placeUploadedSignature(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "+ New" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Upload", exact: true }).click();
|
||||
// The hidden file input only mounts once the Upload tab is active. Scope it to
|
||||
// the dialog so it never collides with the tool dropzone's chooser.
|
||||
await dialog.locator('input[accept="image/png,image/jpeg"]').setInputFiles(SIGNATURE_PNG);
|
||||
const save = dialog.getByRole("button", { name: "Save & place" });
|
||||
// The image is read via FileReader, so the button enables a tick after upload.
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
// The pad closes once the signature is placed onto the canvas.
|
||||
await expect(dialog).toBeHidden();
|
||||
}
|
||||
|
||||
test.describe("Sign PDF tool", () => {
|
||||
test("places a signature on a PDF (interactive flow)", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/pdf/sign-pdf");
|
||||
await uploadPdf(page);
|
||||
|
||||
// pdf.js renders the uploaded page into the sign canvas.
|
||||
await expect(page.getByTestId("sign-pdf-canvas")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Apply is gated on at least one placement, so it starts disabled.
|
||||
const apply = page.getByRole("button", { name: /Apply & Download/ });
|
||||
await expect(apply).toBeDisabled();
|
||||
|
||||
await placeUploadedSignature(page);
|
||||
|
||||
// A placement now exists -> Apply becomes enabled. addSignature loads the
|
||||
// image asynchronously and only then bumps the count, so toBeEnabled
|
||||
// auto-retries until the count propagates to the settings panel.
|
||||
await expect(apply).toBeEnabled();
|
||||
});
|
||||
|
||||
// Stamping calls the Python sidecar (doc_sign, which needs PyMuPDF). The e2e
|
||||
// webServer boots the API with the system Python, which has no PyMuPDF here
|
||||
// (and PyMuPDF-less CI shards exist too), so the actual stamp + signed-PDF
|
||||
// download cannot succeed in those environments. The integration test
|
||||
// (tests/integration/sign-pdf.test.ts) already covers stamping where PyMuPDF
|
||||
// is present. Gate this on an explicit flag so the default suite never depends
|
||||
// on PyMuPDF.
|
||||
test("stamps the PDF and offers a signed download", async ({ loggedInPage: page }) => {
|
||||
test.skip(
|
||||
!process.env.SIGN_PDF_E2E_STAMP,
|
||||
"requires PyMuPDF in the API (set SIGN_PDF_E2E_STAMP=1)",
|
||||
);
|
||||
|
||||
await page.goto("/pdf/sign-pdf");
|
||||
await uploadPdf(page);
|
||||
await expect(page.getByTestId("sign-pdf-canvas")).toBeVisible({ timeout: 15_000 });
|
||||
await placeUploadedSignature(page);
|
||||
|
||||
await page.getByRole("button", { name: /Apply & Download/ }).click();
|
||||
await expect(page.getByRole("link", { name: /Download signed PDF/ })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ const REGISTRY_EXEMPT = new Set([
|
||||
"ocr-pdf",
|
||||
"pdf-to-image",
|
||||
"qr-generate",
|
||||
"sign-pdf",
|
||||
"stitch",
|
||||
"svg-to-raster",
|
||||
"transcribe-audio",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../fixtures/index.js";
|
||||
import { hasFitz } from "../helpers/python-gate.js";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const PDF = readFixture(fixtures.document.pdf3);
|
||||
const SIG = readFixture(fixtures.image.base.png200);
|
||||
|
||||
// The stamping test invokes the docs profile's doc_sign script (PyMuPDF) and is
|
||||
// gated on fitz so it skips where PyMuPDF is not installed (e.g. CI integration
|
||||
// shards). The validation test returns 400 before any Python call, so it always
|
||||
// runs (it needs only Postgres/Redis, which CI provides).
|
||||
describe("sign-pdf", () => {
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
function runTool(placements: unknown) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "in.pdf", contentType: "application/pdf", content: PDF },
|
||||
{ name: "sig0", filename: "sig0.png", contentType: "image/png", content: SIG },
|
||||
{ name: "placements", content: JSON.stringify(placements) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pdf/sign-pdf",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
(hasFitz ? it : it.skip)(
|
||||
"stamps a signature and returns a PDF",
|
||||
async () => {
|
||||
const res = await runTool([{ sig: 0, page: 0, x: 0.5, y: 0.5, w: 0.25, h: 0.05 }]);
|
||||
expect([200, 202]).toContain(res.statusCode);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.jobId).toBeTruthy();
|
||||
if (res.statusCode === 200) {
|
||||
expect(body.downloadUrl).toContain("/api/v1/download/");
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it("rejects when no placements are provided", async () => {
|
||||
const res = await runTool([]);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ const REGISTRY_EXEMPT = new Set([
|
||||
"ocr-pdf",
|
||||
"pdf-to-image",
|
||||
"qr-generate",
|
||||
"sign-pdf",
|
||||
"stitch",
|
||||
"svg-to-raster",
|
||||
"transcribe-audio",
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("toolSection", () => {
|
||||
expect(toolSection({ modality: "document", acceptedInputs: [".docx", ".odt"] })).toBe("files");
|
||||
});
|
||||
|
||||
it("partitions the catalog into exactly 28 PDF and 23 Files tools", () => {
|
||||
it("partitions the catalog into exactly 29 PDF and 23 Files tools", () => {
|
||||
const bySection = (s: Section) =>
|
||||
TOOLS.filter((t) => toolSection(t) === s)
|
||||
.map((t) => t.id)
|
||||
@@ -22,8 +22,9 @@ describe("toolSection", () => {
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(105);
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "video")).toHaveLength(57);
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "audio")).toHaveLength(27);
|
||||
expect(bySection("pdf")).toHaveLength(28);
|
||||
expect(bySection("pdf")).toHaveLength(29);
|
||||
expect(bySection("files")).toHaveLength(23);
|
||||
expect(bySection("pdf")).toContain("sign-pdf");
|
||||
expect(bySection("pdf")).toContain("merge-pdf");
|
||||
expect(bySection("pdf")).toContain("ocr-pdf");
|
||||
expect(bySection("files")).toContain("word-to-pdf");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rotatedBoundingBox, toNormalizedRect } from "@/lib/sign-geometry";
|
||||
|
||||
describe("sign-geometry", () => {
|
||||
it("normalizes a pixel rect against the render size", () => {
|
||||
expect(toNormalizedRect({ x: 50, y: 100, w: 25, h: 10 }, 100, 200)).toEqual({
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 0.25,
|
||||
h: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounding box of an unrotated rect is unchanged", () => {
|
||||
expect(rotatedBoundingBox(40, 20, 0)).toEqual({ w: 40, h: 20 });
|
||||
});
|
||||
|
||||
it("bounding box of a 90deg rotation swaps w/h", () => {
|
||||
const b = rotatedBoundingBox(40, 20, 90);
|
||||
expect(b.w).toBeCloseTo(20, 5);
|
||||
expect(b.h).toBeCloseTo(40, 5);
|
||||
});
|
||||
|
||||
it("bounding box grows for a 45deg rotation", () => {
|
||||
const b = rotatedBoundingBox(40, 20, 45);
|
||||
expect(b.w).toBeCloseTo((40 + 20) / Math.SQRT2, 5);
|
||||
expect(b.h).toBeCloseTo((40 + 20) / Math.SQRT2, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSignature,
|
||||
deleteSignature,
|
||||
listSignatures,
|
||||
MAX_SIGNATURES,
|
||||
} from "@/lib/signature-store";
|
||||
|
||||
// The jsdom env's native localStorage is a broken stub here (Node's experimental
|
||||
// Web Storage shadows it, missing methods), so back it with an in-memory map --
|
||||
// the same approach the other web unit tests use.
|
||||
const storageMap = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, val: string) => storageMap.set(key, val)),
|
||||
removeItem: vi.fn((key: string) => storageMap.delete(key)),
|
||||
clear: vi.fn(() => storageMap.clear()),
|
||||
get length() {
|
||||
return storageMap.size;
|
||||
},
|
||||
key: vi.fn((_i: number) => null),
|
||||
});
|
||||
|
||||
describe("signature-store", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("adds and lists signatures", () => {
|
||||
addSignature("data:image/png;base64,AAAA");
|
||||
expect(listSignatures()).toHaveLength(1);
|
||||
expect(listSignatures()[0].dataUrl).toContain("base64,AAAA");
|
||||
});
|
||||
|
||||
it("caps the library at MAX_SIGNATURES (drops oldest)", () => {
|
||||
for (let i = 0; i < MAX_SIGNATURES + 3; i++) addSignature(`data:image/png;base64,S${i}`);
|
||||
expect(listSignatures()).toHaveLength(MAX_SIGNATURES);
|
||||
expect(listSignatures().some((s) => s.dataUrl.endsWith("S0"))).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes by id", () => {
|
||||
const sig = addSignature("data:image/png;base64,BBBB");
|
||||
deleteSignature(sig.id);
|
||||
expect(listSignatures()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -349,6 +349,7 @@ describe("toolRegistry", () => {
|
||||
"interactive-crop",
|
||||
"interactive-eraser",
|
||||
"interactive-split",
|
||||
"interactive-sign",
|
||||
"no-dropzone",
|
||||
"custom-results",
|
||||
"media-player",
|
||||
|
||||
Reference in New Issue
Block a user