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
+1 -1
View File
@@ -1,4 +1,4 @@
import { PDFDocument, PageSizes } from 'pdf-lib';
import { PDFDocument, PageSizes } from '@cantoo/pdf-lib';
export interface ImageToPdfOptions {
imageResolution?: 'full' | 'reduced';
+553
View File
@@ -0,0 +1,553 @@
/**
* pdfLinkUtils — Create, modify, and extract link annotations in PDF documents.
*/
import {
PDFDocument,
PDFPage,
PDFName,
PDFString,
PDFArray,
PDFDict,
PDFRef,
PDFContext,
PDFNumber,
PDFHexString,
} from '@cantoo/pdf-lib';
export type LinkType = 'internal' | 'external' | 'unknown';
export type LinkBorderStyle = 'solid' | 'dashed' | 'beveled' | 'inset' | 'underline';
export type LinkHighlightMode = 'none' | 'invert' | 'outline' | 'push';
export interface PdfLibLink {
id: string;
/** Index of this annotation in the page's /Annots array (used for deletion matching). */
annotIndex: number;
/** Rectangle in PDF-page coordinate space (top-left origin, unscaled). */
rect: { x: number; y: number; width: number; height: number };
type: LinkType;
/** 0-based target page index (internal links). */
targetPage?: number;
/** URI for external links. */
uri?: string;
/** Tooltip / alt text from the /Contents entry. */
title?: string;
/** RGB color of the link annotation border (each component 01). */
color?: [number, number, number];
/** Border width and style. */
borderStyle?: { width: number; style: LinkBorderStyle };
/** Visual feedback when the link is clicked. */
highlightMode?: LinkHighlightMode;
}
export interface CreateLinkOptions {
/** Page to place the link on. */
page: PDFPage;
/** Link rectangle in PDF user-space coordinates (lower-left origin). */
rect: { x: number; y: number; width: number; height: number };
/** External URL (mutually exclusive with destinationPage). */
url?: string;
/** Internal destination page index, 0-based (mutually exclusive with url). */
destinationPage?: number;
/** Tooltip text shown on hover (stored in /Contents). */
title?: string;
/** RGB colour for the border, each component 01. Defaults to blue. */
color?: [number, number, number];
/** Border width in points. 0 = invisible (PDF convention). */
borderWidth?: number;
/** Border line style. */
borderStyle?: LinkBorderStyle;
/** Visual feedback when the link is clicked. */
highlightMode?: LinkHighlightMode;
}
/**
* Create a link annotation on a PDF page.
* Supports both external URIs and internal GoTo page destinations.
*/
export function createLinkAnnotation(
pdfDoc: PDFDocument,
options: CreateLinkOptions,
): void {
const {
page,
rect,
url,
destinationPage,
title,
color = [0, 0, 1],
borderWidth = 0,
borderStyle = 'solid',
highlightMode = 'invert',
} = options;
if (!url && destinationPage === undefined) {
throw new Error('createLinkAnnotation: must provide either url or destinationPage');
}
if (url && destinationPage !== undefined) {
throw new Error('createLinkAnnotation: url and destinationPage are mutually exclusive');
}
if (destinationPage !== undefined) {
const pageCount = pdfDoc.getPageCount();
if (destinationPage < 0 || destinationPage >= pageCount) {
throw new RangeError(
`createLinkAnnotation: destinationPage ${destinationPage} out of range [0, ${pageCount})`,
);
}
}
if (rect.width <= 0 || rect.height <= 0) {
throw new Error('createLinkAnnotation: rect dimensions must be positive');
}
if (color.some((c) => c < 0 || c > 1)) {
throw new RangeError('createLinkAnnotation: color components must be between 0 and 1');
}
if (borderWidth < 0) {
throw new RangeError('createLinkAnnotation: borderWidth must be non-negative');
}
const ctx = pdfDoc.context;
const entries: Record<string, any> = {
Type: 'Annot',
Subtype: 'Link',
Rect: [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height],
Border: [0, 0, borderWidth],
C: color,
H: PDFName.of(highlightModeCode(highlightMode)),
};
if (title) {
entries.Contents = PDFString.of(title);
}
const annotDict = ctx.obj(entries);
if (borderStyle !== 'solid' && borderWidth > 0) {
const bsDict = ctx.obj({
W: borderWidth,
S: PDFName.of(borderStyleCode(borderStyle)),
});
(annotDict as PDFDict).set(PDFName.of('BS'), bsDict);
}
if (url) {
const actionDict = ctx.obj({
S: 'URI',
URI: PDFString.of(url),
});
(annotDict as PDFDict).set(PDFName.of('A'), actionDict);
} else if (destinationPage !== undefined) {
const destPage = pdfDoc.getPage(destinationPage);
const destArray = ctx.obj([destPage.ref, 'XYZ', null, null, null]);
(annotDict as PDFDict).set(PDFName.of('Dest'), destArray);
}
const annotRef = ctx.register(annotDict);
const existingAnnots = page.node.get(PDFName.of('Annots'));
if (existingAnnots) {
const resolvedAnnots =
existingAnnots instanceof PDFRef ? ctx.lookup(existingAnnots) : existingAnnots;
if (resolvedAnnots instanceof PDFArray) {
resolvedAnnots.push(annotRef);
} else {
page.node.set(PDFName.of('Annots'), ctx.obj([annotRef]));
}
} else {
page.node.set(PDFName.of('Annots'), ctx.obj([annotRef]));
}
}
/**
* Remove a link annotation from a page by its index in the /Annots array.
* Returns true if the annotation was found and removed.
*/
export function removeLinkAnnotation(
pdfDoc: PDFDocument,
page: PDFPage,
annotIndex: number,
): boolean {
const ctx = pdfDoc.context;
const annotsRaw = page.node.get(PDFName.of('Annots'));
if (!annotsRaw) return false;
const annots =
annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
if (!(annots instanceof PDFArray)) return false;
if (annotIndex < 0 || annotIndex >= annots.size()) return false;
const entry = annots.get(annotIndex);
if (entry instanceof PDFRef) {
ctx.delete(entry);
}
annots.remove(annotIndex);
if (annots.size() === 0) {
page.node.delete(PDFName.of('Annots'));
}
return true;
}
/**
* Extract all link annotations from a given PDF page.
*/
export function extractLinksFromPage(
doc: PDFDocument,
page: PDFPage,
pageIndex: number,
): PdfLibLink[] {
const links: PdfLibLink[] = [];
const ctx = doc.context;
const { height: pageHeight } = page.getSize();
const annotsRaw = page.node.get(PDFName.of('Annots'));
if (!annotsRaw) return links;
const annots = annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
if (!(annots instanceof PDFArray)) return links;
for (let i = 0; i < annots.size(); i++) {
try {
const annotRaw = annots.get(i);
const annot = annotRaw instanceof PDFRef ? ctx.lookup(annotRaw) : annotRaw;
if (!(annot instanceof PDFDict)) continue;
const subtype = annot.get(PDFName.of('Subtype'));
if (subtype?.toString() !== '/Link') continue;
const rectRaw = annot.get(PDFName.of('Rect'));
const rect = rectRaw instanceof PDFRef ? ctx.lookup(rectRaw) : rectRaw;
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
const x1 = num(ctx, rect.get(0));
const y1 = num(ctx, rect.get(1));
const x2 = num(ctx, rect.get(2));
const y2 = num(ctx, rect.get(3));
const left = Math.min(x1, x2);
const bottom = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
const top = pageHeight - bottom - height;
let linkType: LinkType = 'unknown';
let targetPage: number | undefined;
let uri: string | undefined;
const actionRaw = annot.get(PDFName.of('A'));
const action = actionRaw instanceof PDFRef ? ctx.lookup(actionRaw) : actionRaw;
if (action instanceof PDFDict) {
const actionType = action.get(PDFName.of('S'))?.toString();
if (actionType === '/URI') {
linkType = 'external';
uri = str(ctx, action.get(PDFName.of('URI')));
} else if (actionType === '/GoTo') {
linkType = 'internal';
const dest = action.get(PDFName.of('D'));
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
if (destResolved instanceof PDFArray) {
targetPage = resolveDestArray(doc, ctx, destResolved);
} else {
const destName = str(ctx, destResolved);
if (destName) {
targetPage = resolveNamedDest(doc, ctx, destName);
}
}
} else if (actionType === '/GoToR' || actionType === '/Launch') {
linkType = 'external';
uri = str(ctx, action.get(PDFName.of('F')));
}
}
if (linkType === 'unknown') {
const destRaw = annot.get(PDFName.of('Dest'));
const dest = destRaw instanceof PDFRef ? ctx.lookup(destRaw) : destRaw;
if (dest instanceof PDFArray) {
linkType = 'internal';
targetPage = resolveDestArray(doc, ctx, dest);
} else {
const destName = str(ctx, dest);
if (destName) {
linkType = 'internal';
targetPage = resolveNamedDest(doc, ctx, destName);
}
}
}
const title = extractTitle(ctx, annot);
const color = extractColor(ctx, annot);
const borderStyle = extractBorderStyle(ctx, annot);
const highlightMode = parseHighlightMode(ctx, annot.get(PDFName.of('H')));
links.push({
id: `pdflib-link-${pageIndex}-${i}`,
annotIndex: i,
rect: { x: left, y: top, width, height },
type: linkType,
targetPage,
uri,
title,
color,
borderStyle,
highlightMode,
});
} catch (e) {
console.warn('[pdfLinkUtils] Failed to parse annotation:', e);
}
}
return links;
}
// ---------------------------------------------------------------------------
// Private Helpers (Internal to extraction logic)
// ---------------------------------------------------------------------------
function num(ctx: PDFContext, value: unknown): number {
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
if (resolved instanceof PDFNumber) return resolved.asNumber();
if (typeof resolved === 'number') return resolved;
return 0;
}
function str(ctx: PDFContext, value: unknown): string | undefined {
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
if (resolved instanceof PDFString) return resolved.decodeText();
if (resolved instanceof PDFHexString) return resolved.decodeText();
if (typeof resolved === 'string') return resolved;
return undefined;
}
function resolvePageIndex(doc: PDFDocument, pageRef: PDFRef): number | undefined {
const pages = doc.getPages();
for (let i = 0; i < pages.length; i++) {
const ref = pages[i].ref;
if (
ref === pageRef ||
(ref.objectNumber === pageRef.objectNumber &&
ref.generationNumber === pageRef.generationNumber)
) {
return i;
}
}
return undefined;
}
function resolveDestArray(
doc: PDFDocument,
ctx: PDFContext,
destArr: PDFArray,
): number | undefined {
if (destArr.size() < 1) return undefined;
const first = destArr.get(0);
if (first instanceof PDFRef) {
return resolvePageIndex(doc, first);
}
const n = num(ctx, first);
if (typeof n === 'number' && n >= 0) return n;
return undefined;
}
function resolveNamedDest(
doc: PDFDocument,
ctx: PDFContext,
name: string,
): number | undefined {
try {
const catalog = doc.catalog;
const namesRaw = catalog.get(PDFName.of('Names'));
const namesDict = namesRaw instanceof PDFRef ? ctx.lookup(namesRaw) : namesRaw;
if (namesDict instanceof PDFDict) {
const destsRaw = namesDict.get(PDFName.of('Dests'));
const destsTree = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw;
if (destsTree instanceof PDFDict) {
const result = searchNameTree(doc, ctx, destsTree, name);
if (result !== undefined) return result;
}
}
const destsRaw = catalog.get(PDFName.of('Dests'));
const destsDict = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw;
if (destsDict instanceof PDFDict) {
const dest = destsDict.get(PDFName.of(name));
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
if (destResolved instanceof PDFArray) {
return resolveDestArray(doc, ctx, destResolved);
}
}
} catch {
// ignore
}
return undefined;
}
function searchNameTree(
doc: PDFDocument,
ctx: PDFContext,
node: PDFDict,
name: string,
): number | undefined {
const namesArr = node.get(PDFName.of('Names'));
const resolved = namesArr instanceof PDFRef ? ctx.lookup(namesArr) : namesArr;
if (resolved instanceof PDFArray) {
for (let i = 0; i < resolved.size(); i += 2) {
const key = str(ctx, resolved.get(i));
if (key === name) {
const val = resolved.get(i + 1);
const valResolved = val instanceof PDFRef ? ctx.lookup(val) : val;
if (valResolved instanceof PDFArray) {
return resolveDestArray(doc, ctx, valResolved);
}
if (valResolved instanceof PDFDict) {
const d = valResolved.get(PDFName.of('D'));
const dResolved = d instanceof PDFRef ? ctx.lookup(d) : d;
if (dResolved instanceof PDFArray) {
return resolveDestArray(doc, ctx, dResolved);
}
}
}
}
}
const kidsArr = node.get(PDFName.of('Kids'));
const kidsResolved = kidsArr instanceof PDFRef ? ctx.lookup(kidsArr) : kidsArr;
if (kidsResolved instanceof PDFArray) {
for (let i = 0; i < kidsResolved.size(); i++) {
const kidRef = kidsResolved.get(i);
const kid = kidRef instanceof PDFRef ? ctx.lookup(kidRef) : kidRef;
if (kid instanceof PDFDict) {
const limits = kid.get(PDFName.of('Limits'));
const limitsResolved = limits instanceof PDFRef ? ctx.lookup(limits) : limits;
if (limitsResolved instanceof PDFArray && limitsResolved.size() >= 2) {
const lo = str(ctx, limitsResolved.get(0)) ?? '';
const hi = str(ctx, limitsResolved.get(1)) ?? '';
if (name < lo || name > hi) continue;
}
const result = searchNameTree(doc, ctx, kid, name);
if (result !== undefined) return result;
}
}
}
return undefined;
}
function borderStyleCode(style: LinkBorderStyle): string {
switch (style) {
case 'dashed': return 'D';
case 'beveled': return 'B';
case 'inset': return 'I';
case 'underline': return 'U';
default: return 'S';
}
}
function highlightModeCode(mode: LinkHighlightMode): string {
switch (mode) {
case 'none': return 'N';
case 'outline': return 'O';
case 'push': return 'P';
default: return 'I';
}
}
function parseBorderStyleName(ctx: PDFContext, value: unknown): LinkBorderStyle {
if (!value) return 'solid';
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
const s = resolved instanceof PDFName ? resolved.decodeText() : String(resolved);
switch (s) {
case 'D': return 'dashed';
case 'B': return 'beveled';
case 'I': return 'inset';
case 'U': return 'underline';
default: return 'solid';
}
}
function parseHighlightMode(ctx: PDFContext, value: unknown): LinkHighlightMode {
if (!value) return 'invert';
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
const s = resolved instanceof PDFName ? resolved.decodeText() : String(resolved);
switch (s) {
case 'N': return 'none';
case 'I': return 'invert';
case 'O': return 'outline';
case 'P': return 'push';
default: return 'invert';
}
}
function extractBorderStyle(
ctx: PDFContext,
annot: PDFDict,
): PdfLibLink['borderStyle'] | undefined {
const bsRaw = annot.get(PDFName.of('BS'));
const bs = bsRaw instanceof PDFRef ? ctx.lookup(bsRaw) : bsRaw;
if (bs instanceof PDFDict) {
const w = bs.get(PDFName.of('W'));
const s = bs.get(PDFName.of('S'));
return {
width: num(ctx, w) || 1,
style: parseBorderStyleName(ctx, s),
};
}
const borderRaw = annot.get(PDFName.of('Border'));
const border = borderRaw instanceof PDFRef ? ctx.lookup(borderRaw) : borderRaw;
if (border instanceof PDFArray && border.size() >= 3) {
const width = num(ctx, border.get(2));
const style: LinkBorderStyle = border.size() >= 4 ? 'dashed' : 'solid';
return { width, style };
}
return undefined;
}
function extractColor(
ctx: PDFContext,
annot: PDFDict,
): [number, number, number] | undefined {
const cRaw = annot.get(PDFName.of('C'));
const c = cRaw instanceof PDFRef ? ctx.lookup(cRaw) : cRaw;
if (!(c instanceof PDFArray)) return undefined;
const len = c.size();
if (len === 3) {
return [num(ctx, c.get(0)), num(ctx, c.get(1)), num(ctx, c.get(2))];
}
if (len === 1) {
const g = num(ctx, c.get(0));
return [g, g, g];
}
if (len === 4) {
const cVal = num(ctx, c.get(0));
const m = num(ctx, c.get(1));
const y = num(ctx, c.get(2));
const k = num(ctx, c.get(3));
return [
(1 - cVal) * (1 - k),
(1 - m) * (1 - k),
(1 - y) * (1 - k),
];
}
return undefined;
}
function extractTitle(
ctx: PDFContext,
annot: PDFDict,
): string | undefined {
const raw = annot.get(PDFName.of('Contents'));
const resolved = raw instanceof PDFRef ? ctx.lookup(raw) : raw;
if (resolved instanceof PDFString || resolved instanceof PDFHexString) {
return resolved.decodeText();
}
return undefined;
}
+114 -54
View File
@@ -1,9 +1,9 @@
import { PDFDocument, rgb } from 'pdf-lib';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { createProcessedFile, createChildStub } from '@app/contexts/file/fileActions';
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '@app/types/fileContext';
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
import {PDFDocument, rgb} from '@cantoo/pdf-lib';
import {PdfAnnotationSubtype} from '@embedpdf/models';
import {generateThumbnailWithMetadata} from '@app/utils/thumbnailUtils';
import {createChildStub, createProcessedFile} from '@app/contexts/file/fileActions';
import {createStirlingFile, FileId, StirlingFile, StirlingFileStub} from '@app/types/fileContext';
import type {SignatureAPI} from '@app/components/viewer/viewerTypes';
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
@@ -38,29 +38,20 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (signatureApiRef?.current) {
// Get actual page count from viewer
const scrollState = getScrollState();
const totalPages = scrollState.totalPages;
// Check only actual pages that exist in the document
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
try {
const pageAnnotations = await signatureApiRef.current.getPageAnnotations(pageIndex);
if (pageAnnotations && pageAnnotations.length > 0) {
// Filter to only include annotations added in this session
const sessionAnnotations = pageAnnotations.filter(annotation => {
// Check if this annotation has stored image data (indicates it was added this session)
const hasStoredImageData = annotation.id && getImageData(annotation.id);
// Also check if it has image data directly in the annotation (new signatures)
const hasDirectImageData = annotation.imageData || annotation.appearance ||
annotation.stampData || annotation.imageSrc ||
annotation.contents || annotation.data;
const isSessionAnnotation = hasStoredImageData || (hasDirectImageData && typeof hasDirectImageData === 'string' && hasDirectImageData.startsWith('data:image'));
return isSessionAnnotation;
return hasStoredImageData || (hasDirectImageData && typeof hasDirectImageData === 'string' && hasDirectImageData.startsWith('data:image'));
});
if (sessionAnnotations.length > 0) {
@@ -74,12 +65,11 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
// Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually)
// Leave old annotations alone - they will remain as annotations in the PDF
if (allAnnotations.length > 0 && signatureApiRef?.current) {
for (const pageData of allAnnotations) {
for (const annotation of pageData.annotations) {
try {
await signatureApiRef.current.deleteAnnotation(annotation.id, pageData.pageIndex);
signatureApiRef.current.deleteAnnotation(annotation.id, pageData.pageIndex);
} catch (deleteError) {
console.warn(`Failed to delete annotation ${annotation.id}:`, deleteError);
}
@@ -96,17 +86,12 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (pdfArrayBuffer) {
// Try loading with more permissive PDF-lib options
// Convert ArrayBuffer to File
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the current file - try from originalFile first, then from all files
let currentFile = originalFile;
if (!currentFile) {
const allFileIds = selectors.getAllFileIds();
if (allFileIds.length > 0) {
// Use activeFileIndex if provided, otherwise default to 0
const fileIndex = activeFileIndex !== undefined && activeFileIndex < allFileIds.length ? activeFileIndex : 0;
const fileStub = selectors.getStirlingFileStub(allFileIds[fileIndex]);
const fileObject = selectors.getFile(allFileIds[fileIndex]);
@@ -128,7 +113,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
try {
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
// Try different loading options to handle problematic PDFs
let pdfDoc: PDFDocument;
try {
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
@@ -139,7 +123,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
} catch {
console.warn('Failed to load with standard options, trying createProxy...');
try {
// Create a fresh PDF and copy pages instead of modifying
pdfDoc = await PDFDocument.create();
const sourcePdf = await PDFDocument.load(pdfArrayBufferForFlattening, {
ignoreEncryption: true,
@@ -169,22 +152,18 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
if (rect) {
// Extract original annotation position and size
const originalX = rect.origin?.x || rect.x || rect.left || 0;
const originalY = rect.origin?.y || rect.y || rect.top || 0;
const width = rect.size?.width || rect.width || 100;
const height = rect.size?.height || rect.height || 50;
// Convert EmbedPDF coordinates to PDF-lib coordinates
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
// Try to get annotation image data
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
annotation.imageSrc || annotation.contents || annotation.data;
// If no image data found directly, try to get it from storage
if (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
@@ -192,24 +171,63 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image/svg+xml')) {
let svgRendered = false;
try {
const svgContent = decodeSvgDataUrl(imageDataUrl);
if (svgContent && typeof (page as any).drawSvg === 'function') {
// drawSvg from @cantoo/pdf-lib renders SVG natively as
(page as any).drawSvg(svgContent, {
x: pdfX,
y: pdfY,
width: width,
height: height,
});
svgRendered = true;
}
} catch (svgError) {
console.warn('Native SVG embed failed, falling back to raster:', svgError);
}
if (!svgRendered) {
try {
const pngBytes = await rasteriseSvgToPng(imageDataUrl, width * 2, height * 2);
if (pngBytes) {
const image = await pdfDoc.embedPng(pngBytes);
page.drawImage(image, { x: pdfX, y: pdfY, width, height });
svgRendered = true;
}
} catch (rasterError) {
console.error('SVG raster fallback also failed:', rasterError);
}
}
if (!svgRendered) {
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(0.8, 0, 0),
borderWidth: 1,
color: rgb(1, 0.95, 0.95),
opacity: 0.7,
});
}
} else if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
try {
// Convert data URL to bytes
const base64Data = imageDataUrl.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
// Embed image in PDF based on data URL type
let image;
if (imageDataUrl.includes('data:image/jpeg') || imageDataUrl.includes('data:image/jpg')) {
image = await pdfDoc.embedJpg(imageBytes);
} else if (imageDataUrl.includes('data:image/png')) {
image = await pdfDoc.embedPng(imageBytes);
} else {
// Default to PNG for other formats (including converted SVGs)
image = await pdfDoc.embedPng(imageBytes);
}
// Draw image on page at annotation position
page.drawImage(image, {
x: pdfX,
y: pdfY,
@@ -221,8 +239,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
console.error('Failed to render image annotation:', imageError);
}
} else if (annotation.content || annotation.text) {
console.warn('Rendering text annotation instead');
// Handle text annotations
page.drawText(annotation.content || annotation.text, {
x: pdfX,
y: pdfY + height - 12, // Adjust for text baseline
@@ -230,26 +246,17 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
color: rgb(0, 0, 0)
});
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
// Handle ink annotations (drawn signatures)
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(0, 0, 0),
borderWidth: 2,
color: rgb(0.9, 0.9, 0.9), // Light gray background
opacity: 0.8
});
page.drawText('Drawn Signature', {
x: pdfX + 5,
y: pdfY + height / 2,
size: 10,
color: rgb(0, 0, 0)
borderWidth: 1,
color: rgb(0.95, 0.95, 0.95),
opacity: 0.6
});
} else {
// Handle other annotation types
page.drawRectangle({
x: pdfX,
y: pdfY,
@@ -257,7 +264,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
height: height,
borderColor: rgb(1, 0, 0),
borderWidth: 2,
color: rgb(1, 1, 0), // Yellow background
color: rgb(1, 1, 0),
opacity: 0.5
});
}
@@ -270,7 +277,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
// Save the PDF with rendered annotations
const flattenedPdfBytes = await pdfDoc.save({ useObjectStreams: false, addDefaultPage: false });
const arrayBuffer = new ArrayBuffer(flattenedPdfBytes.length);
@@ -284,11 +290,9 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
// Generate thumbnail and metadata for the signed file
const thumbnailResult = await generateThumbnailWithMetadata(signedFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Prepare input file data for replacement
const inputFileIds: FileId[] = [currentFile.fileId];
const record = selectors.getStirlingFileStub(currentFile.fileId);
@@ -297,7 +301,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
return null;
}
// Create output stub and file as a child of the original (increments version)
const outputStub = createChildStub(
record,
{ toolId: 'sign', timestamp: Date.now() },
@@ -307,7 +310,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
);
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
// Return the flattened file data for consumption by caller
return {
inputFileIds,
outputStirlingFile,
@@ -321,3 +323,61 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
return null;
}
}
/**
* Decode an SVG data URL to its raw XML string.
* Handles both base64-encoded and URI-encoded SVG data URLs.
*/
function decodeSvgDataUrl(dataUrl: string): string | null {
try {
if (dataUrl.includes(';base64,')) {
const base64 = dataUrl.split(',')[1];
return atob(base64);
}
// URI-encoded SVG
const encoded = dataUrl.split(',')[1];
return decodeURIComponent(encoded);
} catch {
return null;
}
}
/**
* Rasterise an SVG data URL to PNG bytes via an offscreen canvas.
* Used as a fallback when native SVG embedding is unavailable.
*/
function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): Promise<Uint8Array | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(null);
return;
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (!blob) {
resolve(null);
return;
}
blob.arrayBuffer().then(
(buf) => resolve(new Uint8Array(buf)),
() => resolve(null),
);
},
'image/png',
);
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = svgDataUrl;
});
}