mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(pdf): replace PdfLib with Pdfium for form handling and general rendering tasks (#5899)
# Description of Changes Improves PDF rendering in the viewer by adding digital signature field support, cleaning up overlay rendering, and migrating the contrast tool off pdf-lib to PDFium WASM. ### Signature Field Overlay - Added `SignatureFieldOverlay` component that renders digital signature form fields - Renders appearance streams when present; shows a fallback badge for unsigned fields - Uses PDFium WASM for bitmap extraction ### Overlay Rendering - Integrated `SignatureFieldOverlay` and `ButtonAppearanceOverlay` into `LocalEmbedPDF` - Overlays are now clipped to page boundaries - Clarified in `EmbedPdfViewer` that frontend overlays use PDFium WASM, backend overlays use PDFBox ### Contrast Tool Migration - Replaced pdf-lib with PDFium WASM in `useAdjustContrastOperation` - PDF page creation and image embedding now go through PDFium APIs directly - Updated bitmap handling and memory management accordingly ### Cleanup - Fixed import ordering in viewer components - Removed stale comments in the contrast operation hook <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs <[email protected]> Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* ButtonAppearanceOverlay — Renders PDF push-button widget appearances as
|
||||
* canvas bitmaps on top of a PDF page.
|
||||
*
|
||||
* This is a visual-only layer (pointerEvents: none). Click handling is done
|
||||
* separately by FormFieldOverlay's transparent hit-target divs.
|
||||
*
|
||||
* Uses the same EPDF_RenderAnnotBitmap / FPDF_FFLDraw pipeline as
|
||||
* SignatureFieldOverlay to produce the button's native PDF appearance.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||
import { renderButtonFieldAppearances, type SignatureFieldAppearance } from '@app/services/pdfiumService';
|
||||
|
||||
interface ButtonAppearanceOverlayProps {
|
||||
pageIndex: number;
|
||||
pdfSource: File | Blob | null;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
let _cachedSource: File | Blob | null = null;
|
||||
let _cachePromise: Promise<SignatureFieldAppearance[]> | null = null;
|
||||
|
||||
async function resolveButtonAppearances(source: File | Blob): Promise<SignatureFieldAppearance[]> {
|
||||
if (source === _cachedSource && _cachePromise) return _cachePromise;
|
||||
_cachedSource = source;
|
||||
_cachePromise = source.arrayBuffer().then((buf) => renderButtonFieldAppearances(buf));
|
||||
return _cachePromise;
|
||||
}
|
||||
function ButtonBitmapCanvas({ imageData, cssWidth, cssHeight }: {
|
||||
imageData: ImageData;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.putImageData(imageData, 0, 0);
|
||||
}, [imageData]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: cssWidth, height: cssHeight, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function ButtonAppearanceOverlayInner({
|
||||
pageIndex,
|
||||
pdfSource,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: ButtonAppearanceOverlayProps) {
|
||||
const [appearances, setAppearances] = useState<SignatureFieldAppearance[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfSource) { setAppearances([]); return; }
|
||||
let cancelled = false;
|
||||
resolveButtonAppearances(pdfSource)
|
||||
.then((res) => { if (!cancelled) setAppearances(res); })
|
||||
.catch(() => { if (!cancelled) setAppearances([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pdfSource]);
|
||||
|
||||
const pageAppearances = useMemo(
|
||||
() => appearances.filter((a) => a.pageIndex === pageIndex && a.imageData !== null),
|
||||
[appearances, pageIndex],
|
||||
);
|
||||
|
||||
if (pageAppearances.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 4,
|
||||
}}
|
||||
data-button-appearance-page={pageIndex}
|
||||
>
|
||||
{pageAppearances.map((btn, idx) => {
|
||||
const sx = btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
|
||||
const sy = btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
|
||||
const left = btn.x * sx;
|
||||
const top = btn.y * sy;
|
||||
const width = btn.width * sx;
|
||||
const height = btn.height * sy;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`btn-appearance-${btn.fieldName}-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ButtonBitmapCanvas
|
||||
imageData={btn.imageData!}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ButtonAppearanceOverlay = memo(ButtonAppearanceOverlayInner);
|
||||
@@ -138,15 +138,13 @@ function FieldInputInner({
|
||||
case 'radio': {
|
||||
const radioOptions: { value: string; label: string }[] = [];
|
||||
if (field.widgets && field.widgets.length > 0) {
|
||||
for (const w of field.widgets) {
|
||||
if (w.exportValue && !radioOptions.some((o) => o.value === w.exportValue)) {
|
||||
radioOptions.push({ value: w.exportValue, label: w.exportValue });
|
||||
}
|
||||
for (let i = 0; i < field.widgets.length; i++) {
|
||||
const w = field.widgets[i];
|
||||
// Use widget index as value; display option label, export value, or index
|
||||
const label = (field.options && field.options[i]) || w.exportValue || String(i);
|
||||
radioOptions.push({ value: String(i), label });
|
||||
}
|
||||
}
|
||||
if (radioOptions.length === 0 && field.options) {
|
||||
radioOptions.push(...field.options.map((o) => ({ value: o, label: o })));
|
||||
}
|
||||
return (
|
||||
<Radio.Group
|
||||
value={value}
|
||||
|
||||
@@ -20,7 +20,110 @@
|
||||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import { useDocumentState } from '@embedpdf/core/react';
|
||||
import { useFormFill, useFieldValue } from '@app/tools/formFill/FormFillContext';
|
||||
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import type { FormField, WidgetCoordinates, ButtonAction } from '@app/tools/formFill/types';
|
||||
|
||||
/**
|
||||
* Execute PDF JavaScript in a minimally sandboxed context.
|
||||
*
|
||||
* Implements a heuristic security check by statically rejecting scripts containing
|
||||
* common browser globals (`window`, `document`, `fetch`), reflection APIs,
|
||||
* or execution sinks (`eval`, `Function`).
|
||||
*
|
||||
* Valid scripts run in strict mode with dangerous globals explicitly masked
|
||||
* to `undefined`, allowing safe Acrobat APIs like `this.print()` or `app.alert()`.
|
||||
*/
|
||||
function executePdfJs(
|
||||
js: string,
|
||||
handlers: {
|
||||
print: () => void;
|
||||
save: () => void;
|
||||
submitForm: (url: string) => void;
|
||||
resetForm: () => void;
|
||||
},
|
||||
): void {
|
||||
// 1. Static sanitization: Reject scripts with potentially harmful or unneeded keywords.
|
||||
// This blocks most elementary exploits and prevents prototype tampering.
|
||||
const forbidden = [
|
||||
'window', 'document', 'fetch', 'xmlhttprequest', 'websocket', 'worker',
|
||||
'eval', 'settimeout', 'setinterval', 'function', 'constructor',
|
||||
'__proto__', 'prototype', 'globalthis', 'import', 'require'
|
||||
];
|
||||
|
||||
const lowerJs = js.toLowerCase();
|
||||
for (const word of forbidden) {
|
||||
if (lowerJs.includes(word)) {
|
||||
console.warn(`[PDF JS] Execution blocked: Script contains suspicious keyword "${word}".`, 'Script:', js);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mock Acrobat API
|
||||
const doOpenUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch { /* invalid URL — ignore */ }
|
||||
};
|
||||
|
||||
const app = {
|
||||
print: (_params?: unknown) => handlers.print(),
|
||||
alert: (msg: unknown) => { console.debug('[PDF JS] alert:', msg); },
|
||||
beep: () => {},
|
||||
response: () => null,
|
||||
execMenuItem: (item: string) => {
|
||||
switch (item) {
|
||||
case 'Print': handlers.print(); break;
|
||||
case 'Save': handlers.save(); break;
|
||||
case 'Close': break; // no-op in browser context
|
||||
default: console.debug('[PDF JS] execMenuItem: unhandled item:', item);
|
||||
}
|
||||
},
|
||||
// Prevent prototype walking
|
||||
__proto__: null
|
||||
};
|
||||
|
||||
const doc = {
|
||||
print: (_params?: unknown) => handlers.print(),
|
||||
save: (_params?: unknown) => handlers.save(),
|
||||
saveAs: (_params?: unknown) => handlers.save(),
|
||||
submitForm: (urlOrParams: unknown) => {
|
||||
const url =
|
||||
typeof urlOrParams === 'string'
|
||||
? urlOrParams
|
||||
: (urlOrParams as Record<string, unknown>)?.cURL as string ?? '';
|
||||
if (url) doOpenUrl(url); else handlers.submitForm(url);
|
||||
},
|
||||
resetForm: (_fields?: unknown) => handlers.resetForm(),
|
||||
getField: (_name: string) => null,
|
||||
getAnnot: () => null,
|
||||
getURL: (url: string) => doOpenUrl(url),
|
||||
numPages: 1,
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
// Stub event object — used by field calculation/validation scripts
|
||||
const event = {
|
||||
value: '',
|
||||
changeEx: '',
|
||||
change: '',
|
||||
rc: true,
|
||||
willCommit: false,
|
||||
target: null as null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Pass doc, app, event as both `this` AND named parameters so scripts that
|
||||
// reference them as free variables (not just via `this`) work correctly.
|
||||
const fn = new Function('app', 'doc', 'event', js);
|
||||
fn.call(doc, app, doc, event);
|
||||
} catch (err) {
|
||||
// Swallow errors from missing PDF APIs; log in debug mode for tracing
|
||||
console.debug('[PDF JS] Script execution error (expected for unsupported APIs):', err, '\nScript:', js.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
interface WidgetInputProps {
|
||||
field: FormField;
|
||||
@@ -31,6 +134,7 @@ interface WidgetInputProps {
|
||||
scaleY: number;
|
||||
onFocus: (fieldName: string) => void;
|
||||
onChange: (fieldName: string, value: string) => void;
|
||||
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +151,7 @@ function WidgetInputInner({
|
||||
scaleY,
|
||||
onFocus,
|
||||
onChange,
|
||||
onButtonClick,
|
||||
}: WidgetInputProps) {
|
||||
// Per-field value subscription — only this widget re-renders when its value changes
|
||||
const value = useFieldValue(field.name);
|
||||
@@ -287,10 +392,12 @@ function WidgetInputInner({
|
||||
}
|
||||
|
||||
case 'radio': {
|
||||
// Each radio widget has an exportValue set by the backend
|
||||
const optionValue = widget.exportValue || '';
|
||||
if (!optionValue) return null; // no export value, skip
|
||||
const isSelected = value === optionValue;
|
||||
// Identify this widget by its index within the field's widgets array.
|
||||
// This avoids issues with duplicate exportValues (e.g., all "Yes").
|
||||
const widgetIndex = field.widgets?.indexOf(widget) ?? -1;
|
||||
if (widgetIndex < 0) return null;
|
||||
const widgetIndexStr = String(widgetIndex);
|
||||
const isSelected = value === widgetIndexStr;
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
@@ -304,11 +411,11 @@ function WidgetInputInner({
|
||||
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
|
||||
cursor: field.readOnly ? 'default' : 'pointer',
|
||||
}}
|
||||
title={error || field.tooltip || `${field.label}: ${optionValue}`}
|
||||
title={error || field.tooltip || `${field.label}: ${widget.exportValue || widgetIndexStr}`}
|
||||
onClick={(e) => {
|
||||
if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
|
||||
if (field.readOnly || value === widgetIndexStr) return; // Don't deselect radio buttons
|
||||
handleFocus();
|
||||
onChange(field.name, optionValue);
|
||||
onChange(field.name, widgetIndexStr);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
>
|
||||
@@ -317,8 +424,8 @@ function WidgetInputInner({
|
||||
width: Math.min(width, height) * 0.8,
|
||||
height: Math.min(width, height) * 0.8,
|
||||
borderRadius: '50%',
|
||||
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
|
||||
background: isSelected ? '#2196F3' : '#FFF',
|
||||
border: `1.5px solid ${isSelected ? '#2196F3' : isActive ? '#2196F3' : '#999'}`,
|
||||
background: isSelected ? '#2196F3' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -331,44 +438,58 @@ 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}
|
||||
/>
|
||||
);
|
||||
// Signature fields are handled entirely by SignatureFieldOverlay (bitmap canvas).
|
||||
// Rendering a placeholder here creates a visible grey overlay on top of the
|
||||
// signature appearance, so we skip it entirely.
|
||||
return null;
|
||||
|
||||
case 'button': {
|
||||
// Transparent hit-target only — visual appearance is rendered by ButtonAppearanceOverlay
|
||||
// (which paints the PDF's native /AP bitmap onto a canvas behind this div).
|
||||
const buttonLabel = field.buttonLabel || field.value || field.label || 'Button';
|
||||
const isClickable = !field.readOnly;
|
||||
|
||||
let actionHint = '';
|
||||
if (field.buttonAction) {
|
||||
switch (field.buttonAction.type) {
|
||||
case 'named': actionHint = field.buttonAction.namedAction ?? ''; break;
|
||||
case 'resetForm': actionHint = 'Reset Form'; break;
|
||||
case 'submitForm': actionHint = `Submit to: ${field.buttonAction.url ?? ''}`.trim(); break;
|
||||
case 'uri': actionHint = field.buttonAction.url ?? ''; break;
|
||||
case 'javascript': actionHint = 'Script'; break;
|
||||
}
|
||||
}
|
||||
// Unsigned signature — fall through to placeholder
|
||||
// falls through
|
||||
case 'button':
|
||||
// Just render a highlighted area — not editable
|
||||
const titleText = field.tooltip || (actionHint ? `${buttonLabel} (${actionHint})` : buttonLabel);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
background: 'rgba(200,200,200,0.3)',
|
||||
border: '1px dashed #999',
|
||||
cursor: 'default',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
}}
|
||||
title={titleText}
|
||||
role="button"
|
||||
tabIndex={isClickable ? 0 : -1}
|
||||
aria-label={buttonLabel}
|
||||
onClick={(e) => {
|
||||
handleFocus();
|
||||
if (isClickable) onButtonClick(field, field.buttonAction);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onButtonClick(field, field.buttonAction);
|
||||
}
|
||||
stopPropagation(e);
|
||||
}}
|
||||
title={field.tooltip || `${field.type}: ${field.label}`}
|
||||
onClick={handleFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
@@ -407,6 +528,7 @@ export function FormFieldOverlay({
|
||||
}: FormFieldOverlayProps) {
|
||||
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
||||
const { activeFieldName, validationErrors } = state;
|
||||
const { printActions, scrollActions, exportActions } = useViewer();
|
||||
|
||||
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||
// NOTE: All hooks must be called unconditionally (before any early returns)
|
||||
@@ -416,20 +538,28 @@ export function FormFieldOverlay({
|
||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||
const s = documentState?.scale ?? 1;
|
||||
if (pageIndex === 0) {
|
||||
console.debug('[FormFieldOverlay] page 0 using fallback scale=%f (missing pdfPage.size)', s);
|
||||
}
|
||||
return { scaleX: s, scaleY: s };
|
||||
}
|
||||
|
||||
// pdfPage.size contains un-rotated (MediaBox) dimensions;
|
||||
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
|
||||
return {
|
||||
scaleX: pageWidth / pdfPage.size.width,
|
||||
scaleY: pageHeight / pdfPage.size.height,
|
||||
};
|
||||
const sx = pageWidth / pdfPage.size.width;
|
||||
const sy = pageHeight / pdfPage.size.height;
|
||||
if (pageIndex === 0) {
|
||||
console.debug(
|
||||
'[FormFieldOverlay] page 0 scale: pageW=%f pageH=%f pdfW=%f pdfH=%f → scaleX=%f scaleY=%f docScale=%f',
|
||||
pageWidth, pageHeight, pdfPage.size.width, pdfPage.size.height, sx, sy, documentState?.scale,
|
||||
);
|
||||
}
|
||||
// pdfPage.size contains un-rotated dimensions from the engine;
|
||||
// pageWidth/pageHeight from Scroller = pdfPage.size * documentScale
|
||||
return { scaleX: sx, scaleY: sy };
|
||||
}, [documentState, pageIndex, pageWidth, pageHeight]);
|
||||
|
||||
const pageFields = useMemo(
|
||||
() => fieldsByPage.get(pageIndex) || [],
|
||||
[fieldsByPage, pageIndex],
|
||||
[fieldsByPage, pageIndex]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
@@ -442,6 +572,64 @@ export function FormFieldOverlay({
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(field: FormField, action?: ButtonAction | null) => {
|
||||
const doOpenUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch { /* invalid URL */ }
|
||||
};
|
||||
const doResetForm = () => {
|
||||
for (const f of state.fields) setValue(f.name, f.value ?? '');
|
||||
};
|
||||
const doSave = () => { exportActions.saveAsCopy(); };
|
||||
|
||||
if (!action) {
|
||||
// Action extraction failed — fall back to label matching as a last resort
|
||||
const label = (field.buttonLabel || field.label || '').toLowerCase();
|
||||
if (/print/.test(label)) printActions.print();
|
||||
else if (/save|download/.test(label)) doSave();
|
||||
else if (/reset|clear/.test(label)) doResetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'named':
|
||||
switch (action.namedAction) {
|
||||
case 'Print': printActions.print(); break;
|
||||
case 'Save': doSave(); break;
|
||||
case 'NextPage': scrollActions.scrollToNextPage(); break;
|
||||
case 'PrevPage': scrollActions.scrollToPreviousPage(); break;
|
||||
case 'FirstPage': scrollActions.scrollToFirstPage(); break;
|
||||
case 'LastPage': scrollActions.scrollToLastPage(); break;
|
||||
}
|
||||
break;
|
||||
case 'resetForm':
|
||||
doResetForm();
|
||||
break;
|
||||
case 'submitForm':
|
||||
case 'uri':
|
||||
if (action.url) doOpenUrl(action.url);
|
||||
break;
|
||||
case 'javascript':
|
||||
// Execute in a sandboxed PDF JS environment instead of just logging
|
||||
if (action.javascript) {
|
||||
executePdfJs(action.javascript, {
|
||||
print: () => printActions.print(),
|
||||
save: doSave,
|
||||
submitForm: doOpenUrl,
|
||||
resetForm: doResetForm,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[printActions, scrollActions, exportActions, state.fields, setValue],
|
||||
);
|
||||
|
||||
// Guard: don't render fields from a previous document.
|
||||
// If fileId is provided and doesn't match what the context fetched for, render nothing.
|
||||
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
||||
@@ -485,6 +673,7 @@ export function FormFieldOverlay({
|
||||
scaleY={scaleY}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
onButtonClick={handleButtonClick}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -32,8 +32,9 @@ 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, fetchSignatureFieldsWithAppearances } from '@app/tools/formFill/providers/PdfLibFormProvider';
|
||||
import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||
import { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
|
||||
import { fetchSignatureFieldsWithAppearances } from '@app/services/pdfiumService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FormValuesStore — external store for field values (outside React state)
|
||||
@@ -268,7 +269,7 @@ export function useAllFormValues(): Record<string, string> {
|
||||
}
|
||||
|
||||
/** Singleton provider instances */
|
||||
const pdfLibProvider = new PdfLibFormProvider();
|
||||
const pdfiumProvider = new PdfiumFormProvider();
|
||||
const pdfBoxProvider = new PdfBoxFormProvider();
|
||||
|
||||
export function FormFillProvider({
|
||||
@@ -283,7 +284,7 @@ export function FormFillProvider({
|
||||
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
|
||||
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
|
||||
providerModeRef.current = providerMode;
|
||||
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
|
||||
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider);
|
||||
const providerRef = useRef(provider);
|
||||
providerRef.current = provider;
|
||||
|
||||
@@ -322,7 +323,7 @@ export function FormFillProvider({
|
||||
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)');
|
||||
console.debug('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,7 +331,9 @@ export function FormFillProvider({
|
||||
// (they're not fillable). Fetch them via pdflib so their appearances still render.
|
||||
if (providerModeRef.current === 'pdfbox') {
|
||||
try {
|
||||
const sigFields = await fetchSignatureFieldsWithAppearances(file);
|
||||
// Convert File/Blob to ArrayBuffer for pdfiumService
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const sigFields = await fetchSignatureFieldsWithAppearances(arrayBuffer);
|
||||
if (fetchVersionRef.current !== version) return; // stale check after async
|
||||
if (sigFields.length > 0) {
|
||||
fields = [...fields, ...sigFields];
|
||||
@@ -420,7 +423,7 @@ export function FormFillProvider({
|
||||
if (providerModeRef.current === mode) return;
|
||||
|
||||
// provider (pdfbox vs pdflib).
|
||||
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
|
||||
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider;
|
||||
providerRef.current = newProvider;
|
||||
providerModeRef.current = mode;
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@ export { FieldInput } from '@app/tools/formFill/FieldInput';
|
||||
export { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||
export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
export type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
export { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
|
||||
export { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
|
||||
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||
|
||||
@@ -1,865 +0,0 @@
|
||||
/**
|
||||
* PdfLibFormProvider — Frontend-only form data provider using pdf-lib.
|
||||
*
|
||||
* Extracts form fields directly from the PDF byte stream and fills them
|
||||
* without any backend calls. This avoids sending large PDFs (potentially
|
||||
* hundreds of MB) to the server for a feature that can be done entirely
|
||||
* on the client.
|
||||
*
|
||||
* Used in normal viewer mode when the user views a PDF with form fields.
|
||||
*
|
||||
* Coordinate system:
|
||||
* pdf-lib provides widget rectangles in PDF user space (lower-left origin).
|
||||
* We transform them to CSS space (top-left origin) matching what the backend
|
||||
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
|
||||
* for both providers.
|
||||
*/
|
||||
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
|
||||
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
|
||||
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage,
|
||||
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';
|
||||
|
||||
/**
|
||||
* Read a File/Blob as ArrayBuffer.
|
||||
*/
|
||||
async function readAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page index for a widget annotation by finding which page contains it.
|
||||
*/
|
||||
function getWidgetPageIndex(
|
||||
widget: PDFDict,
|
||||
pages: PDFPage[],
|
||||
): number {
|
||||
// Check /P entry first (direct page reference)
|
||||
const pRef = widget.get(PDFName.of('P'));
|
||||
if (pRef instanceof PDFRef) {
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (pages[i].ref === pRef) return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to scanning each page's /Annots array
|
||||
const widgetRef = findWidgetRef(widget, pages);
|
||||
if (widgetRef !== undefined) return widgetRef;
|
||||
|
||||
return 0; // default to first page
|
||||
}
|
||||
|
||||
function findWidgetRef(widget: PDFDict, pages: PDFPage[]): number | undefined {
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const annots = pages[i].node.lookup(PDFName.of('Annots'));
|
||||
if (annots instanceof PDFArray) {
|
||||
for (let j = 0; j < annots.size(); j++) {
|
||||
const annotRef = annots.get(j);
|
||||
const annotDict = annots.lookup(j);
|
||||
if (annotDict === widget || annotRef === (widget as any).ref) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page rotation in degrees (0, 90, 180, 270).
|
||||
*/
|
||||
function getPageRotation(page: PDFPage): number {
|
||||
const rot = page.getRotation();
|
||||
return rot?.angle ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract widget rectangles from a PDFField, transforming from PDF space
|
||||
* (lower-left origin) to CSS space (top-left origin).
|
||||
*
|
||||
* Widget /Rect coordinates are always in un-rotated PDF user space
|
||||
* (defined by the MediaBox/CropBox). We only need a y-flip to convert
|
||||
* from PDF's lower-left origin to CSS's upper-left origin.
|
||||
*
|
||||
* The embedpdf viewer wraps all page content (including this overlay)
|
||||
* inside a <Rotate> CSS component that handles visual rotation.
|
||||
* Therefore we must NOT apply any rotation here — doing so would
|
||||
* double-rotate the widgets.
|
||||
*/
|
||||
function extractWidgets(
|
||||
field: PDFField,
|
||||
pages: PDFPage[],
|
||||
_doc: PDFDocument,
|
||||
): WidgetCoordinates[] {
|
||||
const widgets: WidgetCoordinates[] = [];
|
||||
// Access the underlying PDFDict from the acro field
|
||||
const acroFieldDict = (field.acroField as any).dict as PDFDict;
|
||||
|
||||
// Get all widget annotations for this field
|
||||
const widgetDicts = getFieldWidgets(acroFieldDict);
|
||||
|
||||
for (const wDict of widgetDicts) {
|
||||
const rect = wDict.lookup(PDFName.of('Rect'));
|
||||
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
|
||||
|
||||
const x1 = numberVal(rect.lookup(0));
|
||||
const y1 = numberVal(rect.lookup(1));
|
||||
const x2 = numberVal(rect.lookup(2));
|
||||
const y2 = numberVal(rect.lookup(3));
|
||||
|
||||
const pageIndex = getWidgetPageIndex(wDict, pages);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) continue;
|
||||
|
||||
// Get CropBox dimensions (un-rotated) for coordinate transformation
|
||||
const cropBox = getCropBox(page);
|
||||
const cropHeight = cropBox.height;
|
||||
const cropX = cropBox.x;
|
||||
const cropY = cropBox.y;
|
||||
|
||||
// Widget rect in PDF space (lower-left origin, un-rotated)
|
||||
const pdfX = Math.min(x1, x2);
|
||||
const pdfY = Math.min(y1, y2);
|
||||
const pdfW = Math.abs(x2 - x1);
|
||||
const pdfH = Math.abs(y2 - y1);
|
||||
|
||||
// Adjust relative to CropBox origin
|
||||
const relativeX = pdfX - cropX;
|
||||
const relativeY = pdfY - cropY;
|
||||
|
||||
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||
// No rotation transform here — the <Rotate> CSS component in the viewer
|
||||
// handles page rotation for all overlays including form fields.
|
||||
const finalX = relativeX;
|
||||
const finalY = cropHeight - relativeY - pdfH;
|
||||
const finalW = pdfW;
|
||||
const finalH = pdfH;
|
||||
|
||||
// Extract export value for checkboxes/radios
|
||||
let exportValue: string | undefined;
|
||||
const ap = wDict.lookup(PDFName.of('AP'));
|
||||
if (ap instanceof PDFDict) {
|
||||
const normal = ap.lookup(PDFName.of('N'));
|
||||
if (normal instanceof PDFDict) {
|
||||
// The keys of /N (other than /Off) are the export values.
|
||||
// PDFDict.entries() reliably returns [PDFName, PDFObject][] in
|
||||
// @cantoo/pdf-lib — no optional chaining needed.
|
||||
try {
|
||||
const entries = normal.entries();
|
||||
const keys = entries
|
||||
.map(([k]) => k.decodeText())
|
||||
.filter((k) => k !== 'Off');
|
||||
if (keys.length > 0) exportValue = keys[0];
|
||||
} catch {
|
||||
// Malformed AP dict — skip export value extraction
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check /AS for current appearance state
|
||||
if (!exportValue) {
|
||||
const asEntry = wDict.lookup(PDFName.of('AS'));
|
||||
if (asEntry instanceof PDFName) {
|
||||
const asVal = asEntry.decodeText();
|
||||
if (asVal !== 'Off') exportValue = asVal;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract font size from default appearance string
|
||||
let fontSize: number | undefined;
|
||||
const da = wDict.lookup(PDFName.of('DA'));
|
||||
if (da) {
|
||||
const daStr = da.toString();
|
||||
const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/);
|
||||
if (tfMatch) {
|
||||
fontSize = parseFloat(tfMatch[1]);
|
||||
if (fontSize === 0) fontSize = undefined; // 0 means auto-size
|
||||
}
|
||||
}
|
||||
|
||||
widgets.push({
|
||||
pageIndex,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalW,
|
||||
height: finalH,
|
||||
exportValue,
|
||||
fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function numberVal(obj: any): number {
|
||||
if (obj instanceof PDFNumber) return obj.asNumber();
|
||||
if (typeof obj === 'number') return obj;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CropBox (or MediaBox fallback) dimensions in un-rotated PDF space.
|
||||
* These are the raw dictionary values without any rotation adjustment.
|
||||
*/
|
||||
function getCropBox(page: PDFPage): { x: number; y: number; width: number; height: number } {
|
||||
// Check direct CropBox entry
|
||||
const cropBox = page.node.lookup(PDFName.of('CropBox'));
|
||||
if (cropBox instanceof PDFArray && cropBox.size() >= 4) {
|
||||
return {
|
||||
x: numberVal(cropBox.lookup(0)),
|
||||
y: numberVal(cropBox.lookup(1)),
|
||||
width: numberVal(cropBox.lookup(2)) - numberVal(cropBox.lookup(0)),
|
||||
height: numberVal(cropBox.lookup(3)) - numberVal(cropBox.lookup(1)),
|
||||
};
|
||||
}
|
||||
// Check direct MediaBox entry
|
||||
const mediaBox = page.node.lookup(PDFName.of('MediaBox'));
|
||||
if (mediaBox instanceof PDFArray && mediaBox.size() >= 4) {
|
||||
return {
|
||||
x: numberVal(mediaBox.lookup(0)),
|
||||
y: numberVal(mediaBox.lookup(1)),
|
||||
width: numberVal(mediaBox.lookup(2)) - numberVal(mediaBox.lookup(0)),
|
||||
height: numberVal(mediaBox.lookup(3)) - numberVal(mediaBox.lookup(1)),
|
||||
};
|
||||
}
|
||||
// Traverse parent page-tree nodes for inherited MediaBox
|
||||
let node: any = page.node;
|
||||
while (node) {
|
||||
const parentNode = node.lookup(PDFName.of('Parent'));
|
||||
if (parentNode instanceof PDFDict) {
|
||||
const inheritedBox = parentNode.lookup(PDFName.of('MediaBox'));
|
||||
if (inheritedBox instanceof PDFArray && inheritedBox.size() >= 4) {
|
||||
return {
|
||||
x: numberVal(inheritedBox.lookup(0)),
|
||||
y: numberVal(inheritedBox.lookup(1)),
|
||||
width: numberVal(inheritedBox.lookup(2)) - numberVal(inheritedBox.lookup(0)),
|
||||
height: numberVal(inheritedBox.lookup(3)) - numberVal(inheritedBox.lookup(1)),
|
||||
};
|
||||
}
|
||||
node = parentNode;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Last resort: use page.getSize() but un-rotate the dimensions
|
||||
const { width, height } = page.getSize();
|
||||
const rotation = getPageRotation(page);
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
return { x: 0, y: 0, width: height, height: width };
|
||||
}
|
||||
return { x: 0, y: 0, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget annotation dictionaries for a field.
|
||||
* A field can either BE a widget (merged) or have child /Kids that are widgets.
|
||||
*/
|
||||
function getFieldWidgets(acroField: PDFDict): PDFDict[] {
|
||||
const kids = acroField.lookup(PDFName.of('Kids'));
|
||||
if (kids instanceof PDFArray) {
|
||||
const result: PDFDict[] = [];
|
||||
for (let i = 0; i < kids.size(); i++) {
|
||||
const kid = kids.lookup(i);
|
||||
if (kid instanceof PDFDict) {
|
||||
// Check if this kid is a widget (has /Rect) vs another field node
|
||||
const subtype = kid.lookup(PDFName.of('Subtype'));
|
||||
if (subtype instanceof PDFName && subtype.decodeText() === 'Widget') {
|
||||
result.push(kid);
|
||||
} else if (kid.lookup(PDFName.of('Rect'))) {
|
||||
// Merged field/widget — has Rect but maybe no explicit Subtype
|
||||
result.push(kid);
|
||||
} else {
|
||||
// Intermediate field node — recurse
|
||||
result.push(...getFieldWidgets(kid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// No Kids — the field dict itself is the widget (merged field/widget)
|
||||
if (acroField.lookup(PDFName.of('Rect'))) {
|
||||
return [acroField];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the FormFieldType from a pdf-lib PDFField.
|
||||
*/
|
||||
function getFieldType(field: PDFField): FormFieldType {
|
||||
if (field instanceof PDFTextField) return 'text';
|
||||
if (field instanceof PDFCheckBox) return 'checkbox';
|
||||
if (field instanceof PDFDropdown) return 'combobox';
|
||||
if (field instanceof PDFRadioGroup) return 'radio';
|
||||
if (field instanceof PDFOptionList) return 'listbox';
|
||||
if (field instanceof PDFButton) return 'button';
|
||||
if (field instanceof PDFSignature) return 'signature';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of a field as a string.
|
||||
*/
|
||||
function getFieldValue(field: PDFField): string {
|
||||
try {
|
||||
if (field instanceof PDFTextField) {
|
||||
return field.getText() ?? '';
|
||||
}
|
||||
if (field instanceof PDFCheckBox) {
|
||||
return field.isChecked() ? 'Yes' : 'Off';
|
||||
}
|
||||
if (field instanceof PDFDropdown) {
|
||||
const selected = field.getSelected();
|
||||
return selected.length > 0 ? selected[0] : '';
|
||||
}
|
||||
if (field instanceof PDFRadioGroup) {
|
||||
return getRadioValue(field);
|
||||
}
|
||||
if (field instanceof PDFOptionList) {
|
||||
const selected = field.getSelected();
|
||||
return selected.join(',');
|
||||
}
|
||||
} catch {
|
||||
// Some fields may throw on getValue if malformed
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRadioValue(field: PDFRadioGroup): string {
|
||||
const selected = field.getSelected() ?? '';
|
||||
if (!selected || selected === 'Off') return selected;
|
||||
|
||||
const options = field.getOptions();
|
||||
|
||||
if (options.includes(selected)) return selected;
|
||||
|
||||
const mappedOption = mapAppearanceStateToOption(field, selected, options);
|
||||
if (mappedOption) return mappedOption;
|
||||
|
||||
const index = parseInt(selected, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < options.length) {
|
||||
return options[index];
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
function mapAppearanceStateToOption(
|
||||
field: PDFRadioGroup,
|
||||
stateName: string,
|
||||
options: string[],
|
||||
): string | undefined {
|
||||
try {
|
||||
const acroFieldDict = (field.acroField as any).dict as PDFDict;
|
||||
const widgets = getFieldWidgets(acroFieldDict);
|
||||
|
||||
for (let i = 0; i < widgets.length; i++) {
|
||||
const ap = widgets[i].lookup(PDFName.of('AP'));
|
||||
if (!(ap instanceof PDFDict)) continue;
|
||||
|
||||
const normal = ap.lookup(PDFName.of('N'));
|
||||
if (!(normal instanceof PDFDict)) continue;
|
||||
|
||||
let keys: string[] = [];
|
||||
try {
|
||||
keys = normal.entries().map(([k]) => k.decodeText());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (keys.includes(stateName) && i < options.length) {
|
||||
return options[i];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback handled by caller
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRadioValueForSelect(
|
||||
field: PDFRadioGroup,
|
||||
value: string,
|
||||
): string | null {
|
||||
const options = field.getOptions();
|
||||
|
||||
if (options.includes(value)) return value;
|
||||
|
||||
const mappedOption = mapAppearanceStateToOption(field, value, options);
|
||||
if (mappedOption) return mappedOption;
|
||||
|
||||
const index = parseInt(value, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < options.length) {
|
||||
return options[index];
|
||||
}
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
const match = options.find((o: string) => o.toLowerCase() === lower);
|
||||
if (match) return match;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field options (for dropdowns, listboxes, radios).
|
||||
*/
|
||||
function getFieldOptions(field: PDFField): string[] | null {
|
||||
try {
|
||||
if (field instanceof PDFDropdown) {
|
||||
return field.getOptions();
|
||||
}
|
||||
if (field instanceof PDFOptionList) {
|
||||
return field.getOptions();
|
||||
}
|
||||
if (field instanceof PDFRadioGroup) {
|
||||
return field.getOptions();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract display labels from the /Opt array if it contains [export, display]
|
||||
* pairs. PDF spec §12.7.4.4: each element of /Opt may be either a text
|
||||
* string (export value == display value) or a two-element array where the
|
||||
* first element is the export value and the second is the display text.
|
||||
*
|
||||
* Returns null when every display value equals its export value (no distinct
|
||||
* display labels exist), keeping the interface lean for the common case.
|
||||
*/
|
||||
function getFieldDisplayOptions(field: PDFField): string[] | null {
|
||||
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const acroDict = (field.acroField as any).dict as PDFDict;
|
||||
const optRaw = acroDict.lookup(PDFName.of('Opt'));
|
||||
if (!(optRaw instanceof PDFArray)) return null;
|
||||
|
||||
const displays: string[] = [];
|
||||
let hasDifference = false;
|
||||
|
||||
for (let i = 0; i < optRaw.size(); i++) {
|
||||
try {
|
||||
const entry = optRaw.lookup(i);
|
||||
|
||||
if (entry instanceof PDFArray && entry.size() >= 2) {
|
||||
// [exportValue, displayValue] pair
|
||||
const exp = decodeText(entry.lookup(0));
|
||||
const disp = decodeText(entry.lookup(1));
|
||||
displays.push(disp);
|
||||
if (exp !== disp) hasDifference = true;
|
||||
} else {
|
||||
// Plain string — export and display are the same
|
||||
const val = decodeText(entry);
|
||||
displays.push(val);
|
||||
}
|
||||
} catch {
|
||||
// Malformed /Opt entry — skip but continue processing remaining entries
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (displays.length === 0) return null;
|
||||
return hasDifference ? displays : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a PDFString, PDFHexString, or PDFName to a JS string. */
|
||||
function decodeText(obj: unknown): string {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString) {
|
||||
return obj.decodeText();
|
||||
}
|
||||
if (obj instanceof PDFName) {
|
||||
return obj.decodeText();
|
||||
}
|
||||
if (typeof obj === 'string') return obj;
|
||||
return String(obj ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field is read-only.
|
||||
*/
|
||||
function isFieldReadOnly(field: PDFField): boolean {
|
||||
try {
|
||||
return field.isReadOnly();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field is required.
|
||||
*/
|
||||
function isFieldRequired(field: PDFField): boolean {
|
||||
try {
|
||||
return field.isRequired();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field tooltip (TU entry).
|
||||
* Uses proper PDFString/PDFHexString decoding for correct Unicode support.
|
||||
*/
|
||||
function getFieldTooltip(acroField: PDFDict): string | null {
|
||||
const tu = acroField.lookup(PDFName.of('TU'));
|
||||
if (!tu) return null;
|
||||
|
||||
try {
|
||||
// Prefer decodeText() for proper Unicode handling (UTF-16BE / PDFDocEncoding)
|
||||
if (tu instanceof PDFString || tu instanceof PDFHexString) {
|
||||
return tu.decodeText();
|
||||
}
|
||||
// Fallback: strip parentheses from raw toString() for other object types
|
||||
return tu.toString().replace(/^\(|\)$/g, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text field is multiline (flag bit 13 set in /Ff).
|
||||
*/
|
||||
function isMultiline(field: PDFField): boolean {
|
||||
if (!(field instanceof PDFTextField)) return false;
|
||||
try {
|
||||
return field.isMultiline();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label for a field — use the partial name or the full qualified name.
|
||||
*/
|
||||
function getFieldLabel(field: PDFField): string {
|
||||
const name = field.getName();
|
||||
// Use the last segment of the qualified name as the label
|
||||
const parts = name.split('.');
|
||||
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';
|
||||
|
||||
async fetchFields(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 (loadError) {
|
||||
console.warn('[PdfLibFormProvider] Failed to load PDF document:', loadError);
|
||||
return [];
|
||||
}
|
||||
|
||||
let form: PDFForm;
|
||||
try {
|
||||
form = doc.getForm();
|
||||
} catch (formError) {
|
||||
// No AcroForm or broken catalog — return empty
|
||||
console.warn('[PdfLibFormProvider] Failed to access AcroForm:', formError);
|
||||
return [];
|
||||
}
|
||||
|
||||
let fields: PDFField[];
|
||||
try {
|
||||
fields = form.getFields();
|
||||
} catch (fieldsError) {
|
||||
console.warn('[PdfLibFormProvider] Failed to enumerate form fields:', fieldsError);
|
||||
return [];
|
||||
}
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
let pages: PDFPage[];
|
||||
try {
|
||||
pages = doc.getPages();
|
||||
} catch (pagesError) {
|
||||
// Pages tree is invalid (same issue as usePdfLibLinks "invalid catalog").
|
||||
// Without page references we can't place widgets, so return empty.
|
||||
// The viewer will fall back to native form rendering via withForms.
|
||||
console.warn(
|
||||
'[PdfLibFormProvider] PDF pages tree is invalid — cannot place form widgets.',
|
||||
'Native form rendering will be used as fallback.',
|
||||
pagesError,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: FormField[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldName = field.getName();
|
||||
try {
|
||||
const type = getFieldType(field);
|
||||
const widgets = extractWidgets(field, pages, doc);
|
||||
|
||||
// Skip fields with no visible widgets
|
||||
if (widgets.length === 0) continue;
|
||||
|
||||
const formField: FormField = {
|
||||
name: field.getName(),
|
||||
label: getFieldLabel(field),
|
||||
type,
|
||||
value: getFieldValue(field),
|
||||
options: getFieldOptions(field),
|
||||
displayOptions: getFieldDisplayOptions(field),
|
||||
required: isFieldRequired(field),
|
||||
readOnly: isFieldReadOnly(field),
|
||||
multiSelect: field instanceof PDFOptionList,
|
||||
multiline: isMultiline(field),
|
||||
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
|
||||
widgets,
|
||||
};
|
||||
|
||||
result.push(formField);
|
||||
} catch (fieldError) {
|
||||
// Skip individual malformed fields but continue processing
|
||||
console.warn(`[PdfLibFormProvider] Skipping field "${fieldName}":`, fieldError);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
async fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await readAsArrayBuffer(file);
|
||||
const doc = await PDFDocument.load(arrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
const form = doc.getForm();
|
||||
const fields = form.getFields();
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldName = field.getName();
|
||||
if (!(fieldName in values)) continue;
|
||||
|
||||
const value = values[fieldName];
|
||||
|
||||
try {
|
||||
if (field instanceof PDFTextField) {
|
||||
field.setText(value || undefined);
|
||||
} else if (field instanceof PDFCheckBox) {
|
||||
if (value && value !== 'Off') {
|
||||
field.check();
|
||||
} else {
|
||||
field.uncheck();
|
||||
}
|
||||
} else if (field instanceof PDFDropdown) {
|
||||
if (value) {
|
||||
field.select(value);
|
||||
} else {
|
||||
field.clear();
|
||||
}
|
||||
} else if (field instanceof PDFRadioGroup) {
|
||||
if (value && value !== 'Off') {
|
||||
const resolved = resolveRadioValueForSelect(field, value);
|
||||
if (resolved) {
|
||||
field.select(resolved);
|
||||
} else {
|
||||
console.warn(
|
||||
`[PdfLibFormProvider] Radio value "${value}" could not be mapped to options [${field.getOptions().join(', ')}] for field "${fieldName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (field instanceof PDFOptionList) {
|
||||
if (value) {
|
||||
const vals = value.split(',').filter(Boolean);
|
||||
field.select(vals[0]); // PDFOptionList.select takes single value
|
||||
} else {
|
||||
field.clear();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[PdfLibFormProvider] Failed to set value for field "${fieldName}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (flatten) {
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
return new Blob([pdfBytes.slice().buffer as ArrayBuffer], { type: 'application/pdf' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* PdfiumFormProvider Frontend-only form data provider using PDFium WASM.
|
||||
*
|
||||
* Replaces the old pdf-lib based PdfLibFormProvider. Extracts form fields
|
||||
* directly from the PDF byte stream via @embedpdf/pdfium WASM and fills
|
||||
* them without any backend calls.
|
||||
*
|
||||
* Used in normal viewer mode when the user views a PDF with form fields.
|
||||
*
|
||||
* Coordinate system:
|
||||
* PDFium provides widget rectangles in PDF user space (lower-left origin).
|
||||
* We transform them to CSS space (top-left origin) matching what the backend
|
||||
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
|
||||
* for both providers.
|
||||
*/
|
||||
import { PDF_FORM_FIELD_TYPE } from '@app/services/pdfiumService';
|
||||
import { FPDF_ANNOT_WIDGET, FLAT_PRINT } from '@app/utils/pdfiumBitmapUtils';
|
||||
import type { FormField, FormFieldType, WidgetCoordinates, ButtonAction } from '@app/tools/formFill/types';
|
||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
import {
|
||||
closeDocAndFreeBuffer,
|
||||
extractFormFields,
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
readUtf16,
|
||||
saveRawDocument,
|
||||
type PdfiumFormField,
|
||||
} from '@app/services/pdfiumService';
|
||||
|
||||
/**
|
||||
* Map PDFium form field type enum to our FormFieldType string.
|
||||
*/
|
||||
function mapFieldType(t: PDF_FORM_FIELD_TYPE): FormFieldType {
|
||||
switch (t) {
|
||||
case PDF_FORM_FIELD_TYPE.TEXTFIELD:
|
||||
return 'text';
|
||||
case PDF_FORM_FIELD_TYPE.CHECKBOX:
|
||||
return 'checkbox';
|
||||
case PDF_FORM_FIELD_TYPE.COMBOBOX:
|
||||
return 'combobox';
|
||||
case PDF_FORM_FIELD_TYPE.RADIOBUTTON:
|
||||
return 'radio';
|
||||
case PDF_FORM_FIELD_TYPE.LISTBOX:
|
||||
return 'listbox';
|
||||
case PDF_FORM_FIELD_TYPE.PUSHBUTTON:
|
||||
return 'button';
|
||||
case PDF_FORM_FIELD_TYPE.SIGNATURE:
|
||||
return 'signature';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PdfiumFormField (from pdfiumService) to the UI FormField shape.
|
||||
* @param optInfo When provided, overrides options/displayOptions for combo/listbox fields.
|
||||
* @param buttonInfo When provided, sets buttonLabel and buttonAction for push buttons.
|
||||
*/
|
||||
function toFormField(
|
||||
f: PdfiumFormField & { _tooltip?: string | null },
|
||||
optInfo?: { exportValues: string[]; displayValues: string[] } | null,
|
||||
buttonInfo?: { label?: string; action?: ButtonAction } | null,
|
||||
): FormField {
|
||||
const type = mapFieldType(f.type);
|
||||
const optionLabels = f.options.map((o) => o.label);
|
||||
|
||||
// Build WidgetCoordinates from the PDFium widget rects
|
||||
const widgets: WidgetCoordinates[] = f.widgets.map((w) => ({
|
||||
pageIndex: w.pageIndex,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
exportValue: w.exportValue,
|
||||
fontSize: w.fontSize,
|
||||
}));
|
||||
|
||||
// Derive value string
|
||||
let value = f.value;
|
||||
if (type === 'checkbox') {
|
||||
value = f.isChecked ? 'Yes' : 'Off';
|
||||
} else if (type === 'radio') {
|
||||
// Use widget index as the canonical radio value.
|
||||
// This avoids issues with duplicate exportValues across widgets
|
||||
// (e.g., all widgets having exportValue "Yes").
|
||||
value = '';
|
||||
for (let i = 0; i < f.widgets.length; i++) {
|
||||
if (f.widgets[i].isChecked) {
|
||||
value = String(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use pdf-lib extracted export/display values when available
|
||||
let options: string[] | null = optionLabels.length > 0 ? optionLabels : null;
|
||||
let displayOptions: string[] | null = null;
|
||||
if (optInfo && optInfo.exportValues.length > 0) {
|
||||
options = optInfo.exportValues;
|
||||
displayOptions = optInfo.displayValues;
|
||||
}
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
label: f.name.split('.').pop() || f.name,
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
displayOptions,
|
||||
required: f.isRequired,
|
||||
readOnly: f.isReadOnly,
|
||||
multiSelect: f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
|
||||
multiline: type === 'text' && (f.flags & 0x1000) !== 0, // bit 13 = Multiline
|
||||
tooltip: f._tooltip ?? null,
|
||||
widgets,
|
||||
buttonLabel: buttonInfo?.label ?? null,
|
||||
buttonAction: buttonInfo?.action ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PdfLibFormProvider — now backed by PDFium WASM.
|
||||
*
|
||||
* The class name is kept for backwards-compatibility with existing imports.
|
||||
* Internally everything goes through @embedpdf/pdfium.
|
||||
*/
|
||||
export class PdfiumFormProvider implements IFormDataProvider {
|
||||
/** Provider identifier — kept as 'pdf-lib' for backwards-compatibility. */
|
||||
readonly name = 'pdf-lib';
|
||||
|
||||
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfiumFields = await extractFormFields(arrayBuffer);
|
||||
|
||||
// Enrich with alternate names (tooltips)
|
||||
await this.enrichWithAlternateNames(arrayBuffer, pdfiumFields);
|
||||
|
||||
// Enrich combo/listbox fields with export/display values from pdf-lib
|
||||
const optMap = await this.extractDisplayOptions(arrayBuffer, pdfiumFields);
|
||||
|
||||
// Enrich push buttons with label (/MK/CA) and action (/A) from pdf-lib
|
||||
const buttonInfoMap = await this.extractButtonInfo(arrayBuffer, pdfiumFields);
|
||||
|
||||
return pdfiumFields
|
||||
.filter((f) => f.widgets.length > 0)
|
||||
.map((f) => toFormField(f, optMap.get(f.name) ?? null, buttonInfoMap.get(f.name) ?? null));
|
||||
} catch (err) {
|
||||
console.warn('[PdfiumFormProvider] Failed to extract form fields:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich fields with alternate names (tooltip / TU entry) via PDFium.
|
||||
*/
|
||||
private async enrichWithAlternateNames(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data);
|
||||
try {
|
||||
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
|
||||
if (!formEnvPtr) return;
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
const nameToField = new Map(fields.map((f) => [f.name, f]));
|
||||
const enriched = new Set<string>();
|
||||
|
||||
for (let pageIdx = 0; pageIdx < pageCount && enriched.size < nameToField.size; pageIdx++) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
if (!pagePtr) continue;
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (let ai = 0; ai < annotCount && enriched.size < nameToField.size; ai++) {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
|
||||
if (!annotPtr) continue;
|
||||
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let name = '';
|
||||
if (nl > 0) {
|
||||
const nb = m.pdfium.wasmExports.malloc(nl);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
|
||||
name = readUtf16(m, nb, nl);
|
||||
m.pdfium.wasmExports.free(nb);
|
||||
}
|
||||
|
||||
if (name && nameToField.has(name) && !enriched.has(name)) {
|
||||
const altLen = m.FPDFAnnot_GetFormFieldAlternateName(formEnvPtr, annotPtr, 0, 0);
|
||||
if (altLen > 0) {
|
||||
const altBuf = m.pdfium.wasmExports.malloc(altLen);
|
||||
m.FPDFAnnot_GetFormFieldAlternateName(formEnvPtr, annotPtr, altBuf, altLen);
|
||||
const altName = readUtf16(m, altBuf, altLen);
|
||||
m.pdfium.wasmExports.free(altBuf);
|
||||
(nameToField.get(name) as any)._tooltip = altName || null;
|
||||
}
|
||||
enriched.add(name);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfiumFormProvider] Failed to enrich alternate names:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pdf-lib to read /Opt arrays for combo/listbox fields.
|
||||
* Returns a map of fieldName → { exportValues, displayValues }.
|
||||
* PDFium only exposes display labels; pdf-lib can read the raw /Opt entries
|
||||
* to separate [export, display] pairs.
|
||||
*/
|
||||
private async extractDisplayOptions(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<Map<string, { exportValues: string[]; displayValues: string[] }>> {
|
||||
const result = new Map<string, { exportValues: string[]; displayValues: string[] }>();
|
||||
const comboOrList = fields.filter(
|
||||
(f) => f.type === PDF_FORM_FIELD_TYPE.COMBOBOX || f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
|
||||
);
|
||||
if (comboOrList.length === 0) return result;
|
||||
|
||||
try {
|
||||
const { PDFDocument, PDFName, PDFArray, PDFString, PDFHexString, PDFDropdown, PDFOptionList } =
|
||||
await import('@cantoo/pdf-lib');
|
||||
const doc = await PDFDocument.load(data, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
const form = doc.getForm();
|
||||
|
||||
const decodeText = (obj: unknown): string => {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
|
||||
return String(obj ?? '');
|
||||
};
|
||||
|
||||
for (const pf of comboOrList) {
|
||||
try {
|
||||
const field = form.getField(pf.name);
|
||||
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) continue;
|
||||
|
||||
const acroDict = (field.acroField as any).dict;
|
||||
const optRaw = acroDict.lookup(PDFName.of('Opt'));
|
||||
if (!(optRaw instanceof PDFArray)) continue;
|
||||
|
||||
const exportValues: string[] = [];
|
||||
const displayValues: string[] = [];
|
||||
let hasDifference = false;
|
||||
|
||||
for (let i = 0; i < optRaw.size(); i++) {
|
||||
try {
|
||||
const entry = optRaw.lookup(i);
|
||||
if (entry instanceof PDFArray && entry.size() >= 2) {
|
||||
const exp = decodeText(entry.lookup(0));
|
||||
const disp = decodeText(entry.lookup(1));
|
||||
exportValues.push(exp);
|
||||
displayValues.push(disp);
|
||||
if (exp !== disp) hasDifference = true;
|
||||
} else {
|
||||
const val = decodeText(entry);
|
||||
exportValues.push(val);
|
||||
displayValues.push(val);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (exportValues.length > 0) {
|
||||
result.set(pf.name, {
|
||||
exportValues,
|
||||
displayValues: hasDifference ? displayValues : exportValues,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip individual field errors
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfiumFormProvider] Failed to extract display options:', e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pdf-lib to extract push button labels (/MK/CA) and actions (/A) for each button field.
|
||||
* Returns a map of fieldName → { label?, action? }.
|
||||
*/
|
||||
private async extractButtonInfo(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<Map<string, { label?: string; action?: ButtonAction }>> {
|
||||
const result = new Map<string, { label?: string; action?: ButtonAction }>();
|
||||
const buttons = fields.filter((f) => f.type === PDF_FORM_FIELD_TYPE.PUSHBUTTON);
|
||||
if (buttons.length === 0) return result;
|
||||
|
||||
try {
|
||||
const { PDFDocument, PDFName, PDFString, PDFHexString, PDFDict } =
|
||||
await import('@cantoo/pdf-lib');
|
||||
|
||||
const doc = await PDFDocument.load(data, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
const form = doc.getForm();
|
||||
|
||||
const decodeText = (obj: unknown): string | null => {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
|
||||
if (obj instanceof PDFName) return (obj as any).asString?.() ?? obj.toString().replace(/^\//, '');
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseActionDict = (aObj: unknown): ButtonAction | null => {
|
||||
if (!(aObj instanceof PDFDict)) return null;
|
||||
const sObj = aObj.lookup(PDFName.of('S'));
|
||||
if (!(sObj instanceof PDFName)) return null;
|
||||
const actionType: string = (sObj as any).asString?.() ?? sObj.toString().replace(/^\//, '');
|
||||
|
||||
switch (actionType) {
|
||||
case 'Named': {
|
||||
const nObj = aObj.lookup(PDFName.of('N'));
|
||||
const name = nObj instanceof PDFName
|
||||
? ((nObj as any).asString?.() ?? nObj.toString().replace(/^\//, ''))
|
||||
: '';
|
||||
return { type: 'named', namedAction: name };
|
||||
}
|
||||
case 'JavaScript': {
|
||||
const jsObj = aObj.lookup(PDFName.of('JS'));
|
||||
const js = decodeText(jsObj) ?? jsObj?.toString() ?? '';
|
||||
return { type: 'javascript', javascript: js };
|
||||
}
|
||||
case 'SubmitForm': {
|
||||
const fObj = aObj.lookup(PDFName.of('F'));
|
||||
let url = '';
|
||||
if (fObj instanceof PDFDict) {
|
||||
url = decodeText(fObj.lookup(PDFName.of('F'))) ?? '';
|
||||
} else if (fObj) {
|
||||
url = decodeText(fObj) ?? fObj.toString();
|
||||
}
|
||||
const flagsObj = aObj.lookup(PDFName.of('Flags'));
|
||||
const flags = typeof (flagsObj as any)?.asNumber === 'function' ? (flagsObj as any).asNumber() : 0;
|
||||
return { type: 'submitForm', url, submitFlags: flags };
|
||||
}
|
||||
case 'ResetForm':
|
||||
return { type: 'resetForm' };
|
||||
case 'URI': {
|
||||
const uriObj = aObj.lookup(PDFName.of('URI'));
|
||||
return { type: 'uri', url: decodeText(uriObj) ?? '' };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMkCaption = (dict: any): string | null => {
|
||||
try {
|
||||
const mkObj = dict.lookup(PDFName.of('MK'));
|
||||
if (!(mkObj instanceof PDFDict)) return null;
|
||||
const caObj = mkObj.lookup(PDFName.of('CA'));
|
||||
return decodeText(caObj);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionFromDict = (dict: any): ButtonAction | null => {
|
||||
try {
|
||||
return parseActionDict(dict.lookup(PDFName.of('A')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buttonNames = new Set(buttons.map((b) => b.name));
|
||||
|
||||
for (const field of form.getFields()) {
|
||||
const name = field.getName();
|
||||
if (!buttonNames.has(name)) continue;
|
||||
|
||||
try {
|
||||
const acroField = (field as any).acroField;
|
||||
if (!acroField?.dict) continue;
|
||||
|
||||
const info: { label?: string; action?: ButtonAction } = {};
|
||||
|
||||
// Try widget dicts first (each widget can have its own /MK and /A)
|
||||
const widgets: any[] = (acroField as any).getWidgets?.() ?? [];
|
||||
for (const widget of widgets) {
|
||||
if (!info.label) {
|
||||
const label = getMkCaption(widget.dict);
|
||||
if (label) info.label = label;
|
||||
}
|
||||
if (!info.action) {
|
||||
const action = getActionFromDict(widget.dict);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
if (info.label && info.action) break;
|
||||
}
|
||||
|
||||
// Fall back to field-level dict
|
||||
if (!info.label) {
|
||||
const label = getMkCaption(acroField.dict);
|
||||
if (label) info.label = label;
|
||||
}
|
||||
if (!info.action) {
|
||||
const action = getActionFromDict(acroField.dict);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
|
||||
// Also check /AA (Additional Actions) → /U (Mouse Up) if no /A found
|
||||
if (!info.action) {
|
||||
try {
|
||||
const aaObj = acroField.dict.lookup(PDFName.of('AA'));
|
||||
if (aaObj instanceof PDFDict) {
|
||||
const uObj = aaObj.lookup(PDFName.of('U'));
|
||||
const action = parseActionDict(uObj);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
if (info.label || info.action) {
|
||||
result.set(name, info);
|
||||
}
|
||||
} catch { /* skip individual field errors */ }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfiumFormProvider] Failed to extract button info:', e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(arrayBuffer);
|
||||
|
||||
try {
|
||||
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
|
||||
if (!formEnvPtr) {
|
||||
throw new Error('PDFium: failed to initialise form environment');
|
||||
}
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
|
||||
// Track radio widget index per field for index-based matching.
|
||||
// The UI stores radio values as widget indices (e.g., "0", "1", "2").
|
||||
const radioWidgetIdx = new Map<string, number>();
|
||||
|
||||
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
if (!pagePtr) continue;
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (let ai = 0; ai < annotCount; ai++) {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
|
||||
if (!annotPtr) continue;
|
||||
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let fieldName = '';
|
||||
if (nl > 0) {
|
||||
const nb = m.pdfium.wasmExports.malloc(nl);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
|
||||
fieldName = readUtf16(m, nb, nl);
|
||||
m.pdfium.wasmExports.free(nb);
|
||||
}
|
||||
|
||||
if (!fieldName || !(fieldName in values)) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = values[fieldName];
|
||||
const fieldType = m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr);
|
||||
|
||||
try {
|
||||
if (fieldType === PDF_FORM_FIELD_TYPE.TEXTFIELD) {
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_SelectAllText(formEnvPtr, pagePtr);
|
||||
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
|
||||
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
|
||||
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
|
||||
m.pdfium.wasmExports.free(wPtr);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
} else if (fieldType === PDF_FORM_FIELD_TYPE.CHECKBOX) {
|
||||
// Toggle checkbox using the same approach as @embedpdf engine:
|
||||
// Focus → Enter key (FORM_OnChar with keycode 13) → Kill focus.
|
||||
// Click simulation (FORM_OnLButtonDown/Up) does NOT reliably
|
||||
// persist checkbox state changes in headless/offscreen mode.
|
||||
const isCurrentlyChecked = m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr);
|
||||
const shouldBeChecked = value !== '' && value !== 'Off';
|
||||
if (isCurrentlyChecked !== shouldBeChecked) {
|
||||
const ENTER_KEY = 13;
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
} else if (fieldType === PDF_FORM_FIELD_TYPE.RADIOBUTTON) {
|
||||
// Radio values are stored as widget indices (e.g., "0", "1", "2").
|
||||
// Track the current widget index for this field and toggle only
|
||||
// the widget whose index matches the stored value.
|
||||
const currentIdx = radioWidgetIdx.get(fieldName) ?? 0;
|
||||
radioWidgetIdx.set(fieldName, currentIdx + 1);
|
||||
|
||||
const targetIdx = parseInt(value, 10);
|
||||
if (!isNaN(targetIdx) && currentIdx === targetIdx) {
|
||||
const isAlreadyChecked = m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr);
|
||||
if (!isAlreadyChecked) {
|
||||
const ENTER_KEY = 13;
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
fieldType === PDF_FORM_FIELD_TYPE.COMBOBOX ||
|
||||
fieldType === PDF_FORM_FIELD_TYPE.LISTBOX
|
||||
) {
|
||||
// FORM_SetIndexSelected requires the annotation to be focused first.
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
|
||||
let matched = false;
|
||||
const optCount = m.FPDFAnnot_GetOptionCount(formEnvPtr, annotPtr);
|
||||
for (let oi = 0; oi < optCount; oi++) {
|
||||
const optLen = m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, 0, 0);
|
||||
if (optLen > 0) {
|
||||
const ob = m.pdfium.wasmExports.malloc(optLen);
|
||||
m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, ob, optLen);
|
||||
const optLabel = readUtf16(m, ob, optLen);
|
||||
m.pdfium.wasmExports.free(ob);
|
||||
if (optLabel === value) {
|
||||
m.FORM_SetIndexSelected(formEnvPtr, pagePtr, oi, true);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: set as text (handles editable combos or
|
||||
// cases where export values differ from display labels).
|
||||
if (!matched && value) {
|
||||
m.FORM_SelectAllText(formEnvPtr, pagePtr);
|
||||
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
|
||||
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
|
||||
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
|
||||
m.pdfium.wasmExports.free(wPtr);
|
||||
}
|
||||
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[PdfiumFormProvider] Failed to set "${fieldName}":`, err);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
|
||||
if (flatten) {
|
||||
m.FPDFPage_Flatten(pagePtr, FLAT_PRINT);
|
||||
}
|
||||
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
|
||||
const savedBytes = await saveRawDocument(docPtr);
|
||||
return new Blob([savedBytes], { type: 'application/pdf' });
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
export { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
|
||||
export { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
|
||||
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface FormField {
|
||||
multiline: boolean;
|
||||
tooltip: string | null;
|
||||
widgets: WidgetCoordinates[] | null;
|
||||
/** Visual label for push buttons (from /MK/CA in the PDF appearance dict) */
|
||||
buttonLabel?: string | null;
|
||||
/** Action descriptor for push buttons */
|
||||
buttonAction?: ButtonAction | null;
|
||||
/** Pre-rendered appearance image for signed signature fields (data URL). */
|
||||
appearanceDataUrl?: string;
|
||||
}
|
||||
@@ -43,6 +47,26 @@ export type FormFieldType =
|
||||
| 'button'
|
||||
| 'signature';
|
||||
|
||||
export type ButtonActionType =
|
||||
| 'named'
|
||||
| 'javascript'
|
||||
| 'submitForm'
|
||||
| 'resetForm'
|
||||
| 'uri'
|
||||
| 'none';
|
||||
|
||||
export interface ButtonAction {
|
||||
type: ButtonActionType;
|
||||
/** For 'named' actions: the PDF action name (e.g. 'Print', 'NextPage', 'PrevPage') */
|
||||
namedAction?: string;
|
||||
/** For 'javascript' actions: the JavaScript source code */
|
||||
javascript?: string;
|
||||
/** For 'submitForm' / 'uri' actions: the target URL */
|
||||
url?: string;
|
||||
/** For 'submitForm' actions: submit flags bitmask */
|
||||
submitFlags?: number;
|
||||
}
|
||||
|
||||
export interface FormFillState {
|
||||
/** Fields fetched from backend with coordinates */
|
||||
fields: FormField[];
|
||||
|
||||
Reference in New Issue
Block a user