Fix signatures not showing (#5872)

This commit is contained in:
ConnorYoh
2026-03-06 00:43:29 +00:00
committed by GitHub
parent 086b55b0bb
commit 7fdd100abf
6 changed files with 217 additions and 14 deletions

View File

@@ -331,6 +331,29 @@ function WidgetInputInner({
}
case 'signature':
// Signed signatures have a pre-rendered appearance image; render it.
if (field.appearanceDataUrl) {
return (
<img
src={field.appearanceDataUrl}
style={{
position: 'absolute',
left,
top,
width,
height,
zIndex: 10,
pointerEvents: 'none',
userSelect: 'none',
display: 'block',
}}
alt={field.label || 'Signature'}
draggable={false}
/>
);
}
// 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(

View File

@@ -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<number, FormField[]>();
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;

View File

@@ -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<string, string> = {};
for (const field of fields) {

View File

@@ -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 (

View File

@@ -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<FormField[]> {
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<void> {
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<number, FormField[]>();
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;
}

View File

@@ -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 =