Files
Stirling-PDF/frontend/src/core/components/viewer/ZoomAPIBridge.tsx
T
Balázs SzücsandGitHub 0a1d2effdc 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]>
2026-02-14 20:55:27 +00:00

264 lines
7.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useZoom, ZoomMode } from '@embedpdf/plugin-zoom/react';
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useFileState } from '@app/contexts/FileContext';
import {
determineAutoZoom,
DEFAULT_FALLBACK_ZOOM,
DEFAULT_VISIBILITY_THRESHOLD,
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();
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} />;
}
function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: zoom, state: zoomState } = useZoom(documentId);
const { spreadMode } = useSpread(documentId);
const { registerBridge, triggerImmediateZoomUpdate } = useViewer();
const { selectors } = useFileState();
const hasSetInitialZoom = useRef(false);
const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None);
const lastFileId = useRef<string | undefined>(undefined);
const lastAppliedZoom = useRef<number | null>(null);
const zoomRef = useRef(zoom);
const [autoZoomTick, setAutoZoomTick] = useState(0);
// Keep zoom ref updated
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
const scheduleAutoZoom = useCallback(() => {
hasSetInitialZoom.current = false;
lastAppliedZoom.current = null;
setAutoZoomTick((tick) => tick + 1);
}, []);
const requestFitWidth = useCallback(() => {
if (zoomRef.current) {
zoomRef.current.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 });
}
}, []);
const stubs = selectors.getStirlingFileStubs();
const firstFileStub = stubs[0];
const firstFileId = firstFileStub?.id;
// 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);
useEffect(() => {
if (!firstFileId) {
hasSetInitialZoom.current = false;
lastFileId.current = undefined;
lastAppliedZoom.current = null;
return;
}
if (firstFileId !== lastFileId.current) {
lastFileId.current = firstFileId;
scheduleAutoZoom();
}
}, [firstFileId, scheduleAutoZoom]);
useEffect(() => {
const currentSpreadMode = spreadMode ?? SpreadMode.None;
if (currentSpreadMode !== lastSpreadMode.current) {
lastSpreadMode.current = currentSpreadMode;
const hadTrackedAutoZoom = lastAppliedZoom.current !== null;
if (
zoomLevel === ZoomMode.FitWidth ||
zoomLevel === ZoomMode.Automatic ||
hadTrackedAutoZoom
) {
requestFitWidth();
scheduleAutoZoom();
}
}
}, [spreadMode, zoomLevel, scheduleAutoZoom, requestFitWidth]);
const isManagedZoom =
!!zoom &&
(zoomLevel === ZoomMode.FitWidth ||
zoomLevel === ZoomMode.Automatic ||
lastAppliedZoom.current !== null);
useFitWidthResize({
isManaged: isManagedZoom,
requestFitWidth,
onDebouncedResize: scheduleAutoZoom,
});
useEffect(() => {
if (!zoom || zoomLevel === undefined || currentZoomLevel === undefined) {
return;
}
if (!firstFileId) {
return;
}
if (hasSetInitialZoom.current) {
return;
}
if (zoomLevel !== ZoomMode.FitWidth) {
if (zoomLevel === ZoomMode.Automatic) {
requestFitWidth();
}
return;
}
const fitWidthZoom = currentZoomLevel;
if (!fitWidthZoom || fitWidthZoom <= 0 || fitWidthZoom === 1) {
return;
}
const applyTrackedZoom = (level: number | ZoomMode, effectiveZoom: number) => {
zoom.requestZoom(level, { vx: 0.5, vy: 0 });
lastAppliedZoom.current = effectiveZoom;
triggerImmediateZoomUpdate(Math.round(effectiveZoom * 100));
hasSetInitialZoom.current = true;
};
let cancelled = false;
const applyAutoZoom = async () => {
const currentSpreadMode = spreadMode ?? SpreadMode.None;
const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1;
if (cancelled) {
return;
}
const viewportWidth = window.innerWidth ?? 0;
const viewportHeight = window.innerHeight ?? 0;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return;
}
const decision = determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect: undefined,
metadataAspectRatio: metadataAspectRatio ?? null,
visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom: DEFAULT_FALLBACK_ZOOM,
});
if (decision.type === 'fallback') {
applyTrackedZoom(decision.zoom, decision.zoom);
return;
}
if (decision.type === 'fitWidth') {
applyTrackedZoom(ZoomMode.FitWidth, fitWidthZoom);
return;
}
applyTrackedZoom(decision.zoom, decision.zoom);
};
applyAutoZoom();
return () => {
cancelled = true;
};
}, [
zoom,
zoomLevel,
currentZoomLevel,
firstFileId,
metadataAspectRatio,
requestFitWidth,
autoZoomTick,
spreadMode,
triggerImmediateZoomUpdate,
]);
// 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;
}
zoomSubscriptionRef.current = zoom.onZoomChange((event: { newZoom?: number }) => {
if (typeof event?.newZoom !== 'number') {
return;
}
lastAppliedZoom.current = event.newZoom;
triggerImmediateZoomUpdate(Math.round(event.newZoom * 100));
});
return () => {
if (zoomSubscriptionRef.current) {
zoomSubscriptionRef.current();
zoomSubscriptionRef.current = null;
}
};
}, [zoom, triggerImmediateZoomUpdate]);
// Extract primitive values to avoid dependency on object reference
const zoomStateCurrentZoomLevel = zoomState?.currentZoomLevel;
// Register bridge - only re-run when actual values change
useEffect(() => {
const currentZoom = zoomRef.current;
if (!currentZoom || zoomStateCurrentZoomLevel === undefined) {
return;
}
const currentZoomLevel =
lastAppliedZoom.current ?? zoomStateCurrentZoomLevel ?? 1;
const newState = {
currentZoom: currentZoomLevel,
zoomPercent: Math.round(currentZoomLevel * 100),
};
triggerImmediateZoomUpdate(newState.zoomPercent);
registerBridge('zoom', {
state: newState,
api: currentZoom,
});
}, [zoomStateCurrentZoomLevel, registerBridge, triggerImmediateZoomUpdate]);
return null;
}