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 <bszucs1209@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
This commit is contained in:
brios
2026-03-24 14:34:52 +01:00
committed by GitHub
parent 3ea11352e3
commit c3530024c4
34 changed files with 4299 additions and 1910 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -1,10 +1,14 @@
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 '@cantoo/pdf-lib';
import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import {
getPdfiumModule,
saveRawDocument,
} from '@app/services/pdfiumService';
import { copyRgbaToBgraHeap } from '@app/utils/pdfiumBitmapUtils';
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
const page = await pdf.getPage(pageNumber);
@@ -18,37 +22,94 @@ async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number
return canvas;
}
// adjustment logic moved to shared util
// Render, adjust, and assemble all pages of a single PDF into a new PDF
// Render, adjust, and assemble all pages of a single PDF into a new PDF using PDFium
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
const m = await getPdfiumModule();
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
const pageCount = pdf.numPages;
const newDoc = await PDFLibDocument.create();
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error('PDFium: failed to create document');
for (let p = 1; p <= pageCount; p++) {
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
const pngUrl = adjusted.toDataURL('image/png');
const res = await fetch(pngUrl);
const pngBytes = new Uint8Array(await res.arrayBuffer());
const embedded = await newDoc.embedPng(pngBytes);
const { width, height } = embedded.scale(1);
const page = newDoc.addPage([width, height]);
page.drawImage(embedded, { x: 0, y: 0, width, height });
try {
for (let p = 1; p <= pageCount; p++) {
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
const ctx = adjusted.getContext('2d');
if (!ctx) {
console.warn(`[adjustContrast] Skipping page ${p}: failed to get canvas context`);
continue;
}
const imageData = ctx.getImageData(0, 0, adjusted.width, adjusted.height);
const imgWidth = imageData.width;
const imgHeight = imageData.height;
// Since we render at scale 2, the actual PDF page size is half
const pdfPageWidth = imgWidth / 2;
const pdfPageHeight = imgHeight / 2;
const pagePtr = m.FPDFPage_New(docPtr, p - 1, pdfPageWidth, pdfPageHeight);
if (!pagePtr) {
console.warn(`[adjustContrast] Skipping page ${p}: failed to create PDFium page`);
continue;
}
let bitmapPtr = 0;
try {
bitmapPtr = m.FPDFBitmap_Create(imgWidth, imgHeight, 1);
if (!bitmapPtr) {
console.warn(`[adjustContrast] Skipping page ${p}: failed to create bitmap`);
continue;
}
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
copyRgbaToBgraHeap(m, new Uint8Array(imageData.data.buffer), bufferPtr, imgWidth, imgHeight, stride);
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (imageObjPtr) {
const setBitmapOk = m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr);
if (setBitmapOk) {
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
try {
m.pdfium.setValue(matrixPtr, pdfPageWidth, 'float');
m.pdfium.setValue(matrixPtr + 4, 0, 'float');
m.pdfium.setValue(matrixPtr + 8, 0, 'float');
m.pdfium.setValue(matrixPtr + 12, pdfPageHeight, 'float');
m.pdfium.setValue(matrixPtr + 16, 0, 'float');
m.pdfium.setValue(matrixPtr + 20, 0, 'float');
if (m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
} else {
m.FPDFPageObj_Destroy(imageObjPtr);
}
} finally {
m.pdfium.wasmExports.free(matrixPtr);
}
} else {
m.FPDFPageObj_Destroy(imageObjPtr);
}
}
} finally {
if (bitmapPtr) m.FPDFBitmap_Destroy(bitmapPtr);
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
}
}
const pdfBytes = await saveRawDocument(docPtr);
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
pdfWorkerManager.destroyDocument(pdf);
return out;
} finally {
m.FPDF_CloseDocument(docPtr);
}
const pdfBytes = await newDoc.save();
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
pdfWorkerManager.destroyDocument(pdf);
return out;
}
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<CustomProcessorResult> {
// Limit concurrency to avoid exhausting memory/CPU while still getting speedups
// Heuristic: use up to 4 workers on capable machines, otherwise 2-3
let CONCURRENCY_LIMIT = 2;
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
@@ -85,7 +146,6 @@ export const adjustContrastOperationConfig = {
customProcessor: processPdfClientSide,
operationType: 'adjustContrast',
defaultParameters,
// Single-step settings component for Automate
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
} as const;
@@ -96,4 +156,3 @@ export const useAdjustContrastOperation = () => {
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
});
};

View File

