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:
@@ -1,4 +1,4 @@
|
||||
import { PDFDocument, PageSizes } from 'pdf-lib';
|
||||
import { PDFDocument, PageSizes } from '@cantoo/pdf-lib';
|
||||
|
||||
export interface ImageToPdfOptions {
|
||||
imageResolution?: 'full' | 'reduced';
|
||||
|
||||
@@ -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 0–1). */
|
||||
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 0–1. 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user