Files
Stirling-PDF/frontend/src/proprietary/tools/formFill/providers/PdfLibFormProvider.ts
T
Balázs SzücsandGitHub 0a1d2effdc feat(frontend): Upgrade embedPDF to v2.6.0 and migrate to pdf-lib fork, fix attachment/bookmark panel (#5723)
# Description of Changes

Upgrades embedPDF from v2.5.0 to v2.6.0 and migrates from unmaintained
pdf-lib to @cantoo/pdf-lib fork. Adds defensive error handling for
malformed PDFs and improves bridge lifecycle management.

### Changes

**Dependencies**
- Upgrade all @embedpdf/* packages from ^2.5.0 to ^2.6.0
- Replace pdf-lib with @cantoo/pdf-lib (maintained fork with better
TypeScript support)

**PDF Viewer Infrastructure (attachment/bookmark fix)**
- Add useDocumentReady hook to track document lifecycle across bridges
- Implement defensive bridge cleanup to prevent stale registrations
- Fix race condition in document ready state detection by subscribing to
events before checking state

**Link Extraction (updated to cantoo/pdf-lib)**
- Add graceful error handling for PDFs with invalid catalog structures
- Extract enhanced link metadata (tooltips, colors, border styles,
highlight modes)
- Return empty results instead of throwing on malformed PDFs
- Add validation for link creation (destination page bounds, rect
dimensions, color values)

**Signature Flattening  (updated to cantoo/pdf-lib)**
- Improve SVG embedding with three-tier fallback strategy (native
vector, rasterized PNG, placeholder)
- Add proper Unicode handling for PDF form tooltips via
PDFString.decodeText()
- Extract SVG utilities into cleaner strategy pattern

**Form Field Processing  (updated to cantoo/pdf-lib)**
- Add support for display labels vs export values in dropdown/list
fields per PDF spec 12.7.4.4
- Implement caching for expensive field property lookups
- Add proper handling of malformed /Opt arrays


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] 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)
- [X] I have performed a self-review of my own code
- [X] 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)

- [X] 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]>
2026-02-14 20:55:27 +00:00

702 lines
21 KiB
TypeScript

/**
* 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 } from '@cantoo/pdf-lib';
import type { FormField, FormFieldType, WidgetCoordinates } from '@proprietary/tools/formFill/types';
import type { IFormDataProvider } from '@proprietary/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;
}
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);
}
}
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' });
}
}