mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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;
|
||||
Reference in New Issue
Block a user