diff --git a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx index 22698b9050..0838482a87 100644 --- a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx +++ b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx @@ -331,6 +331,29 @@ function WidgetInputInner({ } case 'signature': + // Signed signatures have a pre-rendered appearance image; render it. + if (field.appearanceDataUrl) { + return ( + {field.label + ); + } + // Unsigned signature — fall through to placeholder + // falls through case 'button': // Just render a highlighted area — not editable return ( @@ -406,7 +429,7 @@ export function FormFieldOverlay({ const pageFields = useMemo( () => fieldsByPage.get(pageIndex) || [], - [fieldsByPage, pageIndex] + [fieldsByPage, pageIndex], ); const handleFocus = useCallback( diff --git a/frontend/src/core/tools/formFill/FormFill.tsx b/frontend/src/core/tools/formFill/FormFill.tsx index 4d6f5436da..0a809a4b0d 100644 --- a/frontend/src/core/tools/formFill/FormFill.tsx +++ b/frontend/src/core/tools/formFill/FormFill.tsx @@ -289,10 +289,15 @@ const FormFill = (_props: BaseToolProps) => { [setActiveField, scrollActions] ); - // Memoize fields grouped by page + // Progress tracking + const fillableFields = useMemo(() => { + return formState.fields.filter((f) => f.type !== 'button' && f.type !== 'signature'); + }, [formState.fields]); + + // Memoize fillable fields grouped by page (signatures/buttons excluded) const { sortedPages, fieldsByPage } = useMemo(() => { const byPage = new Map(); - for (const field of formState.fields) { + for (const field of fillableFields) { const pageIndex = field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0; if (!byPage.has(pageIndex)) { @@ -302,13 +307,7 @@ const FormFill = (_props: BaseToolProps) => { } const pages = Array.from(byPage.keys()).sort((a, b) => a - b); return { sortedPages: pages, fieldsByPage: byPage }; - }, [formState.fields]); - - // Progress tracking - - const fillableFields = useMemo(() => { - return formState.fields.filter((f) => f.type !== 'button' && f.type !== 'signature'); - }, [formState.fields]); + }, [fillableFields]); const fillableCount = fillableFields.length; diff --git a/frontend/src/core/tools/formFill/FormFillContext.tsx b/frontend/src/core/tools/formFill/FormFillContext.tsx index 611c212c96..dc2ce924c3 100644 --- a/frontend/src/core/tools/formFill/FormFillContext.tsx +++ b/frontend/src/core/tools/formFill/FormFillContext.tsx @@ -32,7 +32,7 @@ import React, { import { useDebouncedCallback } from '@mantine/hooks'; import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types'; import type { IFormDataProvider } from '@app/tools/formFill/providers/types'; -import { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider'; +import { PdfLibFormProvider, fetchSignatureFieldsWithAppearances } from '@app/tools/formFill/providers/PdfLibFormProvider'; import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider'; // --------------------------------------------------------------------------- @@ -319,12 +319,27 @@ export function FormFillProvider({ dispatch({ type: 'RESET' }); dispatch({ type: 'FETCH_START' }); try { - const fields = await providerRef.current.fetchFields(file); + let fields = await providerRef.current.fetchFields(file); // If another fetch or reset happened while we were waiting, discard this result if (fetchVersionRef.current !== version) { console.log('[FormFill] Discarding stale fetch result (version mismatch)'); return; } + + // When the pdfbox provider is active the backend doesn't return signature fields + // (they're not fillable). Fetch them via pdflib so their appearances still render. + if (providerModeRef.current === 'pdfbox') { + try { + const sigFields = await fetchSignatureFieldsWithAppearances(file); + if (fetchVersionRef.current !== version) return; // stale check after async + if (sigFields.length > 0) { + fields = [...fields, ...sigFields]; + } + } catch (e) { + console.warn('[FormFill] Failed to extract signature appearances for pdfbox mode:', e); + } + } + // Initialise values in the external store const values: Record = {}; for (const field of fields) { diff --git a/frontend/src/core/tools/formFill/FormSaveBar.tsx b/frontend/src/core/tools/formFill/FormSaveBar.tsx index cc433b6ded..676b1b009f 100644 --- a/frontend/src/core/tools/formFill/FormSaveBar.tsx +++ b/frontend/src/core/tools/formFill/FormSaveBar.tsx @@ -85,7 +85,7 @@ export function FormSaveBar({ file, isFormFillToolActive, onApply }: FormSaveBar // - no form fields found // - still loading // - user dismissed the bar - const hasFields = fields.length > 0; + const hasFields = fields.some(f => f.type !== 'signature' && f.type !== 'button'); const visible = !isFormFillToolActive && hasFields && !loading && !dismissed; return ( diff --git a/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts b/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts index 2da141cc4f..05876cdb1c 100644 --- a/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts +++ b/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts @@ -17,7 +17,9 @@ import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox, PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature, PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage, - PDFString, PDFHexString } from '@cantoo/pdf-lib'; + PDFString, PDFHexString, PDFStream } from '@cantoo/pdf-lib'; +import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs'; +import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; import type { FormField, FormFieldType, WidgetCoordinates } from '@app/tools/formFill/types'; import type { IFormDataProvider } from '@app/tools/formFill/providers/types'; @@ -546,6 +548,159 @@ function getFieldLabel(field: PDFField): string { return parts[parts.length - 1] || name; } +/** + * Extracts only signed signature fields (those with an /AP/N stream) from a PDF, + * renders their appearances via PDF.js, and returns them as FormField objects. + * + * Used by FormFillContext to inject signature overlays when the pdfbox provider + * is active (fill form tool), where the backend doesn't return signature fields. + */ +export async function fetchSignatureFieldsWithAppearances(file: File | Blob): Promise { + const arrayBuffer = await readAsArrayBuffer(file); + let doc: PDFDocument; + try { + doc = await PDFDocument.load(arrayBuffer, { + ignoreEncryption: true, + updateMetadata: false, + throwOnInvalidObject: false, + }); + } catch { return []; } + + let form: PDFForm; + try { form = doc.getForm(); } catch { return []; } + + let pdfFields: PDFField[]; + try { pdfFields = form.getFields(); } catch { return []; } + + let pages: PDFPage[]; + try { pages = doc.getPages(); } catch { return []; } + + const result: FormField[] = []; + + for (const field of pdfFields) { + if (!(field instanceof PDFSignature)) continue; + if (!signatureHasAppearance(field)) continue; + + const widgets = extractWidgets(field, pages, doc); + if (widgets.length === 0) continue; + + result.push({ + name: field.getName(), + label: getFieldLabel(field), + type: 'signature', + value: '', + options: null, + displayOptions: null, + required: false, + readOnly: true, + multiSelect: false, + multiline: false, + tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict), + widgets, + }); + } + + if (result.length > 0) { + await attachSignatureAppearances(result, arrayBuffer); + } + + return result; +} + +/** + * Returns true if the signature field has at least one widget with a normal + * (/AP/N) appearance stream — i.e. the signature has actually been signed. + */ +function signatureHasAppearance(field: PDFField): boolean { + if (!(field instanceof PDFSignature)) return false; + try { + const acroDict = (field.acroField as any).dict as PDFDict; + const widgets = getFieldWidgets(acroDict); + for (const wDict of widgets) { + const ap = wDict.lookup(PDFName.of('AP')); + if (ap instanceof PDFDict) { + const n = ap.lookup(PDFName.of('N')); + if (n instanceof PDFStream) return true; + } + } + } catch { /* ignore malformed fields */ } + return false; +} + +/** + * For each signature FormField that has an /AP/N stream, renders the + * containing page via PDF.js and crops out the widget rectangle, + * attaching the result as field.appearanceDataUrl. + * + * Uses the existing pdfWorkerManager for proper worker lifecycle management. + */ +async function attachSignatureAppearances( + signatureFields: FormField[], + arrayBuffer: ArrayBuffer, +): Promise { + if (signatureFields.length === 0) return; + + let pdfDoc: PDFDocumentProxy | null = null; + try { + // Slice so pdf-lib's retained references to the original buffer are unaffected + pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer.slice(0)); + + // Group fields by the pageIndex of their first widget + const byPage = new Map(); + for (const field of signatureFields) { + for (const w of field.widgets ?? []) { + const arr = byPage.get(w.pageIndex) ?? []; + arr.push(field); + byPage.set(w.pageIndex, arr); + break; // first widget identifies the page + } + } + + const RENDER_SCALE = 2; // 2× for crisp appearance + + for (const [pageIndex, fields] of byPage) { + const page = await pdfDoc.getPage(pageIndex + 1); // PDF.js is 1-indexed + const viewport = page.getViewport({ scale: RENDER_SCALE }); + + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + + await page.render({ canvas, viewport }).promise; + + for (const field of fields) { + for (const widget of field.widgets ?? []) { + if (widget.pageIndex !== pageIndex) continue; + + // widget.x/y are PDF points, CSS upper-left origin (relative to CropBox). + // PDF.js renders the CropBox starting at canvas (0,0), so multiplying by + // RENDER_SCALE gives the correct canvas pixel coordinates for the crop. + const cx = Math.round(widget.x * RENDER_SCALE); + const cy = Math.round(widget.y * RENDER_SCALE); + const cw = Math.max(1, Math.round(widget.width * RENDER_SCALE)); + const ch = Math.max(1, Math.round(widget.height * RENDER_SCALE)); + + const crop = document.createElement('canvas'); + crop.width = cw; + crop.height = ch; + const cropCtx = crop.getContext('2d'); + if (!cropCtx) continue; + + cropCtx.drawImage(canvas, cx, cy, cw, ch, 0, 0, cw, ch); + field.appearanceDataUrl = crop.toDataURL('image/png'); + break; // first widget is representative + } + } + + page.cleanup(); + } + } catch (e) { + console.warn('[PdfLibFormProvider] Failed to extract signature appearances:', e); + } finally { + if (pdfDoc) pdfWorkerManager.destroyDocument(pdfDoc); + } +} + export class PdfLibFormProvider implements IFormDataProvider { readonly name = 'pdf-lib'; @@ -629,6 +784,15 @@ export class PdfLibFormProvider implements IFormDataProvider { } } + // Render appearance streams for signed signature fields so the overlay + // can display them as images instead of placeholder boxes. + const sigFieldsWithAp = result.filter( + f => f.type === 'signature' && signatureHasAppearance(form.getField(f.name)), + ); + if (sigFieldsWithAp.length > 0) { + await attachSignatureAppearances(sigFieldsWithAp, arrayBuffer); + } + return result; } diff --git a/frontend/src/core/tools/formFill/types.ts b/frontend/src/core/tools/formFill/types.ts index 38397dccd9..a19d9cb9ac 100644 --- a/frontend/src/core/tools/formFill/types.ts +++ b/frontend/src/core/tools/formFill/types.ts @@ -30,6 +30,8 @@ export interface FormField { multiline: boolean; tooltip: string | null; widgets: WidgetCoordinates[] | null; + /** Pre-rendered appearance image for signed signature fields (data URL). */ + appearanceDataUrl?: string; } export type FormFieldType =