mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user