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]>
This commit is contained in:
Balázs Szücs
2026-02-14 20:55:27 +00:00
committed by GitHub
parent b8ce4e47c1
commit 0a1d2effdc
43 changed files with 1502 additions and 742 deletions
@@ -60,8 +60,8 @@ function WidgetInputInner({
const borderColor = error ? '#f44336' : (isActive ? '#2196F3' : 'rgba(33, 150, 243, 0.4)');
const bgColor = error
? 'rgba(244, 67, 54, 0.08)'
: (isActive ? 'rgba(33, 150, 243, 0.08)' : 'rgba(255, 255, 255, 0.85)');
? '#FFEBEE' // Red 50 (Opaque)
: (isActive ? '#E3F2FD' : '#FFFFFF'); // Blue 50 (Opaque) : White (Opaque)
const commonStyle: React.CSSProperties = {
position: 'absolute',
@@ -84,10 +84,41 @@ function WidgetInputInner({
alignItems: field.multiline ? 'stretch' : 'center',
};
// Scale font size with the widget height (using Y scale as a proxy for uniform font scaling).
// PDF form fields use fontSize=0 to mean "auto-size" (scale to fit the box).
// For single-line fields (e.g. Title), scale closer to the box height.
// For multiline fields (e.g. Description), use a smaller capped size.
const stopPropagation = (e: React.SyntheticEvent) => {
e.stopPropagation();
// Also stop immediate propagation to native listeners to block non-React subscribers
if (e.nativeEvent) {
e.nativeEvent.stopImmediatePropagation?.();
}
};
const commonProps = {
style: commonStyle,
onPointerDown: stopPropagation,
onPointerUp: stopPropagation,
onMouseDown: stopPropagation,
onMouseUp: stopPropagation,
onClick: stopPropagation,
onDoubleClick: stopPropagation,
onKeyDown: stopPropagation,
onKeyUp: stopPropagation,
onKeyPress: stopPropagation,
onDragStart: stopPropagation,
onSelect: stopPropagation,
onContextMenu: stopPropagation,
};
const captureStopProps = {
onPointerDownCapture: stopPropagation,
onPointerUpCapture: stopPropagation,
onMouseDownCapture: stopPropagation,
onMouseUpCapture: stopPropagation,
onClickCapture: stopPropagation,
onKeyDownCapture: stopPropagation,
onKeyUpCapture: stopPropagation,
onKeyPressCapture: stopPropagation,
};
const fontSize = widget.fontSize
? widget.fontSize * scaleY
: field.multiline
@@ -115,7 +146,7 @@ function WidgetInputInner({
switch (field.type) {
case 'text':
return (
<div style={commonStyle} title={error || field.tooltip || field.label}>
<div {...commonProps} title={error || field.tooltip || field.label}>
{field.multiline ? (
<textarea
value={value}
@@ -129,6 +160,7 @@ function WidgetInputInner({
overflow: 'auto',
paddingTop: `${Math.max(1, 2 * scaleY)}px`,
}}
{...captureStopProps}
/>
) : (
<input
@@ -144,6 +176,7 @@ function WidgetInputInner({
aria-required={field.required}
aria-invalid={!!error}
aria-describedby={error ? `${field.name}-error` : undefined}
{...captureStopProps}
/>
)}
</div>
@@ -156,6 +189,7 @@ function WidgetInputInner({
const onValue = widget.exportValue || 'Yes';
return (
<div
{...commonProps}
style={{
...commonStyle,
display: 'flex',
@@ -164,10 +198,11 @@ function WidgetInputInner({
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || field.label}
onClick={() => {
onClick={(e) => {
if (field.readOnly) return;
handleFocus();
onChange(field.name, isChecked ? 'Off' : onValue);
stopPropagation(e);
}}
>
<span
@@ -206,7 +241,7 @@ function WidgetInputInner({
};
return (
<div style={commonStyle} title={error || field.tooltip || field.label}>
<div {...commonProps} title={error || field.tooltip || field.label}>
<select
id={inputId}
value={selectValue}
@@ -224,6 +259,7 @@ function WidgetInputInner({
aria-label={field.label || field.name}
aria-required={field.required}
aria-invalid={!!error}
{...captureStopProps}
>
{!field.multiSelect && <option value=""> select </option>}
{(field.options || []).map((opt, idx) => (
@@ -243,6 +279,7 @@ function WidgetInputInner({
const isSelected = value === optionValue;
return (
<div
{...commonProps}
style={{
...commonStyle,
display: 'flex',
@@ -251,10 +288,11 @@ function WidgetInputInner({
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || `${field.label}: ${optionValue}`}
onClick={() => {
onClick={(e) => {
if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
handleFocus();
onChange(field.name, optionValue);
stopPropagation(e);
}}
>
<span
@@ -276,6 +314,7 @@ function WidgetInputInner({
// Just render a highlighted area — not editable
return (
<div
{...commonProps}
style={{
...commonStyle,
background: 'rgba(200,200,200,0.3)',
@@ -289,7 +328,7 @@ function WidgetInputInner({
default:
return (
<div style={commonStyle} title={field.tooltip || field.label}>
<div {...commonProps} title={field.tooltip || field.label}>
<input
type="text"
value={value}
@@ -297,6 +336,7 @@ function WidgetInputInner({
onFocus={handleFocus}
disabled={field.readOnly}
style={inputBaseStyle}
{...captureStopProps}
/>
</div>
);
@@ -16,7 +16,8 @@
*/
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage } from 'pdf-lib';
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';
@@ -141,11 +142,18 @@ function extractWidgets(
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
const keys = normal.entries()
.map(([k]) => k.decodeText())
.filter(k => k !== 'Off');
if (keys.length > 0) exportValue = keys[0];
// 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
@@ -353,7 +361,12 @@ function mapAppearanceStateToOption(
const normal = ap.lookup(PDFName.of('N'));
if (!(normal instanceof PDFDict)) continue;
const keys = normal.entries().map(([k]) => k.decodeText());
let keys: string[] = [];
try {
keys = normal.entries().map(([k]) => k.decodeText());
} catch {
continue;
}
if (keys.includes(stateName) && i < options.length) {
return options[i];
}
@@ -381,7 +394,7 @@ function resolveRadioValueForSelect(
}
const lower = value.toLowerCase();
const match = options.find(o => o.toLowerCase() === lower);
const match = options.find((o: string) => o.toLowerCase() === lower);
if (match) return match;
return null;
@@ -407,6 +420,68 @@ function getFieldOptions(field: PDFField): string[] | null {
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.
*/
@@ -431,17 +506,22 @@ function isFieldRequired(field: PDFField): boolean {
/**
* 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) {
try {
return tu.toString().replace(/^\(|\)$/g, '');
} catch {
// ignore
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;
}
return null;
}
/**
@@ -471,49 +551,82 @@ export class PdfLibFormProvider implements IFormDataProvider {
async fetchFields(file: File | Blob): Promise<FormField[]> {
const arrayBuffer = await readAsArrayBuffer(file);
const doc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
updateMetadata: false,
throwOnInvalidObject: false,
});
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 {
// No AcroForm — return empty
} catch (formError) {
// No AcroForm or broken catalog — return empty
console.warn('[PdfLibFormProvider] Failed to access AcroForm:', formError);
return [];
}
const fields = form.getFields();
let fields: PDFField[];
try {
fields = form.getFields();
} catch (fieldsError) {
console.warn('[PdfLibFormProvider] Failed to enumerate form fields:', fieldsError);
return [];
}
if (fields.length === 0) return [];
const pages = doc.getPages();
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 type = getFieldType(field);
const widgets = extractWidgets(field, pages, doc);
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;
// 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: null, // pdf-lib doesn't expose display vs export values separately
required: isFieldRequired(field),
readOnly: isFieldReadOnly(field),
multiSelect: field instanceof PDFOptionList,
multiline: isMultiline(field),
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
widgets,
};
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);
result.push(formField);
} catch (fieldError) {
// Skip individual malformed fields but continue processing
console.warn(`[PdfLibFormProvider] Skipping field "${fieldName}":`, fieldError);
}
}
return result;