feat(pdf): replace PdfLib with Pdfium for form handling and general rendering tasks (#5899)

# Description of Changes

Improves PDF rendering in the viewer by adding digital signature field
support,
cleaning up overlay rendering, and migrating the contrast tool off
pdf-lib to PDFium WASM.

### Signature Field Overlay
- Added `SignatureFieldOverlay` component that renders digital signature
form fields
- Renders appearance streams when present; shows a fallback badge for
unsigned fields
- Uses PDFium WASM for bitmap extraction

### Overlay Rendering
- Integrated `SignatureFieldOverlay` and `ButtonAppearanceOverlay` into
`LocalEmbedPDF`
- Overlays are now clipped to page boundaries
- Clarified in `EmbedPdfViewer` that frontend overlays use PDFium WASM,
  backend overlays use PDFBox

### Contrast Tool Migration
- Replaced pdf-lib with PDFium WASM in `useAdjustContrastOperation`
- PDF page creation and image embedding now go through PDFium APIs
directly
- Updated bitmap handling and memory management accordingly

### Cleanup
- Fixed import ordering in viewer components
- Removed stale comments in the contrast operation hook

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] 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)
- [ ] I have performed a self-review of my own code
- [ ] 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)

- [ ] 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]>
Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
brios
2026-03-24 13:34:52 +00:00
committed by GitHub
co-authored by Reece Browne
parent 3ea11352e3
commit c3530024c4
34 changed files with 4299 additions and 1910 deletions
@@ -20,12 +20,11 @@ import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
// ─── Measure dictionary extraction ────────────────────────────────────────────
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
@@ -38,7 +37,7 @@ async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales |
if (!(measureObj instanceof PDFDict)) return null;
const rObj = measureObj.lookup(PDFName.of('R'));
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
? rObj.decodeText() : '';
? rObj.decodeText() : '';
// D = distance array, X = x-axis fallback
let fmtArray = measureObj.lookup(PDFName.of('D'));
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
@@ -213,7 +212,7 @@ const EmbedPdfViewerContent = ({
const isFormFillToolActive = (selectedTool as string) === 'formFill';
// Form overlays are shown in BOTH modes:
// - Normal viewer: form overlays visible (pdf-lib, frontend-only)
// - Normal viewer: form overlays visible (PDFium WASM, frontend-only)
// - formFill tool: form overlays visible (PDFBox, backend)
const shouldEnableFormFill = true;
@@ -881,7 +880,7 @@ const EmbedPdfViewerContent = ({
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
// Auto-fetch form fields when a PDF is loaded in the viewer.
// In normal viewer mode, this uses pdf-lib (frontend-only).
// In normal viewer mode, this uses PDFium WASM (frontend-only).
// In formFill tool mode, this uses PDFBox (backend).
const formFillFileIdRef = useRef<string | null>(null);
const formFillProviderRef = useRef(isFormFillToolActive);
@@ -61,6 +61,8 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
import { absoluteWithBasePath } from '@app/constants/app';
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
import { ButtonAppearanceOverlay } from '@app/tools/formFill/ButtonAppearanceOverlay';
import SignatureFieldOverlay from '@app/components/viewer/SignatureFieldOverlay';
import { CommentsSidebar } from '@app/components/viewer/CommentsSidebar';
import { CommentAuthorProvider } from '@app/contexts/CommentAuthorContext';
import { accountService } from '@app/services/accountService';
@@ -770,6 +772,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
width,
height,
position: 'relative',
overflow: 'hidden', // clip overlays (buttons, fields) that extend beyond the page rect
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
@@ -790,6 +793,16 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
</div>
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
@@ -801,6 +814,17 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
@@ -0,0 +1,250 @@
/**
* SignatureFieldOverlay — Renders digital-signature form fields on top of a
* PDF page.
*
* When a signature widget has an appearance stream (i.e. a visible graphic
* embedded by the signing tool), we render it via `EPDF_RenderAnnotBitmap`
* (an @embedpdf PDFium WASM extension) and paint the result into a `<canvas>`
* positioned at the correct overlay location. This is the same rendering
* path the engine itself uses for individual annotation bitmaps.
*
* For widgets without an appearance stream (unsigned fields, or fields whose
* PDF writer didn't embed one), we fall back to a translucent badge overlay.
*/
import React, { useEffect, useMemo, useRef, useState, memo } from 'react';
import {
renderSignatureFieldAppearances,
extractSignatures,
type SignatureFieldAppearance,
} from '@app/services/pdfiumService';
interface SignatureFieldOverlayProps {
pageIndex: number;
/** URL or File for the current PDF — used to extract signature data. */
pdfSource: File | Blob | null;
/** Document ID from EmbedPDF (kept for caller compatibility). */
documentId: string;
/** Rendered page width from Scroller (pixel space). */
pageWidth: number;
/** Rendered page height from Scroller (pixel space). */
pageHeight: number;
}
interface ResolvedSignatureField extends SignatureFieldAppearance {
/** Whether a cryptographic signature was found for this field. */
isSigned: boolean;
/** Signer reason string (if available). */
reason?: string;
/** Signing time string (if available). */
time?: string;
}
let _cachedSource: File | Blob | null = null;
let _cachedFields: ResolvedSignatureField[] = [];
let _cachePromise: Promise<ResolvedSignatureField[]> | null = null;
async function resolveFields(
source: File | Blob,
): Promise<ResolvedSignatureField[]> {
if (source === _cachedSource && _cachePromise) return _cachePromise;
_cachedSource = source;
_cachePromise = (async () => {
const buf = await source.arrayBuffer();
const [appearances, signatures] = await Promise.all([
renderSignatureFieldAppearances(buf),
extractSignatures(buf),
]);
return appearances.map((f, i) => {
// Positional correlation is only reliable when both arrays have the same
// length — i.e. one signature object per signature field in document order.
// When the counts differ we cannot safely attribute reason/time per-field,
// so we fall back to a whole-document "is signed" indicator.
const exactMatch = appearances.length === signatures.length;
const matchedSig = exactMatch ? signatures[i] : undefined;
return {
...f,
isSigned: exactMatch ? i < signatures.length : signatures.length > 0,
reason: matchedSig?.reason,
time: matchedSig?.time,
};
});
})();
_cachedFields = await _cachePromise;
return _cachedFields;
}
function SignatureBitmapCanvas({
imageData,
cssWidth,
cssHeight,
}: {
imageData: ImageData;
cssWidth: number;
cssHeight: number;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d');
if (ctx) ctx.putImageData(imageData, 0, 0);
}, [imageData]);
return (
<canvas
ref={canvasRef}
style={{
width: cssWidth,
height: cssHeight,
display: 'block',
}}
/>
);
}
function SignatureFieldOverlayInner({
pageIndex,
pdfSource,
documentId: _documentId,
pageWidth,
pageHeight,
}: SignatureFieldOverlayProps) {
const [fields, setFields] = useState<ResolvedSignatureField[]>([]);
useEffect(() => {
if (!pdfSource) {
setFields([]);
return;
}
let cancelled = false;
resolveFields(pdfSource).then((res) => {
if (!cancelled) setFields(res);
}).catch(() => {
if (!cancelled) setFields([]);
});
return () => { cancelled = true; };
}, [pdfSource]);
const pageFields = useMemo(
() => fields.filter((f) => f.pageIndex === pageIndex),
[fields, pageIndex],
);
if (pageFields.length === 0) return null;
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 6,
}}
data-signature-overlay-page={pageIndex}
>
{pageFields.map((field, idx) => {
// Use the source PDF page dimensions that the extraction used for
// coordinate computation. This avoids mismatches with pdfPage.size
// from EmbedPDF which may report different dimensions.
const sx = field.sourcePageWidth > 0 ? pageWidth / field.sourcePageWidth : 1;
const sy = field.sourcePageHeight > 0 ? pageHeight / field.sourcePageHeight : 1;
const left = field.x * sx;
const top = field.y * sy;
const width = field.width * sx;
const height = field.height * sy;
// If we have a rendered appearance bitmap, paint it via <canvas>.
if (field.imageData) {
return (
<div
key={`sig-${field.fieldName}-${idx}`}
style={{
position: 'absolute',
left,
top,
width,
height,
overflow: 'hidden',
pointerEvents: 'auto',
cursor: 'default',
}}
title={
field.isSigned
? `Signed${field.reason ? `: ${field.reason}` : ''}${field.time ? ` (${field.time})` : ''}`
: `Signature field: ${field.fieldName}`
}
>
<SignatureBitmapCanvas
imageData={field.imageData}
cssWidth={width}
cssHeight={height}
/>
</div>
);
}
// Fallback: translucent badge for fields without an appearance.
return (
<div
key={`sig-${field.fieldName}-${idx}`}
style={{
position: 'absolute',
left,
top,
width,
height,
border: field.isSigned
? '2px solid rgba(34, 139, 34, 0.7)'
: '2px dashed rgba(180, 180, 180, 0.7)',
borderRadius: 4,
background: field.isSigned
? 'rgba(34, 139, 34, 0.08)'
: 'rgba(200, 200, 200, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
pointerEvents: 'auto',
cursor: 'default',
}}
title={
field.isSigned
? `Signed${field.reason ? `: ${field.reason}` : ''}${field.time ? ` (${field.time})` : ''}`
: `Unsigned signature field: ${field.fieldName}`
}
>
<span
style={{
fontSize: Math.min(height * 0.35, 14),
color: field.isSigned ? 'rgba(34, 139, 34, 0.85)' : 'rgba(120, 120, 120, 0.85)',
fontWeight: 600,
textAlign: 'center',
lineHeight: 1.2,
padding: '2px 4px',
userSelect: 'none',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
maxWidth: '100%',
}}
>
{field.isSigned ? '🔒 Signed' : '✎ Signature'}
</span>
</div>
);
})}
</div>
);
}
const SignatureFieldOverlay = memo(SignatureFieldOverlayInner);
export default SignatureFieldOverlay;