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
@@ -8,6 +8,7 @@ import type {
AnnotationEvent,
AnnotationPatch,
} from '@app/components/viewer/viewerTypes';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
@@ -290,6 +291,7 @@ const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
const { provides: annotationApi } = useAnnotationCapability();
const documentReady = useDocumentReady();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
@@ -323,6 +325,7 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function Annotation
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
configureAnnotationTool(toolId, options);
},
isReady: () => !!annotationApi && documentReady,
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as AnnotationApiSurface | undefined;
@@ -3,7 +3,11 @@ import { useAttachmentCapability } from '@embedpdf/plugin-attachment/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { AttachmentState, AttachmentAPIWrapper } from '@app/contexts/viewer/viewerBridges';
import { PdfAttachmentObject } from '@embedpdf/models';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF attachment plugin to the shared ViewerContext.
*/
export function AttachmentAPIBridge() {
const { provides: attachmentCapability } = useAttachmentCapability();
const { registerBridge } = useViewer();
@@ -12,10 +16,19 @@ export function AttachmentAPIBridge() {
isLoading: false,
error: null,
});
const documentReady = useDocumentReady();
const fetchAttachments = useCallback(
async () => {
if (!attachmentCapability) return [];
if (!attachmentCapability || !documentReady) {
// Set error state instead of throwing for better user experience
setState(prev => ({
...prev,
error: 'Document not ready or attachment capability not available',
isLoading: false
}));
return [];
}
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
@@ -42,11 +55,12 @@ export function AttachmentAPIBridge() {
throw error;
}
},
[attachmentCapability]
[attachmentCapability, documentReady]
);
const api = useMemo<AttachmentAPIWrapper | null>(() => {
if (!attachmentCapability) return null;
// Only provide API when both capability AND document are ready
if (!attachmentCapability || !documentReady) return null;
return {
getAttachments: fetchAttachments,
@@ -84,15 +98,23 @@ export function AttachmentAPIBridge() {
});
},
};
}, [attachmentCapability, fetchAttachments]);
}, [attachmentCapability, documentReady, fetchAttachments]);
useEffect(() => {
if (!api) return;
if (!api) {
// If API becomes null (e.g. document transitions), ensure we unregister stale bridge
registerBridge('attachment', null);
return;
}
registerBridge('attachment', {
state,
api,
});
return () => {
registerBridge('attachment', null);
};
}, [api, state, registerBridge]);
return null;
@@ -2,7 +2,11 @@ import { useEffect, useMemo, useState, useCallback } from 'react';
import { useBookmarkCapability, BookmarkCapability } from '@embedpdf/plugin-bookmark/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF bookmark plugin to the shared ViewerContext.
*/
export function BookmarkAPIBridge() {
const { provides: bookmarkCapability } = useBookmarkCapability();
const { registerBridge } = useViewer();
@@ -11,9 +15,19 @@ export function BookmarkAPIBridge() {
isLoading: false,
error: null,
});
const documentReady = useDocumentReady();
const fetchBookmarks = useCallback(
async (capability: BookmarkCapability) => {
if (!documentReady) {
setState(prev => ({
...prev,
error: 'Document not ready or bookmark capability not available',
isLoading: false,
}));
return [];
}
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const task = capability.getBookmarks();
@@ -34,11 +48,12 @@ export function BookmarkAPIBridge() {
throw error;
}
},
[]
[documentReady]
);
const api = useMemo<BookmarkAPIWrapper | null>(() => {
if (!bookmarkCapability) return null;
// Only provide API when both capability AND document are ready
if (!bookmarkCapability || !documentReady) return null;
return {
fetchBookmarks: () => fetchBookmarks(bookmarkCapability),
@@ -57,15 +72,22 @@ export function BookmarkAPIBridge() {
});
},
};
}, [bookmarkCapability, fetchBookmarks]);
}, [bookmarkCapability, documentReady, fetchBookmarks]);
useEffect(() => {
if (!api) return;
if (!api) {
registerBridge('bookmark', null);
return;
}
registerBridge('bookmark', {
state,
api,
});
return () => {
registerBridge('bookmark', null);
};
}, [api, state, registerBridge]);
return null;
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
import {
PdfPermissionFlag,
DocumentPermissionsState,
@@ -25,6 +26,7 @@ export function DocumentPermissionsAPIBridge({
permissions = PdfPermissionFlag.AllowAll,
}: DocumentPermissionsAPIBridgeProps) {
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
const state = useMemo<DocumentPermissionsState>(() => ({
isEncrypted,
@@ -42,7 +44,7 @@ export function DocumentPermissionsAPIBridge({
const api = useMemo<DocumentPermissionsAPIWrapper>(() => ({
hasPermission: (flag: PdfPermissionFlag) => hasPermissionFlag(permissions, flag),
hasAllPermissions: (flags: PdfPermissionFlag[]) =>
hasAllPermissions: (flags: PdfPermissionFlag[]) =>
flags.every(flag => hasPermissionFlag(permissions, flag)),
getEffectivePermission: (flag: PdfPermissionFlag) => {
if (isOwnerUnlocked) return true;
@@ -51,11 +53,17 @@ export function DocumentPermissionsAPIBridge({
}), [permissions, isOwnerUnlocked]);
useEffect(() => {
registerBridge('permissions', {
state,
api,
});
}, [registerBridge, state, api]);
if (documentReady) {
registerBridge('permissions', {
state,
api,
});
}
return () => {
registerBridge('permissions', null);
};
}, [registerBridge, state, api, documentReady]);
return null;
}
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useExportCapability } from '@embedpdf/plugin-export/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Component that runs inside EmbedPDF context and provides export functionality
@@ -8,9 +9,10 @@ import { useViewer } from '@app/contexts/ViewerContext';
export function ExportAPIBridge() {
const { provides: exportApi } = useExportCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (exportApi) {
if (exportApi && documentReady) {
// Register this bridge with ViewerContext
registerBridge('export', {
state: {
@@ -19,7 +21,11 @@ export function ExportAPIBridge() {
api: exportApi
});
}
}, [exportApi, registerBridge]);
return () => {
registerBridge('export', null);
};
}, [exportApi, documentReady, registerBridge]);
return null;
}
}
@@ -2,19 +2,25 @@ import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '@app/contexts/SignatureContext';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { uuidV4, PdfAnnotationSubtype } from '@embedpdf/models';
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF history (undo/redo) plugin to the shared ViewerContext.
*/
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
const { provides: historyApi } = useHistoryCapability();
const { provides: annotationApi } = useAnnotationCapability();
const { getImageData, storeImageData } = useSignature();
const documentReady = useDocumentReady();
const restoringIds = useRef<Set<string>>(new Set());
// Monitor annotation events to detect when annotations are restored
useEffect(() => {
if (!annotationApi) return;
if (!annotationApi || !documentReady) return;
const handleAnnotationEvent = (event: any) => {
const annotation = event.annotation;
@@ -146,6 +152,8 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
return historyApi ? historyApi.canRedo() : false;
},
isReady: () => !!historyApi && documentReady,
purgeByMetadata: <T,>(predicate: (metadata: T | undefined) => boolean, topic?: string) => {
if (historyApi?.purgeByMetadata) {
return historyApi.purgeByMetadata(predicate, topic);
@@ -3,7 +3,8 @@ import { useDocumentState } from '@embedpdf/core/react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { usePdfLibLinks, type PdfLibLink } from '@app/hooks/usePdfLibLinks';
import { usePdfLibLinks } from '@app/hooks/usePdfLibLinks';
import type { PdfLibLink } from '@app/utils/pdfLinkUtils';
// ---------------------------------------------------------------------------
// Inline SVG icons (thin-stroke, modern)
@@ -125,7 +125,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}),
createPluginRegistration(ScrollPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: !enableFormFill, // Disable native form rendering when our interactive overlay is active
withForms: true,
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
}),
@@ -204,7 +204,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl, enableAnnotations, enableFormFill, showBakedAnnotations, fileName, file, url]);
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
@@ -2,28 +2,33 @@ import { useEffect, useRef } from 'react';
import { usePan } from '@embedpdf/plugin-pan/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF pan (hand tool) plugin to the shared ViewerContext.
*/
export function PanAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and the document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
return <PanAPIBridgeInner documentId={activeDocumentId} />;
}
function PanAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: pan, isPanning } = usePan(documentId);
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
// Keep pan ref updated to avoid re-running effect when object reference changes
const panRef = useRef(pan);
useEffect(() => {
panRef.current = pan;
}, [pan]);
// Track previous isPanning value to detect changes
const prevIsPanningRef = useRef<boolean>(isPanning);
@@ -57,13 +62,16 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
},
}
});
// Trigger immediate pan update if the value changed
if (prevIsPanningRef.current !== isPanning) {
prevIsPanningRef.current = isPanning;
triggerImmediatePanUpdate(isPanning);
}
}
return () => {
registerBridge('pan', null);
};
}, [isPanning, registerBridge, triggerImmediatePanUpdate]);
return null;
@@ -1,16 +1,18 @@
import { useEffect } from 'react';
import { usePrintCapability } from '@embedpdf/plugin-print/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Component that runs inside EmbedPDF context and exposes print API to ViewerContext
* Connects the PDF print plugin to the shared ViewerContext.
*/
export function PrintAPIBridge() {
const { provides: print } = usePrintCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (print) {
if (print && documentReady) {
// Register this bridge with ViewerContext
registerBridge('print', {
state: {},
@@ -19,7 +21,11 @@ export function PrintAPIBridge() {
}
});
}
}, [print, registerBridge]);
return () => {
registerBridge('print', null);
};
}, [print, documentReady, registerBridge]);
return null;
}
@@ -4,17 +4,18 @@ import { PdfAnnotationSubtype } from '@embedpdf/models';
import { useRedaction } from '@app/contexts/RedactionContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* RedactionAPIBridge - Uses embedPDF v2.5.0
* Bridges between the EmbedPDF redaction plugin and the Stirling-PDF RedactionContext.
* Uses the unified redaction mode (toggleRedact/enableRedact/endRedact).
*/
export function RedactionAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
@@ -69,9 +70,7 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
}, [annotationProvides, manualRedactColor]);
// Expose the EmbedPDF API through our context's ref
// Uses v2.5.0 unified redaction mode
useImperativeHandle(redactionApiRef, () => ({
// Unified redaction methods (v2.5.0)
toggleRedact: () => {
redactionProvides?.toggleRedact();
},
@@ -2,15 +2,20 @@ import { useEffect, useRef } from 'react';
import { useRotate } from '@embedpdf/plugin-rotate/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF rotation plugin to the shared ViewerContext.
*/
export function RotateAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
return <RotateAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -42,7 +47,11 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
}
});
}
return () => {
registerBridge('rotation', null);
};
}, [rotation, registerBridge]);
return null;
}
}
@@ -2,15 +2,20 @@ import { useEffect, useRef } from 'react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF scroll plugin to the shared ViewerContext.
*/
export function ScrollAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
return <ScrollAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -35,7 +40,7 @@ function ScrollAPIBridgeInner({ documentId }: { documentId: string }) {
currentPage,
totalPages,
};
// Trigger immediate update for responsive UI
triggerImmediateScrollUpdate(newState.currentPage, newState.totalPages);
@@ -44,6 +49,10 @@ function ScrollAPIBridgeInner({ documentId }: { documentId: string }) {
api: currentScroll
});
}
return () => {
registerBridge('scroll', null);
};
}, [currentPage, totalPages, registerBridge, triggerImmediateScrollUpdate]);
return null;
@@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
interface SearchResult {
pageIndex: number;
@@ -11,30 +12,35 @@ interface SearchResult {
}>;
}
/**
* SearchAPIBridge - Updated for embedPDF v2.6.0
* Connects the PDF search plugin to the shared ViewerContext.
*/
export function SearchAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
return <SearchAPIBridgeInner documentId={activeDocumentId} />;
}
function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: search } = useSearch(documentId);
const { registerBridge, scrollActions } = useViewer();
// Keep search ref updated to avoid re-running effects when object reference changes
const searchRef = useRef(search);
const isSearchingRef = useRef(false);
const lastScrolledIndexRef = useRef<number | null>(null);
useEffect(() => {
searchRef.current = search;
}, [search]);
const [localState, setLocalState] = useState({
results: null as SearchResult[] | null,
activeIndex: 0
@@ -42,14 +48,14 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
// Subscribe to search result changes from EmbedPDF
const subscriptionRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Cleanup previous subscription
if (subscriptionRef.current) {
subscriptionRef.current();
subscriptionRef.current = null;
}
if (!search) return;
subscriptionRef.current = search.onSearchResultStateChange?.((state: any) => {
@@ -87,11 +93,11 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
lastScrolledIndexRef.current = null;
return;
}
// Only scroll if the active index actually changed
const activeResultIndex = localActiveIndex - 1; // Convert back to 0-based
if (activeResultIndex >= 0 &&
activeResultIndex < localResults.length &&
if (activeResultIndex >= 0 &&
activeResultIndex < localResults.length &&
lastScrolledIndexRef.current !== activeResultIndex) {
const activeResult = localResults[activeResultIndex];
if (activeResult) {
@@ -114,13 +120,13 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
if (isSearchingRef.current) {
return null;
}
if (!currentSearch?.startSearch || !currentSearch?.searchAllPages) {
return null;
}
isSearchingRef.current = true;
try {
currentSearch.startSearch();
const results = await currentSearch.searchAllPages(query);
@@ -171,6 +177,10 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
}
});
}
return () => {
registerBridge('search', null);
};
}, [localResults, localActiveIndex, registerBridge]);
return null;
@@ -1,17 +1,22 @@
import { useEffect, useRef } from 'react';
import { useSelectionCapability } from '@embedpdf/plugin-selection/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF selection plugin to the shared ViewerContext.
*/
export function SelectionAPIBridge() {
const { provides: selection } = useSelectionCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
const hasSelectionRef = useRef(false);
const selectedTextRef = useRef('');
useEffect(() => {
if (!selection) return;
if (!selection || !documentReady) return;
const buildApi = () => ({
copyToClipboard: () => selection.copyToClipboard(),
@@ -68,8 +73,9 @@ export function SelectionAPIBridge() {
unsubCopy?.();
document.removeEventListener('copy', handleCopy);
document.removeEventListener('keydown', handleKeyDown);
registerBridge('selection', null);
};
}, [selection]);
}, [selection, documentReady, registerBridge]);
return null;
}
@@ -5,6 +5,11 @@ import { useSignature } from '@app/contexts/SignatureContext';
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF signature (stamp/ink) tools to the shared ViewerContext and SignatureContext.
*/
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
// This prevents rendering issues and ensures signatures are always visible and usable.
@@ -132,6 +137,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature();
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const documentReady = useDocumentReady();
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
const lastStampImageRef = useRef<string | null>(null);
@@ -211,7 +217,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
// Enable keyboard deletion of selected annotations
useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode
if (!annotationApi || (isPlacementMode === undefined)) return;
if (!annotationApi || (isPlacementMode === undefined) || !documentReady) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
@@ -391,7 +397,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
if (!annotationApi?.onAnnotationEvent || !documentReady) {
return;
}
@@ -430,10 +436,10 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData, setSignaturesApplied]);
}, [annotationApi, storeImageData, setSignaturesApplied, documentReady]);
useEffect(() => {
if (!isPlacementMode) {
if (!isPlacementMode || !documentReady) {
return;
}
@@ -447,66 +453,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
return;
}
const unsubscribe = annotationApi.onAnnotationEvent(event => {
if (event.type !== 'create' && event.type !== 'update') {
return;
}
const annotation: any = event.annotation;
const annotationId: string | undefined = annotation?.id;
if (!annotationId) {
return;
}
// Mark signatures as not applied when a new signature is placed
if (event.type === 'create') {
setSignaturesApplied(false);
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
extractDataUrl(annotation.appearance) ||
extractDataUrl(annotation.stampData) ||
extractDataUrl(annotation.contents) ||
extractDataUrl(annotation.data) ||
extractDataUrl(annotation.customData) ||
extractDataUrl(annotation.asset);
const dataToStore = directData || lastStampImageRef.current;
if (dataToStore) {
storeImageData(annotationId, dataToStore);
}
});
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData, setSignaturesApplied]);
useEffect(() => {
if (!isPlacementMode) {
return;
}
let cancelled = false;
configureStampDefaults().catch((error) => {
if (!cancelled) {
console.error('Error updating signature defaults:', error);
}
});
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig, documentReady]);
return null; // This is a bridge component with no UI
@@ -2,15 +2,21 @@ import { useEffect, useRef } from 'react';
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* SpreadAPIBridge - Updated for embedPDF v2.6.0
* Connects the PDF spread mode (single/dual page) plugin to the shared ViewerContext.
*/
export function SpreadAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
return <SpreadAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -52,6 +58,10 @@ function SpreadAPIBridgeInner({ documentId }: { documentId: string }) {
});
triggerImmediateSpreadUpdate(spreadMode);
return () => {
registerBridge('spread', null);
};
}, [spreadMode, registerBridge, triggerImmediateSpreadUpdate]);
return null;
@@ -1,23 +1,29 @@
import { useEffect } from 'react';
import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* ThumbnailAPIBridge provides thumbnail generation functionality.
* Exposes thumbnail API to UI components without managing state.
* ThumbnailAPIBridge - Updated for embedPDF v2.6.0
* Provides thumbnail generation functionality.
*/
export function ThumbnailAPIBridge() {
const { provides: thumbnail } = useThumbnailCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (thumbnail) {
if (thumbnail && documentReady) {
registerBridge('thumbnail', {
state: null, // No state - just provides API
api: thumbnail
});
}
}, [thumbnail]);
return () => {
registerBridge('thumbnail', null);
};
}, [thumbnail, documentReady, registerBridge]);
return null;
}
@@ -11,15 +11,20 @@ import {
useFitWidthResize,
} from '@app/utils/viewerZoom';
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF zoom plugin to the shared ViewerContext.
*/
export function ZoomAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
return null;
}
return <ZoomAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -60,7 +65,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
// Extract primitive values from zoomState for dependency arrays
const zoomLevel = zoomState?.zoomLevel;
const currentZoomLevel = zoomState?.currentZoomLevel;
// Extract metadata aspect ratio as a primitive to avoid object reference issues
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
@@ -200,14 +205,14 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
// Subscribe to zoom changes - use ref to avoid re-subscribing when zoom reference changes
const zoomSubscriptionRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Cleanup previous subscription if any
if (zoomSubscriptionRef.current) {
zoomSubscriptionRef.current();
zoomSubscriptionRef.current = null;
}
if (!zoom) {
return;
}
@@ -255,4 +260,4 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
}, [zoomStateCurrentZoomLevel, registerBridge, triggerImmediateZoomUpdate]);
return null;
}
}
@@ -0,0 +1,67 @@
import { useState, useEffect } from 'react';
import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react';
/**
* useDocumentReady - Custom hook to track whether a PDF document is fully loaded
* and ready for interaction.
*
* Subscribes to both onDocumentOpened (sets true) and onDocumentClosed (resets
* to false) so the flag correctly tracks the document lifecycle across
* open → close → reopen transitions.
*
* The initial check is synchronous (getActiveDocument is sync) — no debounce
* needed.
*/
export function useDocumentReady() {
const { provides: documentManagerCapability } = useDocumentManagerCapability();
const [documentReady, setDocumentReady] = useState(false);
useEffect(() => {
if (!documentManagerCapability) {
setDocumentReady(false);
return;
}
let mounted = true;
const unsubOpen = documentManagerCapability.onDocumentOpened?.((event: any) => {
if (mounted && (event?.documentId || event?.id)) {
setDocumentReady(true);
}
});
const unsubClose = documentManagerCapability.onDocumentClosed?.(() => {
if (!mounted) return;
try {
const remaining = documentManagerCapability.getActiveDocument?.();
if (!remaining?.id && mounted) {
setDocumentReady(false);
}
} catch {
if (mounted) setDocumentReady(false);
}
});
try {
const activeDoc = documentManagerCapability.getActiveDocument?.();
if (mounted) {
setDocumentReady(!!activeDoc?.id);
}
} catch {
if (mounted) setDocumentReady(false);
}
return () => {
mounted = false;
if (typeof unsubOpen === 'function') {
unsubOpen();
}
if (typeof unsubClose === 'function') {
unsubClose();
}
};
}, [documentManagerCapability]);
return documentReady;
}
+2 -2
View File
@@ -151,7 +151,7 @@ interface ViewerContextType {
// Bridge registration - internal use by bridges
registerBridge: <K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
) => void;
// Save changes function - registered by EmbedPdfViewer
@@ -242,7 +242,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
) => {
setBridgeRef(bridgeRefs.current, type, ref);
},
@@ -227,7 +227,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
export function registerBridge<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
): void {
registry[type] = ref as ViewerBridgeRegistry[K];
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { PDFDocument as PDFLibDocument } from '@cantoo/pdf-lib';
import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
import { PDFDocument, PDFName, PDFRef, PDFDict } from '@cantoo/pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
import { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage } from 'pdf-lib';
import { PDFFont, PDFPage } from '@cantoo/pdf-lib';
import { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFPage } from 'pdf-lib';
import { PDFFont, PDFPage } from '@cantoo/pdf-lib';
import { SignatureValidationSignature } from '@app/types/validateSignature';
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
import { drawStatusBadge } from '@app/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection';
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
interface StatusBadgeOptions {
page: PDFPage;
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFImage, PDFPage } from 'pdf-lib';
import { PDFFont, PDFImage, PDFPage } from '@cantoo/pdf-lib';
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
import { drawThumbnailImage, drawThumbnailPlaceholder } from '@app/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection';
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage, PDFImage } from 'pdf-lib';
import { PDFFont, PDFPage, PDFImage } from '@cantoo/pdf-lib';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
export const drawThumbnailPlaceholder = (
@@ -1,4 +1,4 @@
import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib';
import { PDFDocument, PDFPage, StandardFonts } from '@cantoo/pdf-lib';
import type { TFunction } from 'i18next';
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
import { REPORT_PDF_FILENAME } from '@app/hooks/tools/validateSignature/utils/signatureUtils';
@@ -1,4 +1,4 @@
import { PDFDocument, PDFFont, PDFImage } from 'pdf-lib';
import { PDFDocument, PDFFont, PDFImage } from '@cantoo/pdf-lib';
import type { TFunction } from 'i18next';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
@@ -1,4 +1,4 @@
import { rgb } from 'pdf-lib';
import { rgb } from '@cantoo/pdf-lib';
type RgbTuple = [number, number, number];
@@ -1,4 +1,4 @@
import { PDFFont } from 'pdf-lib';
import { PDFFont } from '@cantoo/pdf-lib';
export const wrapText = (text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] => {
const lines: string[] = [];
+43 -265
View File
@@ -1,32 +1,14 @@
import { useState, useEffect, useRef } from 'react';
import {
PDFDocument,
PDFDict,
PDFName,
PDFArray,
PDFString,
PDFHexString,
PDFNumber,
PDFRef,
PDFPage,
PDFContext,
} from 'pdf-lib';
} from '@cantoo/pdf-lib';
import {
PdfLibLink,
extractLinksFromPage,
} from '@app/utils/pdfLinkUtils';
export type LinkType = 'internal' | 'external' | 'unknown';
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;
}
export type { PdfLibLink };
export interface PdfLibLinksResult {
links: PdfLibLink[];
@@ -43,6 +25,9 @@ interface CachedDoc {
refCount: number;
/** Per-page extracted links (lazy, filled on first request). */
pageLinks: Map<number, { links: PdfLibLink[]; width: number; height: number }>;
/** Set to true when the PDF catalog/pages tree is invalid, so we
* skip link extraction on all subsequent calls without retrying. */
invalidCatalog?: boolean;
}
const docCache = new Map<string, Promise<CachedDoc>>();
@@ -54,11 +39,17 @@ async function acquireDocument(url: string): Promise<CachedDoc> {
const buffer = await response.arrayBuffer();
const doc = await PDFDocument.load(new Uint8Array(buffer), {
ignoreEncryption: true,
updateMetadata: false,
throwOnInvalidObject: false,
});
return { doc, refCount: 0, pageLinks: new Map() };
})();
docCache.set(url, promise);
promise.catch(() => {
docCache.delete(url);
});
}
const cached = await docCache.get(url)!;
cached.refCount++;
@@ -76,241 +67,7 @@ function releaseDocument(url: string): void {
});
}
function num(ctx: PDFContext, value: unknown): number {
if (value instanceof PDFRef) value = ctx.lookup(value);
if (value instanceof PDFNumber) return value.asNumber();
if (typeof value === 'number') return value;
return 0;
}
function str(ctx: PDFContext, value: unknown): string | undefined {
if (value instanceof PDFRef) value = ctx.lookup(value);
if (value instanceof PDFString) return value.decodeText();
if (value instanceof PDFHexString) return value.decodeText();
if (typeof value === 'string') return value;
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 {
// Swallow named dest resolution is best-effort
}
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 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') {
linkType = 'external';
uri = str(ctx, action.get(PDFName.of('F')));
} else if (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);
}
}
}
links.push({
id: `pdflib-link-${pageIndex}-${i}`,
annotIndex: i,
rect: { x: left, y: top, width, height },
type: linkType,
targetPage,
uri,
});
} catch (e) {
console.warn('[usePdfLibLinks] Failed to parse annotation:', e);
}
}
return links;
}
export function usePdfLibLinks(
pdfUrl: string | null,
@@ -350,20 +107,41 @@ export function usePdfLibLinks(
return;
}
if (cached.invalidCatalog) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
}
let pageData = cached.pageLinks.get(pageIndex);
if (!pageData) {
const pageCount = cached.doc.getPageCount();
let pageCount: number;
try {
pageCount = cached.doc.getPageCount();
} catch {
cached.invalidCatalog = true;
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
}
if (pageIndex < 0 || pageIndex >= pageCount) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
}
const page = cached.doc.getPage(pageIndex);
const { width, height } = page.getSize();
const links = extractLinksFromPage(cached.doc, page, pageIndex);
pageData = { links, width, height };
cached.pageLinks.set(pageIndex, pageData);
try {
const page = cached.doc.getPage(pageIndex);
const { width, height } = page.getSize();
const links = extractLinksFromPage(cached.doc, page, pageIndex);
pageData = { links, width, height };
cached.pageLinks.set(pageIndex, pageData);
} catch (pageError) {
console.warn(`[usePdfLibLinks] Failed to read page ${pageIndex}:`, pageError);
pageData = { links: [], width: 0, height: 0 };
cached.pageLinks.set(pageIndex, pageData);
}
}
if (!cancelled && mountedRef.current) {
@@ -377,7 +155,7 @@ export function usePdfLibLinks(
releaseDocument(url);
} catch (error) {
console.error('[usePdfLibLinks] Failed to extract links:', error);
console.warn('[usePdfLibLinks] Failed to extract links:', error);
if (!cancelled && mountedRef.current) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
}
@@ -1,4 +1,4 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from '@cantoo/pdf-lib';
import { downloadFile } from '@app/services/downloadService';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
+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;
});
}