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:
brios
2026-03-24 13:34:52 +00:00
committed by GitHub
co-authored by Reece Browne
parent 3ea11352e3
commit c3530024c4
34 changed files with 4299 additions and 1910 deletions
@@ -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;
+1 -1
View File
@@ -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';
+24
View File
@@ -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[];