@@ -2,73 +2,49 @@ 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 '@cantoo/pdf-lib';
// Client-side PDF processing using PDF-lib
import {
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
} from '@app/services/pdfiumService';
// Client-side PDF processing using PDFium WASM
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
for (const file of files) {
try {
// Load the PDF
const m = await getPdfiumModule();
const fileArrayBuffer = await file.arrayBuffer();
const pdfBytesIn = new Uint8Array(fileArrayBuffer);
const pdfDoc = await PDFDocument.load(pdfBytesIn, { ignoreEncryption: true });
const ctx = pdfDoc.context;
const docPtr = await openRawDocumentSafe(fileArrayBuffer);
const pages = pdfDoc.getPages();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
// Annots() returns PDFArray | undefined
const annots = page.node.Annots();
if (!annots || annots.size() === 0) continue;
// Delete each annotation object (they are usually PDFRef)
for (let j = annots.size() - 1; j >= 0; j--) {
try {
const entry = annots.get(j);
if (entry instanceof PDFRef) {
ctx.delete(entry);
} else if (entry instanceof PDFDict) {
// In practice, Annots array should contain refs; if not, just remove the array linkage.
// (We avoid poking internal maps to find a ref for the dict.)
}
} catch (err) {
console.warn(`Failed to remove annotation ${j} on page ${i + 1}:`, err);
}
}
// Remove the Annots key entirely
try {
if (page.node.has(PDFName.of('Annots'))) {
page.node.delete(PDFName.of('Annots'));
}
} catch (err) {
console.warn(`Failed to delete /Annots on page ${i + 1}:`, err);
}
}
// Optional: if removing ALL annotations across the doc, strip AcroForm to avoid dangling widget refs
try {
const catalog = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Root);
if (catalog && 'has' in catalog && 'delete' in catalog) {
const catalogDict = catalog as any;
if (catalogDict.has(PDFName.of('AcroForm'))) {
catalogDict.delete(PDFName.of('AcroForm'));
const pageCount = m.FPDF_GetPageCount(docPtr);
for (let i = 0; i < pageCount; i++) {
const pagePtr = m.FPDF_LoadPage(docPtr, i);
if (!pagePtr) continue;
// Remove all annotations from the page (iterate backward)
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
for (let j = annotCount - 1; j >= 0; j--) {
try {
m.FPDFPage_RemoveAnnot(pagePtr, j);
} catch (err) {
console.warn(`Failed to remove annotation ${j} on page ${i + 1}:`, err);
}
}
m.FPDF_ClosePage(pagePtr);
}
} catch (err) {
console.warn('Failed to remove /AcroForm:', err);
const outBytes = await saveRawDocument(docPtr);
const processedFile = new File([outBytes], file.name, { type: 'application/pdf' });
processedFiles.push(processedFile);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
// Save returns Uint8Array — safe for Blob
const outBytes = await pdfDoc.save();
const outBlob = new Blob([new Uint8Array(outBytes)], { type: 'application/pdf' });
// Create new file with original name
const processedFile = new File([outBlob], file.name, { type: 'application/pdf' });
processedFiles.push(processedFile);
} catch (error) {
console.error('Error processing file:', file.name, error);
throw new Error(

View File

@@ -1,11 +1,11 @@
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
import { PdfiumFont, PdfiumPage, rgb } from '@app/services/pdfiumDocBuilder';
import { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
interface DrawCenteredMessageOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
page: PdfiumPage;
font: PdfiumFont;
fontBold: PdfiumFont;
text: string;
description: string;
marginX: number;

View File

@@ -1,11 +1,11 @@
import { PDFFont, PDFPage } from '@cantoo/pdf-lib';
import { PdfiumFont, PdfiumPage } from '@app/services/pdfiumDocBuilder';
import { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
interface FieldBoxOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
page: PdfiumPage;
font: PdfiumFont;
fontBold: PdfiumFont;
x: number;
top: number;
width: number;

View File

@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFPage } from '@cantoo/pdf-lib';
import { PdfiumFont, PdfiumPage } from '@app/services/pdfiumDocBuilder';
import { SignatureValidationSignature } from '@app/types/validateSignature';
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
import { drawStatusBadge } from '@app/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection';
@@ -8,15 +8,15 @@ import { formatDate } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
interface DrawSignatureSectionOptions {
page: PDFPage;
page: PdfiumPage;
cursorY: number;
signature: SignatureValidationSignature;
index: number;
marginX: number;
contentWidth: number;
columnGap: number;
font: PDFFont;
fontBold: PDFFont;
font: PdfiumFont;
fontBold: PdfiumFont;
t: TFunction<'translation'>;
}
@@ -106,7 +106,7 @@ export const drawSignatureSection = ({
thickness: 1,
color: colorPalette.boxBorder,
});
nextY -= 20;
nextY -= 20;
const certificateFields = [
{ label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },

View File

@@ -1,9 +1,9 @@
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
import { PdfiumFont, PdfiumPage, rgb } from '@app/services/pdfiumDocBuilder';
interface StatusBadgeOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
page: PdfiumPage;
font: PdfiumFont;
fontBold: PdfiumFont;
text: string;
x: number;
y: number;

View File

@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFImage, PDFPage } from '@cantoo/pdf-lib';
import { PdfiumFont, PdfiumImage, PdfiumPage } from '@app/services/pdfiumDocBuilder';
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';
@@ -7,17 +7,17 @@ import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalett
import { formatFileSize } from '@app/hooks/tools/validateSignature/utils/pdfText';
interface DrawSummarySectionOptions {
page: PDFPage;
page: PdfiumPage;
cursorY: number;
entry: SignatureValidationReportEntry;
font: PDFFont;
fontBold: PDFFont;
font: PdfiumFont;
fontBold: PdfiumFont;
marginX: number;
contentWidth: number;
columnGap: number;
statusText: string;
statusColor: (typeof colorPalette)['success'];
loadThumbnail: (url: string) => Promise<{ image: PDFImage } | null>;
loadThumbnail: (url: string) => Promise<{ image: PdfiumImage } | null>;
t: TFunction<'translation'>;
}

View File

@@ -1,9 +1,9 @@
import { PDFFont, PDFPage, PDFImage } from '@cantoo/pdf-lib';
import { PdfiumFont, PdfiumPage, PdfiumImage } from '@app/services/pdfiumDocBuilder';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
export const drawThumbnailPlaceholder = (
page: PDFPage,
fontBold: PDFFont,
page: PdfiumPage,
fontBold: PdfiumFont,
x: number,
top: number,
width: number,
@@ -35,8 +35,8 @@ export const drawThumbnailPlaceholder = (
};
export const drawThumbnailImage = (
page: PDFPage,
image: PDFImage,
page: PdfiumPage,
image: PdfiumImage,
x: number,
top: number,
width: number,

View File

@@ -1,4 +1,4 @@
import { PDFDocument, PDFPage, StandardFonts } from '@cantoo/pdf-lib';
import { PdfiumDocument, PdfiumPage, StandardFonts } from '@app/services/pdfiumDocBuilder';
import type { TFunction } from 'i18next';
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
import { REPORT_PDF_FILENAME } from '@app/hooks/tools/validateSignature/utils/signatureUtils';
@@ -16,7 +16,7 @@ const MARGIN_Y = 22;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
const COLUMN_GAP = 18;
const drawDivider = (page: PDFPage, marginX: number, contentWidth: number, y: number) => {
const drawDivider = (page: PdfiumPage, marginX: number, contentWidth: number, y: number) => {
page.drawLine({
start: { x: marginX, y },
end: { x: marginX + contentWidth, y },
@@ -29,7 +29,7 @@ export const createReportPdf = async (
entries: SignatureValidationReportEntry[],
t: TFunction<'translation'>
): Promise<File> => {
const doc = await PDFDocument.create();
const doc = await PdfiumDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const loadThumbnail = createThumbnailLoader(doc);
@@ -101,7 +101,6 @@ export const createReportPdf = async (
}
for (let i = 0; i < entry.signatures.length; i += 1) {
// After the first signature, start a new page per signature
if (i > 0) {
({ page, cursorY } = startReportPage({
doc,

View File

@@ -1,11 +1,11 @@
import { PDFDocument, PDFFont, PDFImage } from '@cantoo/pdf-lib';
import { PdfiumDocument, PdfiumFont, PdfiumImage } from '@app/services/pdfiumDocBuilder';
import type { TFunction } from 'i18next';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
interface StartPageParams {
doc: PDFDocument;
font: PDFFont;
fontBold: PDFFont;
doc: PdfiumDocument;
font: PdfiumFont;
fontBold: PdfiumFont;
marginX: number;
marginY: number;
contentWidth: number;
@@ -63,8 +63,8 @@ export const startReportPage = ({
return { page, cursorY };
};
export const createThumbnailLoader = (doc: PDFDocument) => {
const cache = new Map<string, { image: PDFImage } | null>();
export const createThumbnailLoader = (doc: PdfiumDocument) => {
const cache = new Map<string, { image: PdfiumImage } | null>();
return async (url: string) => {
if (cache.has(url)) {
@@ -75,7 +75,7 @@ export const createThumbnailLoader = (doc: PDFDocument) => {
const response = await fetch(url);
const bytes = new Uint8Array(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || '';
let image: PDFImage;
let image: PdfiumImage;
if (contentType.includes('png')) {
image = await doc.embedPng(bytes);

View File

@@ -1,4 +1,4 @@
import { rgb } from '@cantoo/pdf-lib';
import { rgb } from '@app/services/pdfiumDocBuilder';
type RgbTuple = [number, number, number];
@@ -21,7 +21,7 @@ const defaultLightPalette: Record<
const toRgb = ([r, g, b]: RgbTuple) => rgb(r / 255, g / 255, b / 255);
/**
* Utility function to get CSS variable values and convert them to pdf-lib RGB format.
* Utility function to get CSS variable values and convert them to RGB format.
* Falls back to sensible defaults when the CSS variable cannot be resolved.
*/
function getCssVariableAsRgb(variableName: string, fallback: RgbTuple) {

View File

@@ -1,6 +1,6 @@
import { PDFFont } from '@cantoo/pdf-lib';
import { PdfiumFont } from '@app/services/pdfiumDocBuilder';
export const wrapText = (text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] => {
export const wrapText = (text: string, font: PdfiumFont, fontSize: number, maxWidth: number): string[] => {
const lines: string[] = [];
const paragraphs = text.split(/\r?\n/);

View File

@@ -1,7 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import {
PDFDocument,
} from '@cantoo/pdf-lib';
import {
PdfLibLink,
extractLinksFromPage,
@@ -20,14 +17,14 @@ export interface PdfLibLinksResult {
}
interface CachedDoc {
doc: PDFDocument;
data: ArrayBuffer;
/** Number of active consumers (hook instances) holding this entry. */
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
/** Set to true when the PDF is invalid, so we
* skip link extraction on all subsequent calls without retrying. */
invalidCatalog?: boolean;
invalidDocument?: boolean;
}
const docCache = new Map<string, Promise<CachedDoc>>();
@@ -37,13 +34,8 @@ async function acquireDocument(url: string): Promise<CachedDoc> {
const promise = (async (): Promise<CachedDoc> => {
const response = await fetch(url);
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() };
return { data: buffer, refCount: 0, pageLinks: new Map() };
})();
docCache.set(url, promise);
@@ -107,7 +99,7 @@ export function usePdfLibLinks(
return;
}
if (cached.invalidCatalog) {
if (cached.invalidDocument) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
@@ -115,27 +107,12 @@ export function usePdfLibLinks(
let pageData = cached.pageLinks.get(pageIndex);
if (!pageData) {
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;
}
try {
const page = cached.doc.getPage(pageIndex);
const { width, height } = page.getSize();
const links = extractLinksFromPage(cached.doc, page, pageIndex);
pageData = { links, width, height };
const { links, pdfPageWidth, pdfPageHeight } = await extractLinksFromPage(
cached.data,
pageIndex,
);
pageData = { links, width: pdfPageWidth, height: pdfPageHeight };
cached.pageLinks.set(pageIndex, pageData);
} catch (pageError) {
console.warn(`[usePdfLibLinks] Failed to read page ${pageIndex}:`, pageError);

View File

@@ -1,7 +1,19 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from '@cantoo/pdf-lib';
import {
getPdfiumModule,
openRawDocumentSafe,
closeRawDocument,
saveRawDocument,
importPages,
setPageRotation,
addNewPage,
} from '@app/services/pdfiumService';
import { downloadFile } from '@app/services/downloadService';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
// A4 dimensions in PDF points (72 dpi)
const A4_WIDTH = 595.276;
const A4_HEIGHT = 841.890;
export interface ExportOptions {
selectedOnly?: boolean;
filename?: string;
@@ -19,7 +31,6 @@ export class PDFExportService {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
@@ -28,10 +39,8 @@ export class PDFExportService {
throw new Error('No pages to export');
}
// Load original PDF and create new document
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const blob = await this.createSingleDocument(originalPDFBytes, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name);
return { blob, filename: exportFilename };
@@ -56,7 +65,6 @@ export class PDFExportService {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
@@ -79,102 +87,140 @@ export class PDFExportService {
}
/**
* Create a PDF document from multiple source files
* Create a PDF document from multiple source files using PDFium WASM.
*/
private async createMultiSourceDocument(
sourceFiles: Map<string, File>,
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
const m = await getPdfiumModule();
// Create destination document
const destDocPtr = m.FPDF_CreateNewDocument();
if (!destDocPtr) throw new Error('PDFium: failed to create destination document');
// Load all source documents once and cache them
const loadedDocs = new Map<string, PDFLibDocument>();
const loadedDocs = new Map<string, number>();
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
loadedDocs.set(fileId, doc);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
}
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
blankPage.setRotation(degrees(page.rotation));
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
// Get the correct source document for this page
const sourceDoc = loadedDocs.get(page.originalFileId)!;
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page from the correct source document
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
copiedPage.setRotation(degrees(page.rotation));
newDoc.addPage(copiedPage);
try {
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const docPtr = await openRawDocumentSafe(arrayBuffer);
loadedDocs.set(fileId, docPtr);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
} else {
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
}
let insertIdx = 0;
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Insert a blank A4 page
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
// Apply rotation
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
const srcDocPtr = loadedDocs.get(page.originalFileId)!;
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
// Import the specific page (1-based page range for FPDF_ImportPages)
const pageRange = String(sourcePageIndex + 1);
const imported = await importPages(destDocPtr, srcDocPtr, pageRange, insertIdx);
if (!imported) {
console.warn(
`[PDFExport] importPages failed for fileId=${page.originalFileId} pageRange=${pageRange} — page will be missing from output.`,
);
}
// Apply rotation
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
}
} else {
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
}
}
// Save the assembled document
const resultBuf = await saveRawDocument(destDocPtr);
return new Blob([resultBuf], { type: 'application/pdf' });
} finally {
// Cleanup all loaded source documents
for (const docPtr of loadedDocs.values()) {
await closeRawDocument(docPtr);
}
m.FPDF_CloseDocument(destDocPtr);
}
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
}
/**
* Create a single PDF document with all operations applied (single source)
* Create a single PDF document with all operations applied (single source) using PDFium.
*/
private async createSingleDocument(
sourceDoc: PDFLibDocument,
sourceData: ArrayBuffer,
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
const m = await getPdfiumModule();
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
blankPage.setRotation(degrees(page.rotation));
} else {
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
copiedPage.setRotation(degrees(page.rotation));
newDoc.addPage(copiedPage);
}
}
// Open source document
const srcDocPtr = await openRawDocumentSafe(sourceData);
const destDocPtr = m.FPDF_CreateNewDocument();
if (!destDocPtr) {
await closeRawDocument(srcDocPtr);
throw new Error('PDFium: failed to create destination document');
}
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
try {
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
let insertIdx = 0;
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
} else {
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
const pageRange = String(sourcePageIndex + 1);
const imported = await importPages(destDocPtr, srcDocPtr, pageRange, insertIdx);
if (!imported) {
console.warn(
`[PDFExport] importPages failed for page ${page.originalPageNumber} pageRange=${pageRange} — page will be missing from output.`,
);
}
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
}
}
}
const resultBuf = await saveRawDocument(destDocPtr);
return new Blob([resultBuf], { type: 'application/pdf' });
} finally {
await closeRawDocument(srcDocPtr);
m.FPDF_CloseDocument(destDocPtr);
}
}
/**
* Generate appropriate filename for export
*/
@@ -183,7 +229,6 @@ export class PDFExportService {
return `${baseName}.pdf`;
}
/**
* Download a single file
*/
@@ -198,7 +243,7 @@ export class PDFExportService {
blobs.forEach((blob, index) => {
setTimeout(() => {
this.downloadFile(blob, filenames[index]);
}, index * 500); // Stagger downloads
}, index * 500);
});
}
@@ -241,9 +286,8 @@ export class PDFExportService {
const splitCount = pagesToExport.reduce((count, page) => {
return count + (page.splitAfter ? 1 : 0);
}, 1); // At least 1 document
}, 1);
// Rough size estimation (very approximate)
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
const estimatedBytes = avgPageSize * pagesToExport.length;
const estimatedSize = this.formatFileSize(estimatedBytes);
@@ -267,5 +311,18 @@ export class PDFExportService {
}
}
/**
* Convert degrees (0, 90, 180, 270) to PDFium rotation enum (0, 1, 2, 3).
*/
function degreesToPdfiumRotation(degrees: number): number {
const normalized = ((degrees % 360) + 360) % 360;
switch (normalized) {
case 90: return 1;
case 180: return 2;
case 270: return 3;
default: return 0;
}
}
// Export singleton instance
export const pdfExportService = new PDFExportService();

View File

@@ -34,6 +34,7 @@ class PDFWorkerManager {
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url
).toString();
(GlobalWorkerOptions as any).docBaseUrl = undefined;
this.isInitialized = true;
}
}
@@ -74,13 +75,17 @@ class PDFWorkerManager {
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
verbosity: options.verbosity ?? 0,
// Suppress warnings about unimplemented widget types and other non-critical issues
isEvalSupported: false,
} : {
...pdfData,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
verbosity: options.verbosity ?? 0,
// Suppress warnings about unimplemented widget types and other non-critical issues
isEvalSupported: false,
}
);

View File

@@ -0,0 +1,405 @@
/**
* pdfiumDocBuilder — High-level PDF document builder powered by PDFium WASM.
*
* Provides an API surface compatible with common pdf-lib patterns
* (drawText, drawRectangle, drawLine, drawImage, font metrics, etc.)
* so that code previously using pdf-lib for PDF *generation* can be
* migrated with minimal changes.
*
* Used by the signature validation report system.
*/
import { getPdfiumModule, writeUtf16, saveRawDocument } from '@app/services/pdfiumService';
import { embedBitmapImageOnPage } from '@app/utils/pdfiumBitmapUtils';
import type { WrappedPdfiumModule } from '@embedpdf/pdfium';
// ---------------------------------------------------------------------------
// Color type (replaces pdf-lib's `rgb()`)
// ---------------------------------------------------------------------------
export interface PdfiumColor {
_r: number;
_g: number;
_b: number;
}
/**
* Create an RGB color (each component 01). Drop-in replacement for pdf-lib's `rgb()`.
*/
export function rgb(r: number, g: number, b: number): PdfiumColor {
return { _r: r, _g: g, _b: b };
}
function colorToRGBA(c: PdfiumColor): [number, number, number, number] {
return [Math.round(c._r * 255), Math.round(c._g * 255), Math.round(c._b * 255), 255];
}
// ---------------------------------------------------------------------------
// Font abstraction
// ---------------------------------------------------------------------------
/** Standard PDF font names matching pdf-lib's StandardFonts enum. */
export const StandardFonts = {
Helvetica: 'Helvetica',
HelveticaBold: 'Helvetica-Bold',
HelveticaOblique: 'Helvetica-Oblique',
HelveticaBoldOblique: 'Helvetica-BoldOblique',
Courier: 'Courier',
CourierBold: 'Courier-Bold',
TimesRoman: 'Times-Roman',
TimesBold: 'Times-Bold',
} as const;
export class PdfiumFont {
readonly name: string;
private _canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
private _ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null;
constructor(name: string) {
this.name = name;
}
/**
* Measure the width of `text` at the given `fontSize` (in PDF points).
* Uses canvas font metrics as a reliable cross-browser measurement.
*/
widthOfTextAtSize(text: string, fontSize: number): number {
const ctx = this._getCtx();
ctx.font = `${fontSize}px "${this._cssFontFamily()}"`;
return ctx.measureText(text).width;
}
/**
* Returns the line height for a given font size (approximation).
*/
heightAtSize(fontSize: number): number {
return fontSize * 1.2;
}
/** Map PDF font name to a CSS font-family for canvas measurement. */
private _cssFontFamily(): string {
if (this.name.startsWith('Helvetica')) return 'Helvetica, Arial, sans-serif';
if (this.name.startsWith('Courier')) return 'Courier, monospace';
if (this.name.startsWith('Times')) return 'Times New Roman, serif';
return 'sans-serif';
}
private _getCtx(): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D {
if (this._ctx) return this._ctx;
if (typeof OffscreenCanvas !== 'undefined') {
this._canvas = new OffscreenCanvas(1, 1);
this._ctx = this._canvas.getContext('2d')!;
} else {
this._canvas = document.createElement('canvas');
this._ctx = this._canvas.getContext('2d')!;
}
return this._ctx;
}
}
// ---------------------------------------------------------------------------
// Image abstraction
// ---------------------------------------------------------------------------
export class PdfiumImage {
readonly width: number;
readonly height: number;
/** Raw RGBA pixel data */
readonly _rgba: Uint8Array;
constructor(rgba: Uint8Array, width: number, height: number) {
this._rgba = rgba;
this.width = width;
this.height = height;
}
/** Returns scaled dimensions (matches pdf-lib's `PDFImage.scale()`). */
scale(factor: number): { width: number; height: number } {
return { width: this.width * factor, height: this.height * factor };
}
/** Scale to fit within maxWidth × maxHeight preserving aspect ratio (matches pdf-lib's `PDFImage.scaleToFit()`). */
scaleToFit(maxWidth: number, maxHeight: number): { width: number; height: number } {
const ratio = Math.min(maxWidth / this.width, maxHeight / this.height, 1);
return { width: this.width * ratio, height: this.height * ratio };
}
}
// ---------------------------------------------------------------------------
// Page abstraction
// ---------------------------------------------------------------------------
export interface DrawTextOptions {
x: number;
y: number;
size: number;
font?: PdfiumFont;
color?: PdfiumColor;
}
export interface DrawRectangleOptions {
x: number;
y: number;
width: number;
height: number;
color?: PdfiumColor;
borderColor?: PdfiumColor;
borderWidth?: number;
opacity?: number;
}
export interface DrawImageOptions {
x: number;
y: number;
width: number;
height: number;
}
export interface DrawLineOptions {
start: { x: number; y: number };
end: { x: number; y: number };
thickness?: number;
color?: PdfiumColor;
}
export class PdfiumPage {
readonly _m: WrappedPdfiumModule;
readonly _docPtr: number;
readonly _pagePtr: number;
readonly width: number;
readonly height: number;
constructor(m: WrappedPdfiumModule, docPtr: number, pagePtr: number, width: number, height: number) {
this._m = m;
this._docPtr = docPtr;
this._pagePtr = pagePtr;
this.width = width;
this.height = height;
}
/**
* Draw text on the page. API compatible with pdf-lib's `PDFPage.drawText()`.
*/
drawText(text: string, options: DrawTextOptions): void {
const { x, y, size, font, color } = options;
const m = this._m;
const fontName = font?.name ?? 'Helvetica';
const textObjPtr = m.FPDFPageObj_NewTextObj(this._docPtr, fontName, size);
if (!textObjPtr) return;
// Set text content (UTF-16)
const textPtr = writeUtf16(m, text);
m.FPDFText_SetText(textObjPtr, textPtr);
m.pdfium.wasmExports.free(textPtr);
// Set color
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetFillColor(textObjPtr, r, g, b, a);
}
// Position the text object
// FPDFPageObj_Transform(obj, a, b, c, d, e, f) — applies affine transform
// Identity + translate: a=1, b=0, c=0, d=1, e=x, f=y
m.FPDFPageObj_Transform(textObjPtr, 1, 0, 0, 1, x, y);
m.FPDFPage_InsertObject(this._pagePtr, textObjPtr);
}
/**
* Draw a rectangle on the page. API compatible with pdf-lib's `PDFPage.drawRectangle()`.
*/
drawRectangle(options: DrawRectangleOptions): void {
const { x, y, width, height, color, borderColor, borderWidth = 1 } = options;
const m = this._m;
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, x + width, y);
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
m.FPDFPath_LineTo(pathPtr, x, y + height);
m.FPDFPath_Close(pathPtr);
let hasFill = false;
let hasStroke = false;
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetFillColor(pathPtr, r, g, b, a);
hasFill = true;
}
if (borderColor) {
const [r, g, b, a] = colorToRGBA(borderColor);
m.FPDFPageObj_SetStrokeColor(pathPtr, r, g, b, a);
m.FPDFPageObj_SetStrokeWidth(pathPtr, borderWidth);
hasStroke = true;
}
// Fill mode: 0 = none, 1 = alternate, 2 = winding
const fillMode = hasFill ? 1 : 0;
m.FPDFPath_SetDrawMode(pathPtr, fillMode, hasStroke);
m.FPDFPage_InsertObject(this._pagePtr, pathPtr);
}
/**
* Draw a line on the page. API compatible with pdf-lib's `PDFPage.drawLine()`.
*/
drawLine(options: DrawLineOptions): void {
const { start, end, thickness = 1, color } = options;
const m = this._m;
const pathPtr = m.FPDFPageObj_CreateNewPath(start.x, start.y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, end.x, end.y);
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetStrokeColor(pathPtr, r, g, b, a);
}
m.FPDFPageObj_SetStrokeWidth(pathPtr, thickness);
// fillMode=0 (no fill), stroke=true
m.FPDFPath_SetDrawMode(pathPtr, 0, true);
m.FPDFPage_InsertObject(this._pagePtr, pathPtr);
}
/**
* Draw an image on the page. API compatible with pdf-lib's `PDFPage.drawImage()`.
*/
drawImage(image: PdfiumImage, options: DrawImageOptions): void {
const { x, y, width, height } = options;
embedBitmapImageOnPage(
this._m, this._docPtr, this._pagePtr,
{ rgba: image._rgba, width: image.width, height: image.height },
x, y, width, height,
);
}
/** Finalize the page content stream. Called internally by PdfiumDocument. */
_generateContent(): void {
this._m.FPDFPage_GenerateContent(this._pagePtr);
}
/** Close the page pointer. Called internally by PdfiumDocument. */
_close(): void {
this._m.FPDF_ClosePage(this._pagePtr);
}
}
// ---------------------------------------------------------------------------
// Document abstraction
// ---------------------------------------------------------------------------
export class PdfiumDocument {
readonly _m: WrappedPdfiumModule;
readonly _docPtr: number;
private _pages: PdfiumPage[] = [];
private _fonts: Map<string, PdfiumFont> = new Map();
private constructor(m: WrappedPdfiumModule, docPtr: number) {
this._m = m;
this._docPtr = docPtr;
}
/** Create a new empty PDF document. Drop-in replacement for `PDFDocument.create()`. */
static async create(): Promise<PdfiumDocument> {
const m = await getPdfiumModule();
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error('PDFium: failed to create document');
return new PdfiumDocument(m, docPtr);
}
/** Add a new page to the document. */
addPage(dimensions: [number, number]): PdfiumPage {
const [width, height] = dimensions;
const insertIdx = this._pages.length;
const pagePtr = this._m.FPDFPage_New(this._docPtr, insertIdx, width, height);
if (!pagePtr) throw new Error('PDFium: failed to create page');
const page = new PdfiumPage(this._m, this._docPtr, pagePtr, width, height);
this._pages.push(page);
return page;
}
/** Embed a standard PDF font. Returns a PdfiumFont for text measurement and drawing. */
async embedFont(fontName: string): Promise<PdfiumFont> {
if (this._fonts.has(fontName)) return this._fonts.get(fontName)!;
const font = new PdfiumFont(fontName);
this._fonts.set(fontName, font);
return font;
}
/** Embed a PNG image from raw bytes. */
async embedPng(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), 'image/png');
}
/** Embed a JPEG image from raw bytes. */
async embedJpg(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), 'image/jpeg');
}
/** Get the number of pages. */
getPageCount(): number {
return this._pages.length;
}
/** Save the document and return the PDF bytes. */
async save(): Promise<Uint8Array> {
// Generate content for all pages before saving
for (const page of this._pages) {
page._generateContent();
}
const buf = await saveRawDocument(this._docPtr);
// Close all pages and the document
for (const page of this._pages) {
page._close();
}
this._m.FPDF_CloseDocument(this._docPtr);
return new Uint8Array(buf);
}
/**
* Decode image bytes to RGBA pixel data via canvas.
* PDFium's image object APIs require bitmap data.
*/
private _decodeImage(bytes: Uint8Array, mimeType: string): Promise<PdfiumImage> {
return new Promise((resolve, reject) => {
const blob = new Blob([bytes as BlobPart], { type: mimeType });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Canvas 2D context unavailable'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve(new PdfiumImage(new Uint8Array(imageData.data.buffer), canvas.width, canvas.height));
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to decode image'));
};
img.src = url;
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,19 @@ code {
monospace;
}
/* Suppress font visibility warnings for PDF.js embedded fonts */
@font-face {
font-family: 'Adwaita Mono';
src: local('Adwaita Mono');
font-display: optional;
}
@font-face {
font-family: 'Nimbus Mono PS';
src: local('Nimbus Mono PS');
font-display: optional;
}
/* CSS Variables */
:root {
--footer-height: 2rem;

View File

@@ -0,0 +1,121 @@
/**
* ButtonAppearanceOverlay — Renders PDF push-button widget appearances as
* canvas bitmaps on top of a PDF page.
*
* This is a visual-only layer (pointerEvents: none). Click handling is done
* separately by FormFieldOverlay's transparent hit-target divs.
*
* Uses the same EPDF_RenderAnnotBitmap / FPDF_FFLDraw pipeline as
* SignatureFieldOverlay to produce the button's native PDF appearance.
*/
import React, { useEffect, useMemo, useRef, useState, memo } from 'react';
import { renderButtonFieldAppearances, type SignatureFieldAppearance } from '@app/services/pdfiumService';
interface ButtonAppearanceOverlayProps {
pageIndex: number;
pdfSource: File | Blob | null;
pageWidth: number;
pageHeight: number;
}
let _cachedSource: File | Blob | null = null;
let _cachePromise: Promise<SignatureFieldAppearance[]> | null = null;
async function resolveButtonAppearances(source: File | Blob): Promise<SignatureFieldAppearance[]> {
if (source === _cachedSource && _cachePromise) return _cachePromise;
_cachedSource = source;
_cachePromise = source.arrayBuffer().then((buf) => renderButtonFieldAppearances(buf));
return _cachePromise;
}
function ButtonBitmapCanvas({ 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 ButtonAppearanceOverlayInner({
pageIndex,
pdfSource,
pageWidth,
pageHeight,
}: ButtonAppearanceOverlayProps) {
const [appearances, setAppearances] = useState<SignatureFieldAppearance[]>([]);
useEffect(() => {
if (!pdfSource) { setAppearances([]); return; }
let cancelled = false;
resolveButtonAppearances(pdfSource)
.then((res) => { if (!cancelled) setAppearances(res); })
.catch(() => { if (!cancelled) setAppearances([]); });
return () => { cancelled = true; };
}, [pdfSource]);
const pageAppearances = useMemo(
() => appearances.filter((a) => a.pageIndex === pageIndex && a.imageData !== null),
[appearances, pageIndex],
);
if (pageAppearances.length === 0) return null;
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 4,
}}
data-button-appearance-page={pageIndex}
>
{pageAppearances.map((btn, idx) => {
const sx = btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
const sy = btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
const left = btn.x * sx;
const top = btn.y * sy;
const width = btn.width * sx;
const height = btn.height * sy;
return (
<div
key={`btn-appearance-${btn.fieldName}-${idx}`}
style={{
position: 'absolute',
left,
top,
width,
height,
overflow: 'hidden',
}}
>
<ButtonBitmapCanvas
imageData={btn.imageData!}
cssWidth={width}
cssHeight={height}
/>
</div>
);
})}
</div>
);
}
export const ButtonAppearanceOverlay = memo(ButtonAppearanceOverlayInner);

View File

@@ -138,15 +138,13 @@ function FieldInputInner({
case 'radio': {
const radioOptions: { value: string; label: string }[] = [];
if (field.widgets && field.widgets.length > 0) {
for (const w of field.widgets) {
if (w.exportValue && !radioOptions.some((o) => o.value === w.exportValue)) {
radioOptions.push({ value: w.exportValue, label: w.exportValue });
}
for (let i = 0; i < field.widgets.length; i++) {
const w = field.widgets[i];
// Use widget index as value; display option label, export value, or index
const label = (field.options && field.options[i]) || w.exportValue || String(i);
radioOptions.push({ value: String(i), label });
}
}
if (radioOptions.length === 0 && field.options) {
radioOptions.push(...field.options.map((o) => ({ value: o, label: o })));
}
return (
<Radio.Group
value={value}

View File

@@ -20,7 +20,110 @@
import React, { useCallback, useMemo, memo } from 'react';
import { useDocumentState } from '@embedpdf/core/react';
import { useFormFill, useFieldValue } from '@app/tools/formFill/FormFillContext';
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
import { useViewer } from '@app/contexts/ViewerContext';
import type { FormField, WidgetCoordinates, ButtonAction } from '@app/tools/formFill/types';
/**
* Execute PDF JavaScript in a minimally sandboxed context.
*
* Implements a heuristic security check by statically rejecting scripts containing
* common browser globals (`window`, `document`, `fetch`), reflection APIs,
* or execution sinks (`eval`, `Function`).
*
* Valid scripts run in strict mode with dangerous globals explicitly masked
* to `undefined`, allowing safe Acrobat APIs like `this.print()` or `app.alert()`.
*/
function executePdfJs(
js: string,
handlers: {
print: () => void;
save: () => void;
submitForm: (url: string) => void;
resetForm: () => void;
},
): void {
// 1. Static sanitization: Reject scripts with potentially harmful or unneeded keywords.
// This blocks most elementary exploits and prevents prototype tampering.
const forbidden = [
'window', 'document', 'fetch', 'xmlhttprequest', 'websocket', 'worker',
'eval', 'settimeout', 'setinterval', 'function', 'constructor',
'__proto__', 'prototype', 'globalthis', 'import', 'require'
];
const lowerJs = js.toLowerCase();
for (const word of forbidden) {
if (lowerJs.includes(word)) {
console.warn(`[PDF JS] Execution blocked: Script contains suspicious keyword "${word}".`, 'Script:', js);
return;
}
}
// 2. Mock Acrobat API
const doOpenUrl = (url: string) => {
try {
const u = new URL(url);
if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
window.open(url, '_blank', 'noopener,noreferrer');
}
} catch { /* invalid URL — ignore */ }
};
const app = {
print: (_params?: unknown) => handlers.print(),
alert: (msg: unknown) => { console.debug('[PDF JS] alert:', msg); },
beep: () => {},
response: () => null,
execMenuItem: (item: string) => {
switch (item) {
case 'Print': handlers.print(); break;
case 'Save': handlers.save(); break;
case 'Close': break; // no-op in browser context
default: console.debug('[PDF JS] execMenuItem: unhandled item:', item);
}
},
// Prevent prototype walking
__proto__: null
};
const doc = {
print: (_params?: unknown) => handlers.print(),
save: (_params?: unknown) => handlers.save(),
saveAs: (_params?: unknown) => handlers.save(),
submitForm: (urlOrParams: unknown) => {
const url =
typeof urlOrParams === 'string'
? urlOrParams
: (urlOrParams as Record<string, unknown>)?.cURL as string ?? '';
if (url) doOpenUrl(url); else handlers.submitForm(url);
},
resetForm: (_fields?: unknown) => handlers.resetForm(),
getField: (_name: string) => null,
getAnnot: () => null,
getURL: (url: string) => doOpenUrl(url),
numPages: 1,
dirty: false,
};
// Stub event object — used by field calculation/validation scripts
const event = {
value: '',
changeEx: '',
change: '',
rc: true,
willCommit: false,
target: null as null,
};
try {
// Pass doc, app, event as both `this` AND named parameters so scripts that
// reference them as free variables (not just via `this`) work correctly.
const fn = new Function('app', 'doc', 'event', js);
fn.call(doc, app, doc, event);
} catch (err) {
// Swallow errors from missing PDF APIs; log in debug mode for tracing
console.debug('[PDF JS] Script execution error (expected for unsupported APIs):', err, '\nScript:', js.slice(0, 200));
}
}
interface WidgetInputProps {
field: FormField;
@@ -31,6 +134,7 @@ interface WidgetInputProps {
scaleY: number;
onFocus: (fieldName: string) => void;
onChange: (fieldName: string, value: string) => void;
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
}
/**
@@ -47,6 +151,7 @@ function WidgetInputInner({
scaleY,
onFocus,
onChange,
onButtonClick,
}: WidgetInputProps) {
// Per-field value subscription — only this widget re-renders when its value changes
const value = useFieldValue(field.name);
@@ -287,10 +392,12 @@ function WidgetInputInner({
}
case 'radio': {
// Each radio widget has an exportValue set by the backend
const optionValue = widget.exportValue || '';
if (!optionValue) return null; // no export value, skip
const isSelected = value === optionValue;
// Identify this widget by its index within the field's widgets array.
// This avoids issues with duplicate exportValues (e.g., all "Yes").
const widgetIndex = field.widgets?.indexOf(widget) ?? -1;
if (widgetIndex < 0) return null;
const widgetIndexStr = String(widgetIndex);
const isSelected = value === widgetIndexStr;
return (
<div
{...commonProps}
@@ -304,11 +411,11 @@ function WidgetInputInner({
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || `${field.label}: ${optionValue}`}
title={error || field.tooltip || `${field.label}: ${widget.exportValue || widgetIndexStr}`}
onClick={(e) => {
if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
if (field.readOnly || value === widgetIndexStr) return; // Don't deselect radio buttons
handleFocus();
onChange(field.name, optionValue);
onChange(field.name, widgetIndexStr);
stopPropagation(e);
}}
>
@@ -317,8 +424,8 @@ function WidgetInputInner({
width: Math.min(width, height) * 0.8,
height: Math.min(width, height) * 0.8,
borderRadius: '50%',
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
background: isSelected ? '#2196F3' : '#FFF',
border: `1.5px solid ${isSelected ? '#2196F3' : isActive ? '#2196F3' : '#999'}`,
background: isSelected ? '#2196F3' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -331,44 +438,58 @@ function WidgetInputInner({
}
case 'signature':
// Signed signatures have a pre-rendered appearance image; render it.
if (field.appearanceDataUrl) {
return (
<img
src={field.appearanceDataUrl}
style={{
position: 'absolute',
left,
top,
width,
height,
zIndex: 10,
pointerEvents: 'none',
userSelect: 'none',
display: 'block',
}}
alt={field.label || 'Signature'}
draggable={false}
/>
);
// Signature fields are handled entirely by SignatureFieldOverlay (bitmap canvas).
// Rendering a placeholder here creates a visible grey overlay on top of the
// signature appearance, so we skip it entirely.
return null;
case 'button': {
// Transparent hit-target only — visual appearance is rendered by ButtonAppearanceOverlay
// (which paints the PDF's native /AP bitmap onto a canvas behind this div).
const buttonLabel = field.buttonLabel || field.value || field.label || 'Button';
const isClickable = !field.readOnly;
let actionHint = '';
if (field.buttonAction) {
switch (field.buttonAction.type) {
case 'named': actionHint = field.buttonAction.namedAction ?? ''; break;
case 'resetForm': actionHint = 'Reset Form'; break;
case 'submitForm': actionHint = `Submit to: ${field.buttonAction.url ?? ''}`.trim(); break;
case 'uri': actionHint = field.buttonAction.url ?? ''; break;
case 'javascript': actionHint = 'Script'; break;
}
}
// Unsigned signature — fall through to placeholder
// falls through
case 'button':
// Just render a highlighted area — not editable
const titleText = field.tooltip || (actionHint ? `${buttonLabel} (${actionHint})` : buttonLabel);
return (
<div
{...commonProps}
style={{
...commonStyle,
background: 'rgba(200,200,200,0.3)',
border: '1px dashed #999',
cursor: 'default',
background: 'transparent',
border: 'none',
boxShadow: 'none',
cursor: isClickable ? 'pointer' : 'default',
}}
title={titleText}
role="button"
tabIndex={isClickable ? 0 : -1}
aria-label={buttonLabel}
onClick={(e) => {
handleFocus();
if (isClickable) onButtonClick(field, field.buttonAction);
stopPropagation(e);
}}
onKeyDown={(e) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onButtonClick(field, field.buttonAction);
}
stopPropagation(e);
}}
title={field.tooltip || `${field.type}: ${field.label}`}
onClick={handleFocus}
/>
);
}
default:
return (
@@ -407,6 +528,7 @@ export function FormFieldOverlay({
}: FormFieldOverlayProps) {
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
const { activeFieldName, validationErrors } = state;
const { printActions, scrollActions, exportActions } = useViewer();
// Get scale from EmbedPDF document state — same pattern as LinkLayer
// NOTE: All hooks must be called unconditionally (before any early returns)
@@ -416,20 +538,28 @@ export function FormFieldOverlay({
const pdfPage = documentState?.document?.pages?.[pageIndex];
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
if (pageIndex === 0) {
console.debug('[FormFieldOverlay] page 0 using fallback scale=%f (missing pdfPage.size)', s);
}
return { scaleX: s, scaleY: s };
}
// pdfPage.size contains un-rotated (MediaBox) dimensions;
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
};
const sx = pageWidth / pdfPage.size.width;
const sy = pageHeight / pdfPage.size.height;
if (pageIndex === 0) {
console.debug(
'[FormFieldOverlay] page 0 scale: pageW=%f pageH=%f pdfW=%f pdfH=%f → scaleX=%f scaleY=%f docScale=%f',
pageWidth, pageHeight, pdfPage.size.width, pdfPage.size.height, sx, sy, documentState?.scale,
);
}
// pdfPage.size contains un-rotated dimensions from the engine;
// pageWidth/pageHeight from Scroller = pdfPage.size * documentScale
return { scaleX: sx, scaleY: sy };
}, [documentState, pageIndex, pageWidth, pageHeight]);
const pageFields = useMemo(
() => fieldsByPage.get(pageIndex) || [],
[fieldsByPage, pageIndex],
[fieldsByPage, pageIndex]
);
const handleFocus = useCallback(
@@ -442,6 +572,64 @@ export function FormFieldOverlay({
[setValue]
);
const handleButtonClick = useCallback(
(field: FormField, action?: ButtonAction | null) => {
const doOpenUrl = (url: string) => {
try {
const u = new URL(url);
if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
window.open(url, '_blank', 'noopener,noreferrer');
}
} catch { /* invalid URL */ }
};
const doResetForm = () => {
for (const f of state.fields) setValue(f.name, f.value ?? '');
};
const doSave = () => { exportActions.saveAsCopy(); };
if (!action) {
// Action extraction failed — fall back to label matching as a last resort
const label = (field.buttonLabel || field.label || '').toLowerCase();
if (/print/.test(label)) printActions.print();
else if (/save|download/.test(label)) doSave();
else if (/reset|clear/.test(label)) doResetForm();
return;
}
switch (action.type) {
case 'named':
switch (action.namedAction) {
case 'Print': printActions.print(); break;
case 'Save': doSave(); break;
case 'NextPage': scrollActions.scrollToNextPage(); break;
case 'PrevPage': scrollActions.scrollToPreviousPage(); break;
case 'FirstPage': scrollActions.scrollToFirstPage(); break;
case 'LastPage': scrollActions.scrollToLastPage(); break;
}
break;
case 'resetForm':
doResetForm();
break;
case 'submitForm':
case 'uri':
if (action.url) doOpenUrl(action.url);
break;
case 'javascript':
// Execute in a sandboxed PDF JS environment instead of just logging
if (action.javascript) {
executePdfJs(action.javascript, {
print: () => printActions.print(),
save: doSave,
submitForm: doOpenUrl,
resetForm: doResetForm,
});
}
break;
}
},
[printActions, scrollActions, exportActions, state.fields, setValue],
);
// Guard: don't render fields from a previous document.
// If fileId is provided and doesn't match what the context fetched for, render nothing.
if (fileId != null && forFileId != null && fileId !== forFileId) {
@@ -485,6 +673,7 @@ export function FormFieldOverlay({
scaleY={scaleY}
onFocus={handleFocus}
onChange={handleChange}
onButtonClick={handleButtonClick}
/>
);
})

View File

@@ -32,8 +32,9 @@ import React, {
import { useDebouncedCallback } from '@mantine/hooks';
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
import { PdfLibFormProvider, fetchSignatureFieldsWithAppearances } from '@app/tools/formFill/providers/PdfLibFormProvider';
import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
import { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
import { fetchSignatureFieldsWithAppearances } from '@app/services/pdfiumService';
// ---------------------------------------------------------------------------
// FormValuesStore — external store for field values (outside React state)
@@ -268,7 +269,7 @@ export function useAllFormValues(): Record<string, string> {
}
/** Singleton provider instances */
const pdfLibProvider = new PdfLibFormProvider();
const pdfiumProvider = new PdfiumFormProvider();
const pdfBoxProvider = new PdfBoxFormProvider();
export function FormFillProvider({
@@ -283,7 +284,7 @@ export function FormFillProvider({
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
providerModeRef.current = providerMode;
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider);
const providerRef = useRef(provider);
providerRef.current = provider;
@@ -322,7 +323,7 @@ export function FormFillProvider({
let fields = await providerRef.current.fetchFields(file);
// If another fetch or reset happened while we were waiting, discard this result
if (fetchVersionRef.current !== version) {
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
console.debug('[FormFill] Discarding stale fetch result (version mismatch)');
return;
}
@@ -330,7 +331,9 @@ export function FormFillProvider({
// (they're not fillable). Fetch them via pdflib so their appearances still render.
if (providerModeRef.current === 'pdfbox') {
try {
const sigFields = await fetchSignatureFieldsWithAppearances(file);
// Convert File/Blob to ArrayBuffer for pdfiumService
const arrayBuffer = await file.arrayBuffer();
const sigFields = await fetchSignatureFieldsWithAppearances(arrayBuffer);
if (fetchVersionRef.current !== version) return; // stale check after async
if (sigFields.length > 0) {
fields = [...fields, ...sigFields];
@@ -420,7 +423,7 @@ export function FormFillProvider({
if (providerModeRef.current === mode) return;
// provider (pdfbox vs pdflib).
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider;
providerRef.current = newProvider;
providerModeRef.current = mode;

View File

@@ -7,5 +7,5 @@ export { FieldInput } from '@app/tools/formFill/FieldInput';
export { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
export type { IFormDataProvider } from '@app/tools/formFill/providers/types';
export { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
export { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';

View File

@@ -1,865 +0,0 @@
/**
* PdfLibFormProvider — Frontend-only form data provider using pdf-lib.
*
* Extracts form fields directly from the PDF byte stream and fills them
* without any backend calls. This avoids sending large PDFs (potentially
* hundreds of MB) to the server for a feature that can be done entirely
* on the client.
*
* Used in normal viewer mode when the user views a PDF with form fields.
*
* Coordinate system:
* pdf-lib provides widget rectangles in PDF user space (lower-left origin).
* We transform them to CSS space (top-left origin) matching what the backend
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
* for both providers.
*/
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage,
PDFString, PDFHexString, PDFStream } from '@cantoo/pdf-lib';
import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import type { FormField, FormFieldType, WidgetCoordinates } from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
/**
* Read a File/Blob as ArrayBuffer.
*/
async function readAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
return file.arrayBuffer();
}
/**
* Get the page index for a widget annotation by finding which page contains it.
*/
function getWidgetPageIndex(
widget: PDFDict,
pages: PDFPage[],
): number {
// Check /P entry first (direct page reference)
const pRef = widget.get(PDFName.of('P'));
if (pRef instanceof PDFRef) {
for (let i = 0; i < pages.length; i++) {
if (pages[i].ref === pRef) return i;
}
}
// Fall back to scanning each page's /Annots array
const widgetRef = findWidgetRef(widget, pages);
if (widgetRef !== undefined) return widgetRef;
return 0; // default to first page
}
function findWidgetRef(widget: PDFDict, pages: PDFPage[]): number | undefined {
for (let i = 0; i < pages.length; i++) {
const annots = pages[i].node.lookup(PDFName.of('Annots'));
if (annots instanceof PDFArray) {
for (let j = 0; j < annots.size(); j++) {
const annotRef = annots.get(j);
const annotDict = annots.lookup(j);
if (annotDict === widget || annotRef === (widget as any).ref) {
return i;
}
}
}
}
return undefined;
}
/**
* Get the page rotation in degrees (0, 90, 180, 270).
*/
function getPageRotation(page: PDFPage): number {
const rot = page.getRotation();
return rot?.angle ?? 0;
}
/**
* Extract widget rectangles from a PDFField, transforming from PDF space
* (lower-left origin) to CSS space (top-left origin).
*
* Widget /Rect coordinates are always in un-rotated PDF user space
* (defined by the MediaBox/CropBox). We only need a y-flip to convert
* from PDF's lower-left origin to CSS's upper-left origin.
*
* The embedpdf viewer wraps all page content (including this overlay)
* inside a <Rotate> CSS component that handles visual rotation.
* Therefore we must NOT apply any rotation here — doing so would
* double-rotate the widgets.
*/
function extractWidgets(
field: PDFField,
pages: PDFPage[],
_doc: PDFDocument,
): WidgetCoordinates[] {
const widgets: WidgetCoordinates[] = [];
// Access the underlying PDFDict from the acro field
const acroFieldDict = (field.acroField as any).dict as PDFDict;
// Get all widget annotations for this field
const widgetDicts = getFieldWidgets(acroFieldDict);
for (const wDict of widgetDicts) {
const rect = wDict.lookup(PDFName.of('Rect'));
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
const x1 = numberVal(rect.lookup(0));
const y1 = numberVal(rect.lookup(1));
const x2 = numberVal(rect.lookup(2));
const y2 = numberVal(rect.lookup(3));
const pageIndex = getWidgetPageIndex(wDict, pages);
const page = pages[pageIndex];
if (!page) continue;
// Get CropBox dimensions (un-rotated) for coordinate transformation
const cropBox = getCropBox(page);
const cropHeight = cropBox.height;
const cropX = cropBox.x;
const cropY = cropBox.y;
// Widget rect in PDF space (lower-left origin, un-rotated)
const pdfX = Math.min(x1, x2);
const pdfY = Math.min(y1, y2);
const pdfW = Math.abs(x2 - x1);
const pdfH = Math.abs(y2 - y1);
// Adjust relative to CropBox origin
const relativeX = pdfX - cropX;
const relativeY = pdfY - cropY;
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
// No rotation transform here — the <Rotate> CSS component in the viewer
// handles page rotation for all overlays including form fields.
const finalX = relativeX;
const finalY = cropHeight - relativeY - pdfH;
const finalW = pdfW;
const finalH = pdfH;
// Extract export value for checkboxes/radios
let exportValue: string | undefined;
const ap = wDict.lookup(PDFName.of('AP'));
if (ap instanceof PDFDict) {
const normal = ap.lookup(PDFName.of('N'));
if (normal instanceof PDFDict) {
// The keys of /N (other than /Off) are the export values.
// PDFDict.entries() reliably returns [PDFName, PDFObject][] in
// @cantoo/pdf-lib — no optional chaining needed.
try {
const entries = normal.entries();
const keys = entries
.map(([k]) => k.decodeText())
.filter((k) => k !== 'Off');
if (keys.length > 0) exportValue = keys[0];
} catch {
// Malformed AP dict — skip export value extraction
}
}
}
// Also check /AS for current appearance state
if (!exportValue) {
const asEntry = wDict.lookup(PDFName.of('AS'));
if (asEntry instanceof PDFName) {
const asVal = asEntry.decodeText();
if (asVal !== 'Off') exportValue = asVal;
}
}
// Extract font size from default appearance string
let fontSize: number | undefined;
const da = wDict.lookup(PDFName.of('DA'));
if (da) {
const daStr = da.toString();
const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/);
if (tfMatch) {
fontSize = parseFloat(tfMatch[1]);
if (fontSize === 0) fontSize = undefined; // 0 means auto-size
}
}
widgets.push({
pageIndex,
x: finalX,
y: finalY,
width: finalW,
height: finalH,
exportValue,
fontSize,
});
}
return widgets;
}
function numberVal(obj: any): number {
if (obj instanceof PDFNumber) return obj.asNumber();
if (typeof obj === 'number') return obj;
return 0;
}
/**
* Get the CropBox (or MediaBox fallback) dimensions in un-rotated PDF space.
* These are the raw dictionary values without any rotation adjustment.
*/
function getCropBox(page: PDFPage): { x: number; y: number; width: number; height: number } {
// Check direct CropBox entry
const cropBox = page.node.lookup(PDFName.of('CropBox'));
if (cropBox instanceof PDFArray && cropBox.size() >= 4) {
return {
x: numberVal(cropBox.lookup(0)),
y: numberVal(cropBox.lookup(1)),
width: numberVal(cropBox.lookup(2)) - numberVal(cropBox.lookup(0)),
height: numberVal(cropBox.lookup(3)) - numberVal(cropBox.lookup(1)),
};
}
// Check direct MediaBox entry
const mediaBox = page.node.lookup(PDFName.of('MediaBox'));
if (mediaBox instanceof PDFArray && mediaBox.size() >= 4) {
return {
x: numberVal(mediaBox.lookup(0)),
y: numberVal(mediaBox.lookup(1)),
width: numberVal(mediaBox.lookup(2)) - numberVal(mediaBox.lookup(0)),
height: numberVal(mediaBox.lookup(3)) - numberVal(mediaBox.lookup(1)),
};
}
// Traverse parent page-tree nodes for inherited MediaBox
let node: any = page.node;
while (node) {
const parentNode = node.lookup(PDFName.of('Parent'));
if (parentNode instanceof PDFDict) {
const inheritedBox = parentNode.lookup(PDFName.of('MediaBox'));
if (inheritedBox instanceof PDFArray && inheritedBox.size() >= 4) {
return {
x: numberVal(inheritedBox.lookup(0)),
y: numberVal(inheritedBox.lookup(1)),
width: numberVal(inheritedBox.lookup(2)) - numberVal(inheritedBox.lookup(0)),
height: numberVal(inheritedBox.lookup(3)) - numberVal(inheritedBox.lookup(1)),
};
}
node = parentNode;
} else {
break;
}
}
// Last resort: use page.getSize() but un-rotate the dimensions
const { width, height } = page.getSize();
const rotation = getPageRotation(page);
if (rotation === 90 || rotation === 270) {
return { x: 0, y: 0, width: height, height: width };
}
return { x: 0, y: 0, width, height };
}
/**
* Get the widget annotation dictionaries for a field.
* A field can either BE a widget (merged) or have child /Kids that are widgets.
*/
function getFieldWidgets(acroField: PDFDict): PDFDict[] {
const kids = acroField.lookup(PDFName.of('Kids'));
if (kids instanceof PDFArray) {
const result: PDFDict[] = [];
for (let i = 0; i < kids.size(); i++) {
const kid = kids.lookup(i);
if (kid instanceof PDFDict) {
// Check if this kid is a widget (has /Rect) vs another field node
const subtype = kid.lookup(PDFName.of('Subtype'));
if (subtype instanceof PDFName && subtype.decodeText() === 'Widget') {
result.push(kid);
} else if (kid.lookup(PDFName.of('Rect'))) {
// Merged field/widget — has Rect but maybe no explicit Subtype
result.push(kid);
} else {
// Intermediate field node — recurse
result.push(...getFieldWidgets(kid));
}
}
}
return result;
}
// No Kids — the field dict itself is the widget (merged field/widget)
if (acroField.lookup(PDFName.of('Rect'))) {
return [acroField];
}
return [];
}
/**
* Determine the FormFieldType from a pdf-lib PDFField.
*/
function getFieldType(field: PDFField): FormFieldType {
if (field instanceof PDFTextField) return 'text';
if (field instanceof PDFCheckBox) return 'checkbox';
if (field instanceof PDFDropdown) return 'combobox';
if (field instanceof PDFRadioGroup) return 'radio';
if (field instanceof PDFOptionList) return 'listbox';
if (field instanceof PDFButton) return 'button';
if (field instanceof PDFSignature) return 'signature';
return 'text';
}
/**
* Get the current value of a field as a string.
*/
function getFieldValue(field: PDFField): string {
try {
if (field instanceof PDFTextField) {
return field.getText() ?? '';
}
if (field instanceof PDFCheckBox) {
return field.isChecked() ? 'Yes' : 'Off';
}
if (field instanceof PDFDropdown) {
const selected = field.getSelected();
return selected.length > 0 ? selected[0] : '';
}
if (field instanceof PDFRadioGroup) {
return getRadioValue(field);
}
if (field instanceof PDFOptionList) {
const selected = field.getSelected();
return selected.join(',');
}
} catch {
// Some fields may throw on getValue if malformed
}
return '';
}
function getRadioValue(field: PDFRadioGroup): string {
const selected = field.getSelected() ?? '';
if (!selected || selected === 'Off') return selected;
const options = field.getOptions();
if (options.includes(selected)) return selected;
const mappedOption = mapAppearanceStateToOption(field, selected, options);
if (mappedOption) return mappedOption;
const index = parseInt(selected, 10);
if (!isNaN(index) && index >= 0 && index < options.length) {
return options[index];
}
return selected;
}
function mapAppearanceStateToOption(
field: PDFRadioGroup,
stateName: string,
options: string[],
): string | undefined {
try {
const acroFieldDict = (field.acroField as any).dict as PDFDict;
const widgets = getFieldWidgets(acroFieldDict);
for (let i = 0; i < widgets.length; i++) {
const ap = widgets[i].lookup(PDFName.of('AP'));
if (!(ap instanceof PDFDict)) continue;
const normal = ap.lookup(PDFName.of('N'));
if (!(normal instanceof PDFDict)) continue;
let keys: string[] = [];
try {
keys = normal.entries().map(([k]) => k.decodeText());
} catch {
continue;
}
if (keys.includes(stateName) && i < options.length) {
return options[i];
}
}
} catch {
// Fallback handled by caller
}
return undefined;
}
function resolveRadioValueForSelect(
field: PDFRadioGroup,
value: string,
): string | null {
const options = field.getOptions();
if (options.includes(value)) return value;
const mappedOption = mapAppearanceStateToOption(field, value, options);
if (mappedOption) return mappedOption;
const index = parseInt(value, 10);
if (!isNaN(index) && index >= 0 && index < options.length) {
return options[index];
}
const lower = value.toLowerCase();
const match = options.find((o: string) => o.toLowerCase() === lower);
if (match) return match;
return null;
}
/**
* Get field options (for dropdowns, listboxes, radios).
*/
function getFieldOptions(field: PDFField): string[] | null {
try {
if (field instanceof PDFDropdown) {
return field.getOptions();
}
if (field instanceof PDFOptionList) {
return field.getOptions();
}
if (field instanceof PDFRadioGroup) {
return field.getOptions();
}
} catch {
// ignore
}
return null;
}
/**
* Extract display labels from the /Opt array if it contains [export, display]
* pairs. PDF spec §12.7.4.4: each element of /Opt may be either a text
* string (export value == display value) or a two-element array where the
* first element is the export value and the second is the display text.
*
* Returns null when every display value equals its export value (no distinct
* display labels exist), keeping the interface lean for the common case.
*/
function getFieldDisplayOptions(field: PDFField): string[] | null {
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) {
return null;
}
try {
const acroDict = (field.acroField as any).dict as PDFDict;
const optRaw = acroDict.lookup(PDFName.of('Opt'));
if (!(optRaw instanceof PDFArray)) return null;
const displays: string[] = [];
let hasDifference = false;
for (let i = 0; i < optRaw.size(); i++) {
try {
const entry = optRaw.lookup(i);
if (entry instanceof PDFArray && entry.size() >= 2) {
// [exportValue, displayValue] pair
const exp = decodeText(entry.lookup(0));
const disp = decodeText(entry.lookup(1));
displays.push(disp);
if (exp !== disp) hasDifference = true;
} else {
// Plain string — export and display are the same
const val = decodeText(entry);
displays.push(val);
}
} catch {
// Malformed /Opt entry — skip but continue processing remaining entries
continue;
}
}
if (displays.length === 0) return null;
return hasDifference ? displays : null;
} catch {
return null;
}
}
/** Decode a PDFString, PDFHexString, or PDFName to a JS string. */
function decodeText(obj: unknown): string {
if (obj instanceof PDFString || obj instanceof PDFHexString) {
return obj.decodeText();
}
if (obj instanceof PDFName) {
return obj.decodeText();
}
if (typeof obj === 'string') return obj;
return String(obj ?? '');
}
/**
* Check if a field is read-only.
*/
function isFieldReadOnly(field: PDFField): boolean {
try {
return field.isReadOnly();
} catch {
return false;
}
}
/**
* Check if a field is required.
*/
function isFieldRequired(field: PDFField): boolean {
try {
return field.isRequired();
} catch {
return false;
}
}
/**
* Get field tooltip (TU entry).
* Uses proper PDFString/PDFHexString decoding for correct Unicode support.
*/
function getFieldTooltip(acroField: PDFDict): string | null {
const tu = acroField.lookup(PDFName.of('TU'));
if (!tu) return null;
try {
// Prefer decodeText() for proper Unicode handling (UTF-16BE / PDFDocEncoding)
if (tu instanceof PDFString || tu instanceof PDFHexString) {
return tu.decodeText();
}
// Fallback: strip parentheses from raw toString() for other object types
return tu.toString().replace(/^\(|\)$/g, '');
} catch {
return null;
}
}
/**
* Check if a text field is multiline (flag bit 13 set in /Ff).
*/
function isMultiline(field: PDFField): boolean {
if (!(field instanceof PDFTextField)) return false;
try {
return field.isMultiline();
} catch {
return false;
}
}
/**
* Get the label for a field — use the partial name or the full qualified name.
*/
function getFieldLabel(field: PDFField): string {
const name = field.getName();
// Use the last segment of the qualified name as the label
const parts = name.split('.');
return parts[parts.length - 1] || name;
}
/**
* Extracts only signed signature fields (those with an /AP/N stream) from a PDF,
* renders their appearances via PDF.js, and returns them as FormField objects.
*
* Used by FormFillContext to inject signature overlays when the pdfbox provider
* is active (fill form tool), where the backend doesn't return signature fields.
*/
export async function fetchSignatureFieldsWithAppearances(file: File | Blob): Promise<FormField[]> {
const arrayBuffer = await readAsArrayBuffer(file);
let doc: PDFDocument;
try {
doc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
updateMetadata: false,
throwOnInvalidObject: false,
});
} catch { return []; }
let form: PDFForm;
try { form = doc.getForm(); } catch { return []; }
let pdfFields: PDFField[];
try { pdfFields = form.getFields(); } catch { return []; }
let pages: PDFPage[];
try { pages = doc.getPages(); } catch { return []; }
const result: FormField[] = [];
for (const field of pdfFields) {
if (!(field instanceof PDFSignature)) continue;
if (!signatureHasAppearance(field)) continue;
const widgets = extractWidgets(field, pages, doc);
if (widgets.length === 0) continue;
result.push({
name: field.getName(),
label: getFieldLabel(field),
type: 'signature',
value: '',
options: null,
displayOptions: null,
required: false,
readOnly: true,
multiSelect: false,
multiline: false,
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
widgets,
});
}
if (result.length > 0) {
await attachSignatureAppearances(result, arrayBuffer);
}
return result;
}
/**
* Returns true if the signature field has at least one widget with a normal
* (/AP/N) appearance stream — i.e. the signature has actually been signed.
*/
function signatureHasAppearance(field: PDFField): boolean {
if (!(field instanceof PDFSignature)) return false;
try {
const acroDict = (field.acroField as any).dict as PDFDict;
const widgets = getFieldWidgets(acroDict);
for (const wDict of widgets) {
const ap = wDict.lookup(PDFName.of('AP'));
if (ap instanceof PDFDict) {
const n = ap.lookup(PDFName.of('N'));
if (n instanceof PDFStream) return true;
}
}
} catch { /* ignore malformed fields */ }
return false;
}
/**
* For each signature FormField that has an /AP/N stream, renders the
* containing page via PDF.js and crops out the widget rectangle,
* attaching the result as field.appearanceDataUrl.
*
* Uses the existing pdfWorkerManager for proper worker lifecycle management.
*/
async function attachSignatureAppearances(
signatureFields: FormField[],
arrayBuffer: ArrayBuffer,
): Promise<void> {
if (signatureFields.length === 0) return;
let pdfDoc: PDFDocumentProxy | null = null;
try {
// Slice so pdf-lib's retained references to the original buffer are unaffected
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer.slice(0));
// Group fields by the pageIndex of their first widget
const byPage = new Map<number, FormField[]>();
for (const field of signatureFields) {
for (const w of field.widgets ?? []) {
const arr = byPage.get(w.pageIndex) ?? [];
arr.push(field);
byPage.set(w.pageIndex, arr);
break; // first widget identifies the page
}
}
const RENDER_SCALE = 2; // 2× for crisp appearance
for (const [pageIndex, fields] of byPage) {
const page = await pdfDoc.getPage(pageIndex + 1); // PDF.js is 1-indexed
const viewport = page.getViewport({ scale: RENDER_SCALE });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvas, viewport }).promise;
for (const field of fields) {
for (const widget of field.widgets ?? []) {
if (widget.pageIndex !== pageIndex) continue;
// widget.x/y are PDF points, CSS upper-left origin (relative to CropBox).
// PDF.js renders the CropBox starting at canvas (0,0), so multiplying by
// RENDER_SCALE gives the correct canvas pixel coordinates for the crop.
const cx = Math.round(widget.x * RENDER_SCALE);
const cy = Math.round(widget.y * RENDER_SCALE);
const cw = Math.max(1, Math.round(widget.width * RENDER_SCALE));
const ch = Math.max(1, Math.round(widget.height * RENDER_SCALE));
const crop = document.createElement('canvas');
crop.width = cw;
crop.height = ch;
const cropCtx = crop.getContext('2d');
if (!cropCtx) continue;
cropCtx.drawImage(canvas, cx, cy, cw, ch, 0, 0, cw, ch);
field.appearanceDataUrl = crop.toDataURL('image/png');
break; // first widget is representative
}
}
page.cleanup();
}
} catch (e) {
console.warn('[PdfLibFormProvider] Failed to extract signature appearances:', e);
} finally {
if (pdfDoc) pdfWorkerManager.destroyDocument(pdfDoc);
}
}
export class PdfLibFormProvider implements IFormDataProvider {
readonly name = 'pdf-lib';
async fetchFields(file: File | Blob): Promise<FormField[]> {
const arrayBuffer = await readAsArrayBuffer(file);
let doc: PDFDocument;
try {
doc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
updateMetadata: false,
throwOnInvalidObject: false,
});
} catch (loadError) {
console.warn('[PdfLibFormProvider] Failed to load PDF document:', loadError);
return [];
}
let form: PDFForm;
try {
form = doc.getForm();
} catch (formError) {
// No AcroForm or broken catalog — return empty
console.warn('[PdfLibFormProvider] Failed to access AcroForm:', formError);
return [];
}
let fields: PDFField[];
try {
fields = form.getFields();
} catch (fieldsError) {
console.warn('[PdfLibFormProvider] Failed to enumerate form fields:', fieldsError);
return [];
}
if (fields.length === 0) return [];
let pages: PDFPage[];
try {
pages = doc.getPages();
} catch (pagesError) {
// Pages tree is invalid (same issue as usePdfLibLinks "invalid catalog").
// Without page references we can't place widgets, so return empty.
// The viewer will fall back to native form rendering via withForms.
console.warn(
'[PdfLibFormProvider] PDF pages tree is invalid — cannot place form widgets.',
'Native form rendering will be used as fallback.',
pagesError,
);
return [];
}
const result: FormField[] = [];
for (const field of fields) {
const fieldName = field.getName();
try {
const type = getFieldType(field);
const widgets = extractWidgets(field, pages, doc);
// Skip fields with no visible widgets
if (widgets.length === 0) continue;
const formField: FormField = {
name: field.getName(),
label: getFieldLabel(field),
type,
value: getFieldValue(field),
options: getFieldOptions(field),
displayOptions: getFieldDisplayOptions(field),
required: isFieldRequired(field),
readOnly: isFieldReadOnly(field),
multiSelect: field instanceof PDFOptionList,
multiline: isMultiline(field),
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
widgets,
};
result.push(formField);
} catch (fieldError) {
// Skip individual malformed fields but continue processing
console.warn(`[PdfLibFormProvider] Skipping field "${fieldName}":`, fieldError);
}
}
// Render appearance streams for signed signature fields so the overlay
// can display them as images instead of placeholder boxes.
const sigFieldsWithAp = result.filter(
f => f.type === 'signature' && signatureHasAppearance(form.getField(f.name)),
);
if (sigFieldsWithAp.length > 0) {
await attachSignatureAppearances(sigFieldsWithAp, arrayBuffer);
}
return result;
}
async fillForm(
file: File | Blob,
values: Record<string, string>,
flatten: boolean,
): Promise<Blob> {
const arrayBuffer = await readAsArrayBuffer(file);
const doc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const form = doc.getForm();
const fields = form.getFields();
for (const field of fields) {
const fieldName = field.getName();
if (!(fieldName in values)) continue;
const value = values[fieldName];
try {
if (field instanceof PDFTextField) {
field.setText(value || undefined);
} else if (field instanceof PDFCheckBox) {
if (value && value !== 'Off') {
field.check();
} else {
field.uncheck();
}
} else if (field instanceof PDFDropdown) {
if (value) {
field.select(value);
} else {
field.clear();
}
} else if (field instanceof PDFRadioGroup) {
if (value && value !== 'Off') {
const resolved = resolveRadioValueForSelect(field, value);
if (resolved) {
field.select(resolved);
} else {
console.warn(
`[PdfLibFormProvider] Radio value "${value}" could not be mapped to options [${field.getOptions().join(', ')}] for field "${fieldName}"`,
);
}
}
} else if (field instanceof PDFOptionList) {
if (value) {
const vals = value.split(',').filter(Boolean);
field.select(vals[0]); // PDFOptionList.select takes single value
} else {
field.clear();
}
}
} catch (err) {
console.warn(`[PdfLibFormProvider] Failed to set value for field "${fieldName}":`, err);
}
}
if (flatten) {
form.flatten();
}
const pdfBytes = await doc.save();
return new Blob([pdfBytes.slice().buffer as ArrayBuffer], { type: 'application/pdf' });
}
}

View File

@@ -0,0 +1,599 @@
/**
* PdfiumFormProvider Frontend-only form data provider using PDFium WASM.
*
* Replaces the old pdf-lib based PdfLibFormProvider. Extracts form fields
* directly from the PDF byte stream via @embedpdf/pdfium WASM and fills
* them without any backend calls.
*
* Used in normal viewer mode when the user views a PDF with form fields.
*
* Coordinate system:
* PDFium provides widget rectangles in PDF user space (lower-left origin).
* We transform them to CSS space (top-left origin) matching what the backend
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
* for both providers.
*/
import { PDF_FORM_FIELD_TYPE } from '@app/services/pdfiumService';
import { FPDF_ANNOT_WIDGET, FLAT_PRINT } from '@app/utils/pdfiumBitmapUtils';
import type { FormField, FormFieldType, WidgetCoordinates, ButtonAction } from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
import {
closeDocAndFreeBuffer,
extractFormFields,
getPdfiumModule,
openRawDocumentSafe,
readUtf16,
saveRawDocument,
type PdfiumFormField,
} from '@app/services/pdfiumService';
/**
* Map PDFium form field type enum to our FormFieldType string.
*/
function mapFieldType(t: PDF_FORM_FIELD_TYPE): FormFieldType {
switch (t) {
case PDF_FORM_FIELD_TYPE.TEXTFIELD:
return 'text';
case PDF_FORM_FIELD_TYPE.CHECKBOX:
return 'checkbox';
case PDF_FORM_FIELD_TYPE.COMBOBOX:
return 'combobox';
case PDF_FORM_FIELD_TYPE.RADIOBUTTON:
return 'radio';
case PDF_FORM_FIELD_TYPE.LISTBOX:
return 'listbox';
case PDF_FORM_FIELD_TYPE.PUSHBUTTON:
return 'button';
case PDF_FORM_FIELD_TYPE.SIGNATURE:
return 'signature';
default:
return 'text';
}
}
/**
* Convert a PdfiumFormField (from pdfiumService) to the UI FormField shape.
* @param optInfo When provided, overrides options/displayOptions for combo/listbox fields.
* @param buttonInfo When provided, sets buttonLabel and buttonAction for push buttons.
*/
function toFormField(
f: PdfiumFormField & { _tooltip?: string | null },
optInfo?: { exportValues: string[]; displayValues: string[] } | null,
buttonInfo?: { label?: string; action?: ButtonAction } | null,
): FormField {
const type = mapFieldType(f.type);
const optionLabels = f.options.map((o) => o.label);
// Build WidgetCoordinates from the PDFium widget rects
const widgets: WidgetCoordinates[] = f.widgets.map((w) => ({
pageIndex: w.pageIndex,
x: w.x,
y: w.y,
width: w.width,
height: w.height,
exportValue: w.exportValue,
fontSize: w.fontSize,
}));
// Derive value string
let value = f.value;
if (type === 'checkbox') {
value = f.isChecked ? 'Yes' : 'Off';
} else if (type === 'radio') {
// Use widget index as the canonical radio value.
// This avoids issues with duplicate exportValues across widgets
// (e.g., all widgets having exportValue "Yes").
value = '';
for (let i = 0; i < f.widgets.length; i++) {
if (f.widgets[i].isChecked) {
value = String(i);
break;
}
}
}
// Use pdf-lib extracted export/display values when available
let options: string[] | null = optionLabels.length > 0 ? optionLabels : null;
let displayOptions: string[] | null = null;
if (optInfo && optInfo.exportValues.length > 0) {
options = optInfo.exportValues;
displayOptions = optInfo.displayValues;
}
return {
name: f.name,
label: f.name.split('.').pop() || f.name,
type,
value,
options,
displayOptions,
required: f.isRequired,
readOnly: f.isReadOnly,
multiSelect: f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
multiline: type === 'text' && (f.flags & 0x1000) !== 0, // bit 13 = Multiline
tooltip: f._tooltip ?? null,
widgets,
buttonLabel: buttonInfo?.label ?? null,
buttonAction: buttonInfo?.action ?? null,
};
}
/**
* PdfLibFormProvider — now backed by PDFium WASM.
*
* The class name is kept for backwards-compatibility with existing imports.
* Internally everything goes through @embedpdf/pdfium.
*/
export class PdfiumFormProvider implements IFormDataProvider {
/** Provider identifier — kept as 'pdf-lib' for backwards-compatibility. */
readonly name = 'pdf-lib';
async fetchFields(file: File | Blob): Promise<FormField[]> {
try {
const arrayBuffer = await file.arrayBuffer();
const pdfiumFields = await extractFormFields(arrayBuffer);
// Enrich with alternate names (tooltips)
await this.enrichWithAlternateNames(arrayBuffer, pdfiumFields);
// Enrich combo/listbox fields with export/display values from pdf-lib
const optMap = await this.extractDisplayOptions(arrayBuffer, pdfiumFields);
// Enrich push buttons with label (/MK/CA) and action (/A) from pdf-lib
const buttonInfoMap = await this.extractButtonInfo(arrayBuffer, pdfiumFields);
return pdfiumFields
.filter((f) => f.widgets.length > 0)
.map((f) => toFormField(f, optMap.get(f.name) ?? null, buttonInfoMap.get(f.name) ?? null));
} catch (err) {
console.warn('[PdfiumFormProvider] Failed to extract form fields:', err);
return [];
}
}
/**
* Enrich fields with alternate names (tooltip / TU entry) via PDFium.
*/
private async enrichWithAlternateNames(
data: ArrayBuffer,
fields: PdfiumFormField[],
): Promise<void> {
try {
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data);
try {
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
if (!formEnvPtr) return;
const pageCount = m.FPDF_GetPageCount(docPtr);
const nameToField = new Map(fields.map((f) => [f.name, f]));
const enriched = new Set<string>();
for (let pageIdx = 0; pageIdx < pageCount && enriched.size < nameToField.size; pageIdx++) {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
if (!pagePtr) continue;
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
for (let ai = 0; ai < annotCount && enriched.size < nameToField.size; ai++) {
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
if (!annotPtr) continue;
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
m.FPDFPage_CloseAnnot(annotPtr);
continue;
}
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
let name = '';
if (nl > 0) {
const nb = m.pdfium.wasmExports.malloc(nl);
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
name = readUtf16(m, nb, nl);
m.pdfium.wasmExports.free(nb);
}
if (name && nameToField.has(name) && !enriched.has(name)) {
const altLen = m.FPDFAnnot_GetFormFieldAlternateName(formEnvPtr, annotPtr, 0, 0);
if (altLen > 0) {
const altBuf = m.pdfium.wasmExports.malloc(altLen);
m.FPDFAnnot_GetFormFieldAlternateName(formEnvPtr, annotPtr, altBuf, altLen);
const altName = readUtf16(m, altBuf, altLen);
m.pdfium.wasmExports.free(altBuf);
(nameToField.get(name) as any)._tooltip = altName || null;
}
enriched.add(name);
}
m.FPDFPage_CloseAnnot(annotPtr);
}
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
m.FPDF_ClosePage(pagePtr);
}
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
} catch (e) {
console.warn('[PdfiumFormProvider] Failed to enrich alternate names:', e);
}
}
/**
* Use pdf-lib to read /Opt arrays for combo/listbox fields.
* Returns a map of fieldName → { exportValues, displayValues }.
* PDFium only exposes display labels; pdf-lib can read the raw /Opt entries
* to separate [export, display] pairs.
*/
private async extractDisplayOptions(
data: ArrayBuffer,
fields: PdfiumFormField[],
): Promise<Map<string, { exportValues: string[]; displayValues: string[] }>> {
const result = new Map<string, { exportValues: string[]; displayValues: string[] }>();
const comboOrList = fields.filter(
(f) => f.type === PDF_FORM_FIELD_TYPE.COMBOBOX || f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
);
if (comboOrList.length === 0) return result;
try {
const { PDFDocument, PDFName, PDFArray, PDFString, PDFHexString, PDFDropdown, PDFOptionList } =
await import('@cantoo/pdf-lib');
const doc = await PDFDocument.load(data, { ignoreEncryption: true, throwOnInvalidObject: false });
const form = doc.getForm();
const decodeText = (obj: unknown): string => {
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
return String(obj ?? '');
};
for (const pf of comboOrList) {
try {
const field = form.getField(pf.name);
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) continue;
const acroDict = (field.acroField as any).dict;
const optRaw = acroDict.lookup(PDFName.of('Opt'));
if (!(optRaw instanceof PDFArray)) continue;
const exportValues: string[] = [];
const displayValues: string[] = [];
let hasDifference = false;
for (let i = 0; i < optRaw.size(); i++) {
try {
const entry = optRaw.lookup(i);
if (entry instanceof PDFArray && entry.size() >= 2) {
const exp = decodeText(entry.lookup(0));
const disp = decodeText(entry.lookup(1));
exportValues.push(exp);
displayValues.push(disp);
if (exp !== disp) hasDifference = true;
} else {
const val = decodeText(entry);
exportValues.push(val);
displayValues.push(val);
}
} catch {
continue;
}
}
if (exportValues.length > 0) {
result.set(pf.name, {
exportValues,
displayValues: hasDifference ? displayValues : exportValues,
});
}
} catch {
// Skip individual field errors
}
}
} catch (e) {
console.warn('[PdfiumFormProvider] Failed to extract display options:', e);
}
return result;
}
/**
* Use pdf-lib to extract push button labels (/MK/CA) and actions (/A) for each button field.
* Returns a map of fieldName → { label?, action? }.
*/
private async extractButtonInfo(
data: ArrayBuffer,
fields: PdfiumFormField[],
): Promise<Map<string, { label?: string; action?: ButtonAction }>> {
const result = new Map<string, { label?: string; action?: ButtonAction }>();
const buttons = fields.filter((f) => f.type === PDF_FORM_FIELD_TYPE.PUSHBUTTON);
if (buttons.length === 0) return result;
try {
const { PDFDocument, PDFName, PDFString, PDFHexString, PDFDict } =
await import('@cantoo/pdf-lib');
const doc = await PDFDocument.load(data, { ignoreEncryption: true, throwOnInvalidObject: false });
const form = doc.getForm();
const decodeText = (obj: unknown): string | null => {
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
if (obj instanceof PDFName) return (obj as any).asString?.() ?? obj.toString().replace(/^\//, '');
return null;
};
const parseActionDict = (aObj: unknown): ButtonAction | null => {
if (!(aObj instanceof PDFDict)) return null;
const sObj = aObj.lookup(PDFName.of('S'));
if (!(sObj instanceof PDFName)) return null;
const actionType: string = (sObj as any).asString?.() ?? sObj.toString().replace(/^\//, '');
switch (actionType) {
case 'Named': {
const nObj = aObj.lookup(PDFName.of('N'));
const name = nObj instanceof PDFName
? ((nObj as any).asString?.() ?? nObj.toString().replace(/^\//, ''))
: '';
return { type: 'named', namedAction: name };
}
case 'JavaScript': {
const jsObj = aObj.lookup(PDFName.of('JS'));
const js = decodeText(jsObj) ?? jsObj?.toString() ?? '';
return { type: 'javascript', javascript: js };
}
case 'SubmitForm': {
const fObj = aObj.lookup(PDFName.of('F'));
let url = '';
if (fObj instanceof PDFDict) {
url = decodeText(fObj.lookup(PDFName.of('F'))) ?? '';
} else if (fObj) {
url = decodeText(fObj) ?? fObj.toString();
}
const flagsObj = aObj.lookup(PDFName.of('Flags'));
const flags = typeof (flagsObj as any)?.asNumber === 'function' ? (flagsObj as any).asNumber() : 0;
return { type: 'submitForm', url, submitFlags: flags };
}
case 'ResetForm':
return { type: 'resetForm' };
case 'URI': {
const uriObj = aObj.lookup(PDFName.of('URI'));
return { type: 'uri', url: decodeText(uriObj) ?? '' };
}
default:
return null;
}
};
const getMkCaption = (dict: any): string | null => {
try {
const mkObj = dict.lookup(PDFName.of('MK'));
if (!(mkObj instanceof PDFDict)) return null;
const caObj = mkObj.lookup(PDFName.of('CA'));
return decodeText(caObj);
} catch {
return null;
}
};
const getActionFromDict = (dict: any): ButtonAction | null => {
try {
return parseActionDict(dict.lookup(PDFName.of('A')));
} catch {
return null;
}
};
const buttonNames = new Set(buttons.map((b) => b.name));
for (const field of form.getFields()) {
const name = field.getName();
if (!buttonNames.has(name)) continue;
try {
const acroField = (field as any).acroField;
if (!acroField?.dict) continue;
const info: { label?: string; action?: ButtonAction } = {};
// Try widget dicts first (each widget can have its own /MK and /A)
const widgets: any[] = (acroField as any).getWidgets?.() ?? [];
for (const widget of widgets) {
if (!info.label) {
const label = getMkCaption(widget.dict);
if (label) info.label = label;
}
if (!info.action) {
const action = getActionFromDict(widget.dict);
if (action) info.action = action;
}
if (info.label && info.action) break;
}
// Fall back to field-level dict
if (!info.label) {
const label = getMkCaption(acroField.dict);
if (label) info.label = label;
}
if (!info.action) {
const action = getActionFromDict(acroField.dict);
if (action) info.action = action;
}
// Also check /AA (Additional Actions) → /U (Mouse Up) if no /A found
if (!info.action) {
try {
const aaObj = acroField.dict.lookup(PDFName.of('AA'));
if (aaObj instanceof PDFDict) {
const uObj = aaObj.lookup(PDFName.of('U'));
const action = parseActionDict(uObj);
if (action) info.action = action;
}
} catch { /* non-critical */ }
}
if (info.label || info.action) {
result.set(name, info);
}
} catch { /* skip individual field errors */ }
}
} catch (e) {
console.warn('[PdfiumFormProvider] Failed to extract button info:', e);
}
return result;
}
async fillForm(
file: File | Blob,
values: Record<string, string>,
flatten: boolean,
): Promise<Blob> {
const arrayBuffer = await file.arrayBuffer();
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(arrayBuffer);
try {
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
if (!formEnvPtr) {
throw new Error('PDFium: failed to initialise form environment');
}
const pageCount = m.FPDF_GetPageCount(docPtr);
// Track radio widget index per field for index-based matching.
// The UI stores radio values as widget indices (e.g., "0", "1", "2").
const radioWidgetIdx = new Map<string, number>();
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
if (!pagePtr) continue;
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
for (let ai = 0; ai < annotCount; ai++) {
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
if (!annotPtr) continue;
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
m.FPDFPage_CloseAnnot(annotPtr);
continue;
}
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
let fieldName = '';
if (nl > 0) {
const nb = m.pdfium.wasmExports.malloc(nl);
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
fieldName = readUtf16(m, nb, nl);
m.pdfium.wasmExports.free(nb);
}
if (!fieldName || !(fieldName in values)) {
m.FPDFPage_CloseAnnot(annotPtr);
continue;
}
const value = values[fieldName];
const fieldType = m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr);
try {
if (fieldType === PDF_FORM_FIELD_TYPE.TEXTFIELD) {
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
m.FORM_SelectAllText(formEnvPtr, pagePtr);
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
m.pdfium.wasmExports.free(wPtr);
m.FORM_ForceToKillFocus(formEnvPtr);
} else if (fieldType === PDF_FORM_FIELD_TYPE.CHECKBOX) {
// Toggle checkbox using the same approach as @embedpdf engine:
// Focus → Enter key (FORM_OnChar with keycode 13) → Kill focus.
// Click simulation (FORM_OnLButtonDown/Up) does NOT reliably
// persist checkbox state changes in headless/offscreen mode.
const isCurrentlyChecked = m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr);
const shouldBeChecked = value !== '' && value !== 'Off';
if (isCurrentlyChecked !== shouldBeChecked) {
const ENTER_KEY = 13;
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
m.FORM_ForceToKillFocus(formEnvPtr);
}
} else if (fieldType === PDF_FORM_FIELD_TYPE.RADIOBUTTON) {
// Radio values are stored as widget indices (e.g., "0", "1", "2").
// Track the current widget index for this field and toggle only
// the widget whose index matches the stored value.
const currentIdx = radioWidgetIdx.get(fieldName) ?? 0;
radioWidgetIdx.set(fieldName, currentIdx + 1);
const targetIdx = parseInt(value, 10);
if (!isNaN(targetIdx) && currentIdx === targetIdx) {
const isAlreadyChecked = m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr);
if (!isAlreadyChecked) {
const ENTER_KEY = 13;
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
m.FORM_ForceToKillFocus(formEnvPtr);
}
}
} else if (
fieldType === PDF_FORM_FIELD_TYPE.COMBOBOX ||
fieldType === PDF_FORM_FIELD_TYPE.LISTBOX
) {
// FORM_SetIndexSelected requires the annotation to be focused first.
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
let matched = false;
const optCount = m.FPDFAnnot_GetOptionCount(formEnvPtr, annotPtr);
for (let oi = 0; oi < optCount; oi++) {
const optLen = m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, 0, 0);
if (optLen > 0) {
const ob = m.pdfium.wasmExports.malloc(optLen);
m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, ob, optLen);
const optLabel = readUtf16(m, ob, optLen);
m.pdfium.wasmExports.free(ob);
if (optLabel === value) {
m.FORM_SetIndexSelected(formEnvPtr, pagePtr, oi, true);
matched = true;
break;
}
}
}
// Fallback: set as text (handles editable combos or
// cases where export values differ from display labels).
if (!matched && value) {
m.FORM_SelectAllText(formEnvPtr, pagePtr);
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
m.pdfium.wasmExports.free(wPtr);
}
m.FORM_ForceToKillFocus(formEnvPtr);
}
} catch (err) {
console.warn(`[PdfiumFormProvider] Failed to set "${fieldName}":`, err);
}
m.FPDFPage_CloseAnnot(annotPtr);
}
if (flatten) {
m.FPDFPage_Flatten(pagePtr, FLAT_PRINT);
}
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
m.FPDF_ClosePage(pagePtr);
}
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
const savedBytes = await saveRawDocument(docPtr);
return new Blob([savedBytes], { type: 'application/pdf' });
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
}

View File

@@ -1,3 +1,3 @@
export type { IFormDataProvider } from '@app/tools/formFill/providers/types';
export { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
export { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';

View File

@@ -30,6 +30,10 @@ export interface FormField {
multiline: boolean;
tooltip: string | null;
widgets: WidgetCoordinates[] | null;
/** Visual label for push buttons (from /MK/CA in the PDF appearance dict) */
buttonLabel?: string | null;
/** Action descriptor for push buttons */
buttonAction?: ButtonAction | null;
/** Pre-rendered appearance image for signed signature fields (data URL). */
appearanceDataUrl?: string;
}
@@ -43,6 +47,26 @@ export type FormFieldType =
| 'button'
| 'signature';
export type ButtonActionType =
| 'named'
| 'javascript'
| 'submitForm'
| 'resetForm'
| 'uri'
| 'none';
export interface ButtonAction {
type: ButtonActionType;
/** For 'named' actions: the PDF action name (e.g. 'Print', 'NextPage', 'PrevPage') */
namedAction?: string;
/** For 'javascript' actions: the JavaScript source code */
javascript?: string;
/** For 'submitForm' / 'uri' actions: the target URL */
url?: string;
/** For 'submitForm' actions: submit flags bitmask */
submitFlags?: number;
}
export interface FormFillState {
/** Fields fetched from backend with coordinates */
fields: FormField[];

View File

@@ -1,4 +1,8 @@
import { PDFDocument, PageSizes } from '@cantoo/pdf-lib';
import {
getPdfiumModule,
saveRawDocument,
} from '@app/services/pdfiumService';
import { copyRgbaToBgraHeap } from '@app/utils/pdfiumBitmapUtils';
export interface ImageToPdfOptions {
imageResolution?: 'full' | 'reduced';
@@ -6,11 +10,14 @@ export interface ImageToPdfOptions {
stretchToFit?: boolean;
}
// Standard page sizes in PDF points (72 dpi)
const PAGE_SIZES = {
A4: [595.276, 841.890] as [number, number],
Letter: [612, 792] as [number, number],
};
/**
* Convert an image file to a PDF file
* @param imageFile - The image file to convert (JPEG, PNG, etc.)
* @param options - Conversion options
* @returns A Promise that resolves to a PDF File object
* Convert an image file to a PDF file using PDFium WASM.
*/
export async function convertImageToPdf(
imageFile: File,
@@ -21,121 +28,143 @@ export async function convertImageToPdf(
pageFormat = 'A4',
stretchToFit = false,
} = options;
try {
// Create a new PDF document
const pdfDoc = await PDFDocument.create();
// Read the image file as an array buffer
let imageBytes = await imageFile.arrayBuffer();
try {
const m = await getPdfiumModule();
// Read the image file
let imageBlob: Blob = imageFile;
// Apply image resolution reduction if requested
if (imageResolution === 'reduced') {
const reducedImage = await reduceImageResolution(imageFile, 1200); // Max 1200px on longest side
imageBytes = await reducedImage.arrayBuffer();
imageBlob = await reduceImageResolution(imageFile, 1200);
}
// Embed the image based on its type
let image;
const imageType = imageFile.type.toLowerCase();
if (imageType === 'image/jpeg' || imageType === 'image/jpg') {
image = await pdfDoc.embedJpg(imageBytes);
} else if (imageType === 'image/png') {
image = await pdfDoc.embedPng(imageBytes);
} else {
// For other image types, try to convert to PNG first using canvas
const convertedImage = await convertImageToPng(imageFile);
const convertedBytes = await convertedImage.arrayBuffer();
image = await pdfDoc.embedPng(convertedBytes);
// Decode image to RGBA pixels via canvas
const decoded = await decodeImageToRgba(imageBlob);
if (!decoded) {
throw new Error('Failed to decode image');
}
// Get image dimensions
const { width: imageWidth, height: imageHeight } = image;
const { rgba, width: imageWidth, height: imageHeight } = decoded;
// Determine page dimensions based on pageFormat option
// Determine page dimensions
let pageWidth: number;
let pageHeight: number;
if (pageFormat === 'keep') {
// Use original image dimensions
pageWidth = imageWidth;
pageHeight = imageHeight;
} else if (pageFormat === 'letter') {
// US Letter: 8.5" x 11" = 612 x 792 points
pageWidth = PageSizes.Letter[0];
pageHeight = PageSizes.Letter[1];
[pageWidth, pageHeight] = PAGE_SIZES.Letter;
} else {
// A4: 210mm x 297mm = 595 x 842 points (default)
pageWidth = PageSizes.A4[0];
pageHeight = PageSizes.A4[1];
[pageWidth, pageHeight] = PAGE_SIZES.A4;
}
// Adjust page orientation based on image orientation if using standard page size
// Adjust orientation to match image
if (pageFormat !== 'keep') {
const imageIsLandscape = imageWidth > imageHeight;
const pageIsLandscape = pageWidth > pageHeight;
// Rotate page to match image orientation
if (imageIsLandscape !== pageIsLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
}
// Create a page
const page = pdfDoc.addPage([pageWidth, pageHeight]);
// Calculate image placement based on stretchToFit option
// Calculate image placement
let drawX: number;
let drawY: number;
let drawWidth: number;
let drawHeight: number;
if (stretchToFit || pageFormat === 'keep') {
// Stretch/fill to page
drawX = 0;
drawY = 0;
drawWidth = pageWidth;
drawHeight = pageHeight;
} else {
// Fit within page bounds while preserving aspect ratio
const imageAspectRatio = imageWidth / imageHeight;
const pageAspectRatio = pageWidth / pageHeight;
if (imageAspectRatio > pageAspectRatio) {
// Image is wider than page - fit to width
drawWidth = pageWidth;
drawHeight = pageWidth / imageAspectRatio;
drawX = 0;
drawY = (pageHeight - drawHeight) / 2; // Center vertically
drawY = (pageHeight - drawHeight) / 2;
} else {
// Image is taller than page - fit to height
drawHeight = pageHeight;
drawWidth = pageHeight * imageAspectRatio;
drawY = 0;
drawX = (pageWidth - drawWidth) / 2; // Center horizontally
drawX = (pageWidth - drawWidth) / 2;
}
}
// Draw the image on the page
page.drawImage(image, {
x: drawX,
y: drawY,
width: drawWidth,
height: drawHeight,
});
// Create new PDF document
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error('PDFium: failed to create document');
// Save the PDF to bytes
const pdfBytes = await pdfDoc.save();
try {
// Create a page
const pagePtr = m.FPDFPage_New(docPtr, 0, pageWidth, pageHeight);
if (!pagePtr) throw new Error('PDFium: failed to create page');
// Create a filename by replacing the image extension with .pdf
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
// Create bitmap from RGBA data (PDFium uses BGRA)
const bitmapPtr = m.FPDFBitmap_Create(imageWidth, imageHeight, 1);
if (!bitmapPtr) throw new Error('PDFium: failed to create bitmap');
// Create a File object from the PDF bytes
const pdfFile = new File([new Uint8Array(pdfBytes)], pdfFilename, {
type: 'application/pdf',
});
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
return pdfFile;
// Bulk RGBA → BGRA copy via shared utility
copyRgbaToBgraHeap(m, rgba, bufferPtr, imageWidth, imageHeight, stride);
// Create image page object
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (!imageObjPtr) {
m.FPDFBitmap_Destroy(bitmapPtr);
throw new Error('PDFium: failed to create image object');
}
const setBitmapOk = m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr);
m.FPDFBitmap_Destroy(bitmapPtr);
if (!setBitmapOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
throw new Error('PDFium: failed to set bitmap on image object');
}
// Set transformation matrix: scale + translate
// FS_MATRIX: {a, b, c, d, e, f} — 6 floats
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
m.pdfium.setValue(matrixPtr, drawWidth, 'float'); // a = scaleX
m.pdfium.setValue(matrixPtr + 4, 0, 'float'); // b
m.pdfium.setValue(matrixPtr + 8, 0, 'float'); // c
m.pdfium.setValue(matrixPtr + 12, drawHeight, 'float'); // d = scaleY
m.pdfium.setValue(matrixPtr + 16, drawX, 'float'); // e = translateX
m.pdfium.setValue(matrixPtr + 20, drawY, 'float'); // f = translateY
const setMatrixOk = m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr);
m.pdfium.wasmExports.free(matrixPtr);
if (!setMatrixOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
throw new Error('PDFium: failed to set image matrix');
}
// Insert image into page
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
// Generate page content stream
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
// Save document
const pdfBytes = await saveRawDocument(docPtr);
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
return new File([pdfBytes], pdfFilename, { type: 'application/pdf' });
} finally {
m.FPDF_CloseDocument(docPtr);
}
} catch (error) {
console.error('Error converting image to PDF:', error);
throw new Error(
@@ -146,57 +175,41 @@ export async function convertImageToPdf(
}
/**
* Convert an image file to PNG using canvas
* This is used for image types that pdf-lib doesn't directly support
* Decode an image Blob to RGBA pixel data via canvas.
*/
async function convertImageToPng(imageFile: File): Promise<File> {
return new Promise((resolve, reject) => {
function decodeImageToRgba(imageBlob: Blob): Promise<{ rgba: Uint8Array; width: number; height: number } | null> {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(imageFile);
const url = URL.createObjectURL(imageBlob);
img.onload = () => {
try {
// Create a canvas with the image dimensions
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw the image on the canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
URL.revokeObjectURL(url);
resolve(null);
return;
}
ctx.drawImage(img, 0, 0);
// Convert canvas to blob
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to convert canvas to blob'));
return;
}
// Create a File object from the blob
const pngFilename = imageFile.name.replace(/\.[^.]+$/, '.png');
const pngFile = new File([blob], pngFilename, {
type: 'image/png',
});
URL.revokeObjectURL(url);
resolve(pngFile);
},
'image/png',
1.0
);
} catch (error) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
reject(error);
resolve({
rgba: new Uint8Array(imageData.data.buffer),
width: canvas.width,
height: canvas.height,
});
} catch {
URL.revokeObjectURL(url);
resolve(null);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
resolve(null);
};
img.src = url;
@@ -205,9 +218,6 @@ async function convertImageToPng(imageFile: File): Promise<File> {
/**
* Reduce image resolution to a maximum dimension
* @param imageFile - The image file to reduce
* @param maxDimension - Maximum width or height in pixels
* @returns A Promise that resolves to a reduced resolution image file
*/
async function reduceImageResolution(
imageFile: File,
@@ -221,14 +231,12 @@ async function reduceImageResolution(
try {
const { width, height } = img;
// Check if reduction is needed
if (width <= maxDimension && height <= maxDimension) {
URL.revokeObjectURL(url);
resolve(imageFile); // No reduction needed
resolve(imageFile);
return;
}
// Calculate new dimensions while preserving aspect ratio
let newWidth: number;
let newHeight: number;
@@ -240,19 +248,14 @@ async function reduceImageResolution(
newWidth = (width / height) * maxDimension;
}
// Create a canvas with the new dimensions
const canvas = document.createElement('canvas');
canvas.width = newWidth;
canvas.height = newHeight;
// Draw the resized image on the canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
if (!ctx) throw new Error('Failed to get canvas context');
ctx.drawImage(img, 0, 0, newWidth, newHeight);
// Convert canvas to blob (preserve original format if possible)
const outputType = imageFile.type.startsWith('image/')
? imageFile.type
: 'image/jpeg';
@@ -263,17 +266,12 @@ async function reduceImageResolution(
reject(new Error('Failed to convert canvas to blob'));
return;
}
// Create a File object from the blob
const reducedFile = new File([blob], imageFile.name, {
type: outputType,
});
const reducedFile = new File([blob], imageFile.name, { type: outputType });
URL.revokeObjectURL(url);
resolve(reducedFile);
},
outputType,
0.9 // Quality (only applies to JPEG)
0.9,
);
} catch (error) {
URL.revokeObjectURL(url);

View File

@@ -1,18 +1,24 @@
/**
* pdfLinkUtils — Create, modify, and extract link annotations in PDF documents.
*
* Migrated from @cantoo/pdf-lib to @embedpdf/pdfium WASM.
* All operations are performed via PDFium C API wrappers.
*/
import {
PDFDocument,
PDFPage,
PDFName,
PDFString,
PDFArray,
PDFDict,
PDFRef,
PDFContext,
PDFNumber,
PDFHexString,
} from '@cantoo/pdf-lib';
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
readUtf16,
writeUtf16,
readAnnotRectAdjusted,
parseRectToCss,
} from '@app/services/pdfiumService';
import {
FPDF_ANNOT_LINK,
PDFACTION_GOTO,
PDFACTION_URI,
} from '@app/utils/pdfiumBitmapUtils';
export type LinkType = 'internal' | 'external' | 'unknown';
export type LinkBorderStyle = 'solid' | 'dashed' | 'beveled' | 'inset' | 'underline';
@@ -40,9 +46,9 @@ export interface PdfLibLink {
}
export interface CreateLinkOptions {
/** Page to place the link on. */
page: PDFPage;
/** Link rectangle in PDF user-space coordinates (lower-left origin). */
/** 0-based page index. */
pageIndex: number;
/** Link rectangle in CSS (top-left origin) coordinate space. */
rect: { x: number; y: number; width: number; height: number };
/** External URL (mutually exclusive with destinationPage). */
url?: string;
@@ -62,22 +68,21 @@ export interface CreateLinkOptions {
/**
* Create a link annotation on a PDF page.
* Supports both external URIs and internal GoTo page destinations.
* Mutates the document in-place and returns the updated PDF bytes.
*/
export function createLinkAnnotation(
pdfDoc: PDFDocument,
export async function createLinkAnnotation(
data: ArrayBuffer | Uint8Array,
options: CreateLinkOptions,
): void {
password?: string,
): Promise<ArrayBuffer> {
const {
page,
pageIndex,
rect,
url,
destinationPage,
title,
color = [0, 0, 1],
borderWidth = 0,
borderStyle = 'solid',
highlightMode = 'invert',
} = options;
if (!url && destinationPage === undefined) {
@@ -86,468 +91,244 @@ export function createLinkAnnotation(
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) {
if (rect.width <= 0 || rect.height <= 0) {
throw new Error('createLinkAnnotation: rect dimensions must be positive');
}
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data, password);
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
if (destinationPage !== undefined && (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 pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
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)),
};
try {
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
if (title) {
entries.Contents = PDFString.of(title);
}
const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_LINK);
if (!annotPtr) {
throw new Error('Failed to create link annotation');
}
const annotDict = ctx.obj(entries);
try {
// Set rect (convert from CSS top-left to PDF bottom-left origin)
// FS_RECTF layout: { left, top, right, bottom } where top > bottom in PDF coords
const pdfLeft = rect.x;
const pdfTop = pageHeight - rect.y; // CSS y=0 → PDF top
const pdfRight = rect.x + rect.width;
const pdfBottom = pageHeight - rect.y - rect.height; // CSS bottom → PDF bottom
if (borderStyle !== 'solid' && borderWidth > 0) {
const bsDict = ctx.obj({
W: borderWidth,
S: PDFName.of(borderStyleCode(borderStyle)),
});
(annotDict as PDFDict).set(PDFName.of('BS'), bsDict);
}
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
m.pdfium.setValue(rectBuf, pdfLeft, 'float'); // offset 0: left
m.pdfium.setValue(rectBuf + 4, pdfTop, 'float'); // offset 4: top (larger y)
m.pdfium.setValue(rectBuf + 8, pdfRight, 'float'); // offset 8: right
m.pdfium.setValue(rectBuf + 12, pdfBottom, 'float'); // offset 12: bottom (smaller y)
m.FPDFAnnot_SetRect(annotPtr, rectBuf);
m.pdfium.wasmExports.free(rectBuf);
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);
}
// Set color
// FPDFANNOT_COLORTYPE_Color = 0
m.FPDFAnnot_SetColor(
annotPtr,
0,
Math.round(color[0] * 255),
Math.round(color[1] * 255),
Math.round(color[2] * 255),
255,
);
const annotRef = ctx.register(annotDict);
// Set border
m.FPDFAnnot_SetBorder(annotPtr, 0, 0, borderWidth);
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]));
// Set URI for external links
if (url) {
const uriPtr = writeUtf16(m, url);
m.FPDFAnnot_SetURI(annotPtr, uriPtr);
m.pdfium.wasmExports.free(uriPtr);
}
// Set title / contents
if (title) {
const titlePtr = writeUtf16(m, title);
m.FPDFAnnot_SetStringValue(annotPtr, 'Contents', titlePtr);
m.pdfium.wasmExports.free(titlePtr);
}
} finally {
m.FPDFPage_CloseAnnot(annotPtr);
}
} finally {
m.FPDF_ClosePage(pagePtr);
}
} else {
page.node.set(PDFName.of('Annots'), ctx.obj([annotRef]));
return await saveRawDocument(docPtr);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
/**
* Remove a link annotation from a page by its index in the /Annots array.
* Returns true if the annotation was found and removed.
* Remove a link annotation from a page by its index.
* Returns the updated PDF bytes.
*/
export function removeLinkAnnotation(
pdfDoc: PDFDocument,
page: PDFPage,
export async function removeLinkAnnotation(
data: ArrayBuffer | Uint8Array,
pageIndex: number,
annotIndex: number,
): boolean {
const ctx = pdfDoc.context;
const annotsRaw = page.node.get(PDFName.of('Annots'));
if (!annotsRaw) return false;
password?: string,
): Promise<ArrayBuffer> {
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data, password);
const annots =
annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
if (!(annots instanceof PDFArray)) return false;
try {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
if (annotIndex < 0 || annotIndex >= annots.size()) return false;
m.FPDFPage_RemoveAnnot(pagePtr, annotIndex);
m.FPDF_ClosePage(pagePtr);
const entry = annots.get(annotIndex);
if (entry instanceof PDFRef) {
ctx.delete(entry);
return await saveRawDocument(docPtr);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
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,
export async function extractLinksFromPage(
data: ArrayBuffer | Uint8Array,
pageIndex: number,
): PdfLibLink[] {
const links: PdfLibLink[] = [];
const ctx = doc.context;
const { height: pageHeight } = page.getSize();
password?: string,
): Promise<{ links: PdfLibLink[]; pdfPageWidth: number; pdfPageHeight: number }> {
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data, password);
const annotsRaw = page.node.get(PDFName.of('Annots'));
if (!annotsRaw) return links;
try {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) return { links: [], pdfPageWidth: 0, pdfPageHeight: 0 };
const annots = annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
if (!(annots instanceof PDFArray)) return links;
const pageWidth = m.FPDF_GetPageWidthF(pagePtr);
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
const links: PdfLibLink[] = [];
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
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;
for (let i = 0; i < annotCount; i++) {
try {
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, i);
if (!annotPtr) continue;
const subtype = annot.get(PDFName.of('Subtype'));
if (subtype?.toString() !== '/Link') continue;
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
if (subtype !== FPDF_ANNOT_LINK) {
m.FPDFPage_CloseAnnot(annotPtr);
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;
// Get rect (CropBox-adjusted for correct overlay positioning)
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
const hasRect = readAnnotRectAdjusted(m, annotPtr, rectBuf);
if (!hasRect) {
m.pdfium.wasmExports.free(rectBuf);
m.FPDFPage_CloseAnnot(annotPtr);
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 rect = parseRectToCss(m, rectBuf, pageHeight);
m.pdfium.wasmExports.free(rectBuf);
const left = Math.min(x1, x2);
const bottom = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
// Try to get link object
const linkPtr = m.FPDFAnnot_GetLink(annotPtr);
let linkType: LinkType = 'unknown';
let targetPage: number | undefined;
let uri: string | undefined;
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);
if (linkPtr) {
// Check for action
const actionPtr = m.FPDFLink_GetAction(linkPtr);
if (actionPtr) {
const actionType = m.FPDFAction_GetType(actionPtr);
if (actionType === PDFACTION_URI) {
const uriLen = m.FPDFAction_GetURIPath(docPtr, actionPtr, 0, 0);
if (uriLen > 0) {
const uriBuf = m.pdfium.wasmExports.malloc(uriLen);
m.FPDFAction_GetURIPath(docPtr, actionPtr, uriBuf, uriLen);
uri = m.pdfium.UTF8ToString(uriBuf);
m.pdfium.wasmExports.free(uriBuf);
linkType = 'external';
}
} else if (actionType === PDFACTION_GOTO) {
const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr);
if (destPtr) {
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
linkType = 'internal';
}
}
}
} 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);
// Check for direct destination
if (linkType === 'unknown') {
const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr);
if (destPtr) {
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
linkType = 'internal';
}
}
}
}
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);
// Get title from /Contents
let title: string | undefined;
const contentsLen = m.FPDFAnnot_GetStringValue(annotPtr, 'Contents', 0, 0);
if (contentsLen > 2) {
const contentsBuf = m.pdfium.wasmExports.malloc(contentsLen);
m.FPDFAnnot_GetStringValue(annotPtr, 'Contents', contentsBuf, contentsLen);
title = readUtf16(m, contentsBuf, contentsLen) || undefined;
m.pdfium.wasmExports.free(contentsBuf);
}
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);
}
// Get color
let color: [number, number, number] | undefined;
// allocate 4 uint for RGBA
const rPtr = m.pdfium.wasmExports.malloc(16);
const gPtr = rPtr + 4;
const bPtr = rPtr + 8;
const aPtr = rPtr + 12;
const hasColor = m.FPDFAnnot_GetColor(annotPtr, 0, rPtr, gPtr, bPtr, aPtr);
if (hasColor) {
color = [
m.pdfium.getValue(rPtr, 'i32') / 255,
m.pdfium.getValue(gPtr, 'i32') / 255,
m.pdfium.getValue(bPtr, 'i32') / 255,
];
}
m.pdfium.wasmExports.free(rPtr);
links.push({
id: `link-${pageIndex}-${i}`,
annotIndex: i,
rect,
type: linkType,
targetPage,
uri,
title,
color,
});
m.FPDFPage_CloseAnnot(annotPtr);
} catch (e) {
console.warn('[pdfLinkUtils] Failed to parse annotation:', e);
}
}
}
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';
m.FPDF_ClosePage(pagePtr);
return { links, pdfPageWidth: pageWidth, pdfPageHeight: pageHeight };
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
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;
}

View File

@@ -0,0 +1,208 @@
/**
* pdfiumBitmapUtils — Shared utilities for working with PDFium WASM bitmaps.
*
* Centralises the RGBA→BGRA pixel conversion and image-to-page-object
* embedding that previously appeared (copy-pasted) in at least 5 files.
*
* Performance note: instead of calling `m.pdfium.setValue()` per pixel
* (which crosses the JS↔WASM FFI boundary on every call), we perform
* the colour-channel swizzle in a plain JS TypedArray and then bulk-
* copy the result into the WASM heap with a single `HEAPU8.set()`.
*/
import type { WrappedPdfiumModule } from '@embedpdf/pdfium';
/** FPDF_ANNOT_LINK */
export const FPDF_ANNOT_LINK = 4;
/** FPDF_ANNOT_WIDGET (form field) */
export const FPDF_ANNOT_WIDGET = 20;
/** FPDF_ANNOT_INK */
export const FPDF_ANNOT_INK = 15;
/** FPDF_ANNOT_LINE */
export const FPDF_ANNOT_LINE = 3;
/** PDFACTION_GOTO */
export const PDFACTION_GOTO = 1;
/** PDFACTION_URI */
export const PDFACTION_URI = 3;
/** FLAT_PRINT (for FPDFPage_Flatten) */
export const FLAT_PRINT = 2;
/**
* Convert an RGBA pixel buffer to BGRA (PDFium's expected format) **in place
* inside the WASM heap** with a single bulk memcpy.
*
* When `stride === width * 4` the copy is a single `HEAPU8.set()`.
* When the bitmap has padding (stride > width * 4), rows are copied
* individually to skip the padding bytes.
*
* This is ~100× faster than per-pixel `m.pdfium.setValue()` calls for
* large images.
*/
export function copyRgbaToBgraHeap(
m: WrappedPdfiumModule,
rgba: Uint8Array | Uint8ClampedArray,
bufferPtr: number,
width: number,
height: number,
stride: number,
): void {
const rowBytes = width * 4;
if (stride === rowBytes) {
// Fast path: no padding — single bulk copy after swizzle
const bgra = new Uint8Array(rgba.length);
for (let i = 0; i < rgba.length; i += 4) {
bgra[i] = rgba[i + 2]; // B
bgra[i + 1] = rgba[i + 1]; // G
bgra[i + 2] = rgba[i]; // R
bgra[i + 3] = rgba[i + 3]; // A
}
new Uint8Array(
(m.pdfium.wasmExports as any).memory.buffer,
).set(bgra, bufferPtr);
} else {
// Stride has padding — swizzle + copy row by row
const rowBuf = new Uint8Array(rowBytes);
const heap = new Uint8Array(
(m.pdfium.wasmExports as any).memory.buffer,
);
for (let y = 0; y < height; y++) {
const srcRowStart = y * rowBytes;
for (let x = 0; x < rowBytes; x += 4) {
rowBuf[x] = rgba[srcRowStart + x + 2]; // B
rowBuf[x + 1] = rgba[srcRowStart + x + 1]; // G
rowBuf[x + 2] = rgba[srcRowStart + x]; // R
rowBuf[x + 3] = rgba[srcRowStart + x + 3]; // A
}
heap.set(rowBuf, bufferPtr + y * stride);
}
}
}
export interface DecodedImage {
rgba: Uint8Array;
width: number;
height: number;
}
/**
* Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image
* page object, position it via an affine matrix, and insert it into the page.
*
* Returns `true` if the image was successfully inserted, `false` otherwise.
* All intermediate WASM resources are cleaned up on failure.
*/
export function embedBitmapImageOnPage(
m: WrappedPdfiumModule,
docPtr: number,
pagePtr: number,
image: DecodedImage,
pdfX: number,
pdfY: number,
drawWidth: number,
drawHeight: number,
): boolean {
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
if (!bitmapPtr) return false;
try {
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
copyRgbaToBgraHeap(m, image.rgba, bufferPtr, image.width, image.height, stride);
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (!imageObjPtr) return false;
const setBitmapOk = m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr);
if (!setBitmapOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
return false;
}
// -- early-destroy the bitmap; PDFium has copied the pixel data internally
m.FPDFBitmap_Destroy(bitmapPtr);
// Set affine transform: [a b c d e f]
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
try {
m.pdfium.setValue(matrixPtr, drawWidth, 'float'); // a — scaleX
m.pdfium.setValue(matrixPtr + 4, 0, 'float'); // b
m.pdfium.setValue(matrixPtr + 8, 0, 'float'); // c
m.pdfium.setValue(matrixPtr + 12, drawHeight, 'float'); // d — scaleY
m.pdfium.setValue(matrixPtr + 16, pdfX, 'float'); // e — translateX
m.pdfium.setValue(matrixPtr + 20, pdfY, 'float'); // f — translateY
if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
m.FPDFPageObj_Destroy(imageObjPtr);
return false;
}
} finally {
m.pdfium.wasmExports.free(matrixPtr);
}
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
return true;
} finally {
// Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium
// builds but guard anyway. If already destroyed above, the second call
// is harmless because we allow it to be idempotent.
// We use a try-catch to be safe across PDFium WASM builds.
try { m.FPDFBitmap_Destroy(bitmapPtr); } catch { /* already freed */ }
}
}
/**
* Draw a simple light-grey rectangle as a placeholder for annotations
* that could not be rendered.
*/
export function drawPlaceholderRect(
m: WrappedPdfiumModule,
pagePtr: number,
x: number,
y: number,
width: number,
height: number,
): void {
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, x + width, y);
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
m.FPDFPath_LineTo(pathPtr, x, y + height);
m.FPDFPath_Close(pathPtr);
m.FPDFPageObj_SetFillColor(pathPtr, 230, 230, 230, 150);
m.FPDFPageObj_SetStrokeColor(pathPtr, 128, 128, 128, 255);
m.FPDFPageObj_SetStrokeWidth(pathPtr, 1);
// fillMode 1 = alternate fill, stroke = true
m.FPDFPath_SetDrawMode(pathPtr, 1, true);
m.FPDFPage_InsertObject(pagePtr, pathPtr);
}
/**
* Decode an image data URL (e.g. `data:image/png;base64,...`) to raw RGBA
* pixel data via an offscreen canvas.
*/
export function decodeImageDataUrl(dataUrl: string): Promise<DecodedImage | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve(null); return; }
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
resolve({
rgba: new Uint8Array(imageData.data.buffer),
width: canvas.width,
height: canvas.height,
});
} catch { resolve(null); }
};
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}

View File

@@ -1,9 +1,21 @@
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';
// PDFium annotation subtype constants
import {
FPDF_ANNOT_INK,
FPDF_ANNOT_LINE,
embedBitmapImageOnPage,
drawPlaceholderRect,
decodeImageDataUrl,
} from '@app/utils/pdfiumBitmapUtils';
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';
import {
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
} from '@app/services/pdfiumService';
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
@@ -37,7 +49,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
if (signatureApiRef?.current) {
const scrollState = getScrollState();
const totalPages = scrollState.totalPages;
@@ -47,7 +58,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (pageAnnotations && pageAnnotations.length > 0) {
const sessionAnnotations = pageAnnotations.filter(annotation => {
const hasStoredImageData = annotation.id && getImageData(annotation.id);
const hasDirectImageData = annotation.imageData || annotation.appearance ||
annotation.stampData || annotation.imageSrc ||
annotation.contents || annotation.data;
@@ -64,7 +74,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
// Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually)
// Step 2: Delete ONLY session annotations from EmbedPDF before export
if (allAnnotations.length > 0 && signatureApiRef?.current) {
for (const pageData of allAnnotations) {
for (const annotation of pageData.annotations) {
@@ -77,7 +87,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations)
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF
if (!exportActions) {
console.error('No export actions available');
return null;
@@ -85,7 +95,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const pdfArrayBuffer = await exportActions.saveAsCopy();
if (pdfArrayBuffer) {
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
let currentFile = originalFile;
@@ -108,182 +117,85 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
// Step 4: Manually render extracted annotations onto the PDF using PDF-lib
// Step 4: Manually render extracted annotations onto the PDF using PDFium WASM
if (allAnnotations.length > 0) {
try {
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening);
let pdfDoc: PDFDocument;
try {
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
ignoreEncryption: true,
capNumbers: false,
throwOnInvalidObject: false
});
} catch {
console.warn('Failed to load with standard options, trying createProxy...');
try {
pdfDoc = await PDFDocument.create();
const sourcePdf = await PDFDocument.load(pdfArrayBufferForFlattening, {
ignoreEncryption: true,
throwOnInvalidObject: false
});
const pageIndices = sourcePdf.getPages().map((_, i) => i);
const copiedPages = await pdfDoc.copyPages(sourcePdf, pageIndices);
copiedPages.forEach(page => pdfDoc.addPage(page));
} catch (copyError) {
console.error('Failed to load PDF with any method:', copyError);
throw copyError;
}
}
const pageCount = m.FPDF_GetPageCount(docPtr);
const pages = pdfDoc.getPages();
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
if (pageIndex < pageCount) {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) continue;
if (pageIndex < pages.length) {
const page = pages[pageIndex];
const { height: pageHeight } = page.getSize();
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
for (const annotation of annotations) {
try {
for (const annotation of annotations) {
try {
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
if (rect) {
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;
if (rect) {
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 from CSS top-left to PDF bottom-left
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
annotation.imageSrc || annotation.contents || annotation.data;
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
annotation.imageSrc || annotation.contents || annotation.data;
if (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
imageDataUrl = storedImageData;
}
}
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 (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
imageDataUrl = storedImageData;
}
}
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 {
const base64Data = imageDataUrl.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
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);
// Convert SVG to PNG first if needed
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image/svg+xml')) {
const pngBytes = await rasteriseSvgToPng(imageDataUrl, width * 2, height * 2);
if (pngBytes) {
imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes);
} else {
image = await pdfDoc.embedPng(imageBytes);
drawPlaceholderRect(m, pagePtr, pdfX, pdfY, width, height);
continue;
}
page.drawImage(image, {
x: pdfX,
y: pdfY,
width: width,
height: height,
});
} catch (imageError) {
console.error('Failed to render image annotation:', imageError);
}
} else if (annotation.content || annotation.text) {
page.drawText(annotation.content || annotation.text, {
x: pdfX,
y: pdfY + height - 12, // Adjust for text baseline
size: 12,
color: rgb(0, 0, 0)
});
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(0, 0, 0),
borderWidth: 1,
color: rgb(0.95, 0.95, 0.95),
opacity: 0.6
});
} else {
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(1, 0, 0),
borderWidth: 2,
color: rgb(1, 1, 0),
opacity: 0.5
});
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
// Decode the image data URL to raw pixels via canvas
const imageResult = await decodeImageDataUrl(imageDataUrl);
if (imageResult) {
embedBitmapImageOnPage(m, docPtr, pagePtr, imageResult, pdfX, pdfY, width, height);
}
} else if (annotation.type === FPDF_ANNOT_INK || annotation.type === FPDF_ANNOT_LINE) {
drawPlaceholderRect(m, pagePtr, pdfX, pdfY, width, height);
}
}
} catch (annotationError) {
console.warn('Failed to render annotation:', annotationError);
}
} catch (annotationError) {
console.warn('Failed to render annotation:', annotationError);
}
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
}
}
const resultBuf = await saveRawDocument(docPtr);
signedFile = new File([resultBuf], currentFile.name, { type: 'application/pdf' });
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
const flattenedPdfBytes = await pdfDoc.save({ useObjectStreams: false, addDefaultPage: false });
const arrayBuffer = new ArrayBuffer(flattenedPdfBytes.length);
const uint8View = new Uint8Array(arrayBuffer);
uint8View.set(flattenedPdfBytes);
signedFile = new File([arrayBuffer], currentFile.name, { type: 'application/pdf' });
} catch (renderError) {
console.error('Failed to manually render annotations:', renderError);
console.warn('Signatures may only show as annotations');
@@ -325,26 +237,19 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
/**
* Decode an SVG data URL to its raw XML string.
* Handles both base64-encoded and URI-encoded SVG data URLs.
* Convert Uint8Array PNG bytes to a data URL for canvas decoding.
*/
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;
}
function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise<string> {
return new Promise((resolve) => {
const blob = new Blob([pngBytes as BlobPart], { type: 'image/png' });
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}
/**
* 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) => {
@@ -355,17 +260,11 @@ function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): P
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;
}
if (!ctx) { resolve(null); return; }
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (!blob) {
resolve(null);
return;
}
if (!blob) { resolve(null); return; }
blob.arrayBuffer().then(
(buf) => resolve(new Uint8Array(buf)),
() => resolve(null),
@@ -373,9 +272,7 @@ function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): P
},
'image/png',
);
} catch {
resolve(null);
}
} catch { resolve(null); }
};
img.onerror = () => resolve(null);
img.src = svgDataUrl;