mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Fix signatures not showing (#5872)
This commit is contained in:
@@ -331,6 +331,29 @@ function WidgetInputInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'signature':
|
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':
|
case 'button':
|
||||||
// Just render a highlighted area — not editable
|
// Just render a highlighted area — not editable
|
||||||
return (
|
return (
|
||||||
@@ -406,7 +429,7 @@ export function FormFieldOverlay({
|
|||||||
|
|
||||||
const pageFields = useMemo(
|
const pageFields = useMemo(
|
||||||
() => fieldsByPage.get(pageIndex) || [],
|
() => fieldsByPage.get(pageIndex) || [],
|
||||||
[fieldsByPage, pageIndex]
|
[fieldsByPage, pageIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
|
|||||||
@@ -289,10 +289,15 @@ const FormFill = (_props: BaseToolProps) => {
|
|||||||
[setActiveField, scrollActions]
|
[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 { sortedPages, fieldsByPage } = useMemo(() => {
|
||||||
const byPage = new Map<number, FormField[]>();
|
const byPage = new Map<number, FormField[]>();
|
||||||
for (const field of formState.fields) {
|
for (const field of fillableFields) {
|
||||||
const pageIndex =
|
const pageIndex =
|
||||||
field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0;
|
field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0;
|
||||||
if (!byPage.has(pageIndex)) {
|
if (!byPage.has(pageIndex)) {
|
||||||
@@ -302,13 +307,7 @@ const FormFill = (_props: BaseToolProps) => {
|
|||||||
}
|
}
|
||||||
const pages = Array.from(byPage.keys()).sort((a, b) => a - b);
|
const pages = Array.from(byPage.keys()).sort((a, b) => a - b);
|
||||||
return { sortedPages: pages, fieldsByPage: byPage };
|
return { sortedPages: pages, fieldsByPage: byPage };
|
||||||
}, [formState.fields]);
|
}, [fillableFields]);
|
||||||
|
|
||||||
// Progress tracking
|
|
||||||
|
|
||||||
const fillableFields = useMemo(() => {
|
|
||||||
return formState.fields.filter((f) => f.type !== 'button' && f.type !== 'signature');
|
|
||||||
}, [formState.fields]);
|
|
||||||
|
|
||||||
const fillableCount = fillableFields.length;
|
const fillableCount = fillableFields.length;
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import React, {
|
|||||||
import { useDebouncedCallback } from '@mantine/hooks';
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/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';
|
import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -319,12 +319,27 @@ export function FormFillProvider({
|
|||||||
dispatch({ type: 'RESET' });
|
dispatch({ type: 'RESET' });
|
||||||
dispatch({ type: 'FETCH_START' });
|
dispatch({ type: 'FETCH_START' });
|
||||||
try {
|
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 another fetch or reset happened while we were waiting, discard this result
|
||||||
if (fetchVersionRef.current !== version) {
|
if (fetchVersionRef.current !== version) {
|
||||||
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
|
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||||
return;
|
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
|
// Initialise values in the external store
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function FormSaveBar({ file, isFormFillToolActive, onApply }: FormSaveBar
|
|||||||
// - no form fields found
|
// - no form fields found
|
||||||
// - still loading
|
// - still loading
|
||||||
// - user dismissed the bar
|
// - 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;
|
const visible = !isFormFillToolActive && hasFields && !loading && !dismissed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
|
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
|
||||||
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
|
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
|
||||||
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage,
|
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 { FormField, FormFieldType, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/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;
|
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 {
|
export class PdfLibFormProvider implements IFormDataProvider {
|
||||||
readonly name = 'pdf-lib';
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface FormField {
|
|||||||
multiline: boolean;
|
multiline: boolean;
|
||||||
tooltip: string | null;
|
tooltip: string | null;
|
||||||
widgets: WidgetCoordinates[] | null;
|
widgets: WidgetCoordinates[] | null;
|
||||||
|
/** Pre-rendered appearance image for signed signature fields (data URL). */
|
||||||
|
appearanceDataUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormFieldType =
|
export type FormFieldType =
|
||||||
|
|||||||
Reference in New Issue
Block a user