From c3530024c4692ee9b0499a7f045285a6a5feda17 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:34:52 +0100 Subject: [PATCH] feat(pdf): replace PdfLib with Pdfium for form handling and general rendering tasks (#5899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 --- ## 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 Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../core/components/viewer/EmbedPdfViewer.tsx | 9 +- .../core/components/viewer/LocalEmbedPDF.tsx | 24 + .../viewer/SignatureFieldOverlay.tsx | 250 +++ .../useAdjustContrastOperation.ts | 107 +- .../useRemoveAnnotationsOperation.ts | 88 +- .../CenteredMessageSection.ts | 8 +- .../outputtedPDFSections/FieldBoxSection.ts | 8 +- .../outputtedPDFSections/SignatureSection.ts | 10 +- .../StatusBadgeSection.ts | 8 +- .../outputtedPDFSections/SummarySection.ts | 10 +- .../outputtedPDFSections/ThumbnailSection.ts | 10 +- .../validateSignature/signatureReportPdf.ts | 7 +- .../validateSignature/utils/pdfPageHelpers.ts | 14 +- .../validateSignature/utils/pdfPalette.ts | 4 +- .../tools/validateSignature/utils/pdfText.ts | 4 +- frontend/src/core/hooks/usePdfLibLinks.ts | 43 +- .../src/core/services/pdfExportService.ts | 221 ++- .../src/core/services/pdfWorkerManager.ts | 9 +- .../src/core/services/pdfiumDocBuilder.ts | 405 ++++ frontend/src/core/services/pdfiumService.ts | 1672 +++++++++++++++++ frontend/src/core/styles/index.css | 13 + .../formFill/ButtonAppearanceOverlay.tsx | 121 ++ .../src/core/tools/formFill/FieldInput.tsx | 12 +- .../core/tools/formFill/FormFieldOverlay.tsx | 281 ++- .../core/tools/formFill/FormFillContext.tsx | 15 +- frontend/src/core/tools/formFill/index.ts | 2 +- .../formFill/providers/PdfLibFormProvider.ts | 865 --------- .../formFill/providers/PdfiumFormProvider.ts | 599 ++++++ .../core/tools/formFill/providers/index.ts | 2 +- frontend/src/core/tools/formFill/types.ts | 24 + frontend/src/core/utils/imageToPdfUtils.ts | 230 ++- frontend/src/core/utils/pdfLinkUtils.ts | 653 +++---- frontend/src/core/utils/pdfiumBitmapUtils.ts | 208 ++ .../src/core/utils/signatureFlattening.ts | 273 +-- 34 files changed, 4299 insertions(+), 1910 deletions(-) create mode 100644 frontend/src/core/components/viewer/SignatureFieldOverlay.tsx create mode 100644 frontend/src/core/services/pdfiumDocBuilder.ts create mode 100644 frontend/src/core/services/pdfiumService.ts create mode 100644 frontend/src/core/tools/formFill/ButtonAppearanceOverlay.tsx delete mode 100644 frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts create mode 100644 frontend/src/core/tools/formFill/providers/PdfiumFormProvider.ts create mode 100644 frontend/src/core/utils/pdfiumBitmapUtils.ts diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 745b0642b1..b0347754b5 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -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 { @@ -38,7 +37,7 @@ async function extractPageMeasureScales(file: Blob): Promise(null); const formFillProviderRef = useRef(isFormFillToolActive); diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index fd6cd8532d..24219c3f92 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -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, + {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} + {enableFormFill && file && ( + + )} + {/* FormFieldOverlay for interactive form filling */} {enableFormFill && ( )} + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} + {file && ( + + )} + {/* AnnotationLayer for annotation editing and annotation-based redactions */} {(enableAnnotations || enableRedaction) && ( ` + * 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 | null = null; + +async function resolveFields( + source: File | Blob, +): Promise { + 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(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 ( + + ); +} + +function SignatureFieldOverlayInner({ + pageIndex, + pdfSource, + documentId: _documentId, + pageWidth, + pageHeight, +}: SignatureFieldOverlayProps) { + const [fields, setFields] = useState([]); + + 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 ( +
+ {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 . + if (field.imageData) { + return ( +
+ +
+ ); + } + + // Fallback: translucent badge for fields without an appearance. + return ( +
+ + {field.isSigned ? '🔒 Signed' : '✎ Signature'} + +
+ ); + })} +
+ ); +} + +const SignatureFieldOverlay = memo(SignatureFieldOverlayInner); +export default SignatureFieldOverlay; diff --git a/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts index 3cab7d89e2..fe862fb392 100644 --- a/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts +++ b/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -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 { 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 { + 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 { - // 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') }); }; - diff --git a/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts b/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts index 99bf65dd23..0f60593bc3 100644 --- a/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts +++ b/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts @@ -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 => { 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( diff --git a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts index baf4f12a1b..12abc2c62d 100644 --- a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts +++ b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts @@ -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; diff --git a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts index 0fe6384deb..7bcfd34041 100644 --- a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts +++ b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts @@ -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; diff --git a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts index bd3538223e..cd926da8ba 100644 --- a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts +++ b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts @@ -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 || '-' }, diff --git a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts index a931f81d34..ebb0dd29d8 100644 --- a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts +++ b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts @@ -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; diff --git a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts index b86f6f7b37..c9f013f57b 100644 --- a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts +++ b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts @@ -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'>; } diff --git a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts index 932415db5d..e10ada3f04 100644 --- a/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts +++ b/frontend/src/core/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts @@ -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, diff --git a/frontend/src/core/hooks/tools/validateSignature/signatureReportPdf.ts b/frontend/src/core/hooks/tools/validateSignature/signatureReportPdf.ts index 589b437cc8..9d37fc194c 100644 --- a/frontend/src/core/hooks/tools/validateSignature/signatureReportPdf.ts +++ b/frontend/src/core/hooks/tools/validateSignature/signatureReportPdf.ts @@ -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 => { - 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, diff --git a/frontend/src/core/hooks/tools/validateSignature/utils/pdfPageHelpers.ts b/frontend/src/core/hooks/tools/validateSignature/utils/pdfPageHelpers.ts index 91b927ed1a..a1c70b1261 100644 --- a/frontend/src/core/hooks/tools/validateSignature/utils/pdfPageHelpers.ts +++ b/frontend/src/core/hooks/tools/validateSignature/utils/pdfPageHelpers.ts @@ -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(); +export const createThumbnailLoader = (doc: PdfiumDocument) => { + const cache = new Map(); 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); diff --git a/frontend/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts b/frontend/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts index 0640633189..59b4907036 100644 --- a/frontend/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts +++ b/frontend/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts @@ -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) { diff --git a/frontend/src/core/hooks/tools/validateSignature/utils/pdfText.ts b/frontend/src/core/hooks/tools/validateSignature/utils/pdfText.ts index 029758eba7..5be41918c1 100644 --- a/frontend/src/core/hooks/tools/validateSignature/utils/pdfText.ts +++ b/frontend/src/core/hooks/tools/validateSignature/utils/pdfText.ts @@ -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/); diff --git a/frontend/src/core/hooks/usePdfLibLinks.ts b/frontend/src/core/hooks/usePdfLibLinks.ts index f46ecefae2..fb5cef19e2 100644 --- a/frontend/src/core/hooks/usePdfLibLinks.ts +++ b/frontend/src/core/hooks/usePdfLibLinks.ts @@ -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; - /** 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>(); @@ -37,13 +34,8 @@ async function acquireDocument(url: string): Promise { const promise = (async (): Promise => { 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); diff --git a/frontend/src/core/services/pdfExportService.ts b/frontend/src/core/services/pdfExportService.ts index c5fdb02f42..bc9ca4908b 100644 --- a/frontend/src/core/services/pdfExportService.ts +++ b/frontend/src/core/services/pdfExportService.ts @@ -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, pages: PDFPage[] ): Promise { - 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(); + const loadedDocs = new Map(); - 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 { - 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(); diff --git a/frontend/src/core/services/pdfWorkerManager.ts b/frontend/src/core/services/pdfWorkerManager.ts index 13f18d0239..7e70901a88 100644 --- a/frontend/src/core/services/pdfWorkerManager.ts +++ b/frontend/src/core/services/pdfWorkerManager.ts @@ -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, } ); diff --git a/frontend/src/core/services/pdfiumDocBuilder.ts b/frontend/src/core/services/pdfiumDocBuilder.ts new file mode 100644 index 0000000000..5e7dffb71d --- /dev/null +++ b/frontend/src/core/services/pdfiumDocBuilder.ts @@ -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 0–1). 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 = 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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; + }); + } +} diff --git a/frontend/src/core/services/pdfiumService.ts b/frontend/src/core/services/pdfiumService.ts new file mode 100644 index 0000000000..2ff996308d --- /dev/null +++ b/frontend/src/core/services/pdfiumService.ts @@ -0,0 +1,1672 @@ +/** + * pdfiumService — Singleton PDFium WASM engine wrapper. + * + * Provides a thin, promise-based API on top of the @embedpdf/pdfium WASM + * module. Every other module that used to depend on @cantoo/pdf-lib for + * low-level PDF object access should import helpers from here instead. + * + * The engine is lazily initialised on the first call to `getEngine()`. + * All PDFium C-API wrappers are available through `WrappedPdfiumModule`. + * + * Higher-level helpers (`openDocument`, `closeDocument`, `getPageAnnotations`, + * `getSignatures`, `saveAsCopy`, …) wrap the `PdfEngine` interface so callers + * never have to deal with raw pointers or Tasks. + */ +import { init, type WrappedPdfiumModule } from '@embedpdf/pdfium'; +import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types'; + +// PDF form field type constants (matching PDFium C API FPDF_FORMFIELD_* values) +const FPDF_FORMFIELD_UNKNOWN = 0; +const FPDF_FORMFIELD_PUSHBUTTON = 1; +const FPDF_FORMFIELD_CHECKBOX = 2; +const FPDF_FORMFIELD_RADIOBUTTON = 3; +const FPDF_FORMFIELD_COMBOBOX = 4; +const FPDF_FORMFIELD_LISTBOX = 5; +const FPDF_FORMFIELD_TEXTFIELD = 6; +const FPDF_FORMFIELD_SIGNATURE = 7; + +/** Form field type enum — mirrors PDFium C API values. */ +export const PDF_FORM_FIELD_TYPE = { + UNKNOWN: FPDF_FORMFIELD_UNKNOWN, + PUSHBUTTON: FPDF_FORMFIELD_PUSHBUTTON, + CHECKBOX: FPDF_FORMFIELD_CHECKBOX, + RADIOBUTTON: FPDF_FORMFIELD_RADIOBUTTON, + COMBOBOX: FPDF_FORMFIELD_COMBOBOX, + LISTBOX: FPDF_FORMFIELD_LISTBOX, + TEXTFIELD: FPDF_FORMFIELD_TEXTFIELD, + SIGNATURE: FPDF_FORMFIELD_SIGNATURE, +} as const; +export type PDF_FORM_FIELD_TYPE = (typeof PDF_FORM_FIELD_TYPE)[keyof typeof PDF_FORM_FIELD_TYPE]; + +let _initPromise: Promise | null = null; +let _module: WrappedPdfiumModule | null = null; + +/** + * Resolve the absolute WASM URL using the same pattern as LocalEmbedPDF. + */ +function wasmUrl(): string { + const base = (import.meta as any).env?.BASE_URL ?? '/'; + return `${base}pdfium/pdfium.wasm`.replace(/\/\//g, '/'); +} + +/** + * Get (or lazily initialise) the raw `WrappedPdfiumModule`. + * + * This is the low-level PDFium WASM interface with all C functions wrapped. + * Prefer `withDocument()` for document-scoped work. + */ +export async function getPdfiumModule(): Promise { + if (_module) return _module; + if (!_initPromise) { + _initPromise = init({ + locateFile: () => wasmUrl(), + } as any).then((m) => { + // Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up + try { m.PDFiumExt_Init(); } catch { /* already initialized */ } + _module = m; + return m; + }); + } + return _initPromise; +} + +/** + * Reset the singleton module after a fatal WASM error. + * Next call to getPdfiumModule() will create a fresh instance. + */ +export function resetPdfiumModule(): void { + _module = null; + _initPromise = null; + _docDataPtrs.clear(); +} + + +/** + * Map of document pointer → WASM data buffer pointer. + * FPDF_LoadMemDocument does NOT copy the data — it keeps a reference, so the + * buffer must stay alive until FPDF_CloseDocument is called. + */ +const _docDataPtrs = new Map(); + +/** + * Read an annotation rectangle using the CropBox-adjusted `EPDFAnnot_GetRect` + * when available (from @embedpdf's extended pdfium build), falling back to the + * standard `FPDFAnnot_GetRect` otherwise. + * + * The extended version returns coordinates relative to the page's visible + * (CropBox) area, which matches the coordinate space used by the EmbedPDF + * renderer. The standard API returns raw MediaBox coordinates which can be + * offset when CropBox origin ≠ (0,0). + * + * FS_RECTF memory layout: { left (f32), top (f32), right (f32), bottom (f32) } + * In PDF coords (origin lower-left): top > bottom. + */ +export function readAnnotRectAdjusted( + m: WrappedPdfiumModule, + annotPtr: number, + rectBuf: number, +): boolean { + const ext = (m as any).EPDFAnnot_GetRect; + if (typeof ext === 'function') { + return ext.call(m, annotPtr, rectBuf); + } + return m.FPDFAnnot_GetRect(annotPtr, rectBuf); +} + +/** + * Parse an FS_RECTF buffer into CSS-space coordinates. + * + * FS_RECTF layout: { left, top, right, bottom } (all floats, PDF lower-left origin). + * Returns { x, y, width, height } in CSS upper-left origin. + */ +export function parseRectToCss( + m: WrappedPdfiumModule, + rectBuf: number, + pageHeight: number, +): { x: number; y: number; width: number; height: number } { + const left = m.pdfium.getValue(rectBuf, 'float'); + const top = m.pdfium.getValue(rectBuf + 4, 'float'); // FS_RECTF.top (larger y) + const right = m.pdfium.getValue(rectBuf + 8, 'float'); + const bottom = m.pdfium.getValue(rectBuf + 12, 'float'); // FS_RECTF.bottom (smaller y) + + const pdfLeft = Math.min(left, right); + const pdfTop = Math.max(top, bottom); + const pdfWidth = Math.abs(right - left); + const pdfHeight = Math.abs(top - bottom); + + return { + x: pdfLeft, + y: pageHeight - pdfTop, // flip: CSS y = pageHeight − PDF top + width: pdfWidth, + height: pdfHeight, + }; +} + +/** CropBox / MediaBox coordinates for a page. */ +interface PageBox { + left: number; + bottom: number; + right: number; + top: number; +} + +/** + * Read the effective visible page box (CropBox if defined, else MediaBox). + * Falls back to FPDF_GetPageWidthF/HeightF with origin (0,0) if neither is found. + * + * The returned values use the standard PDF coordinate system: + * left < right, bottom < top, origin at lower-left. + */ +export function readEffectivePageBox( + m: WrappedPdfiumModule, + pagePtr: number, +): PageBox { + const buf = m.pdfium.wasmExports.malloc(4 * 4); // 4 floats + + const read = (): PageBox | null => { + const l = m.pdfium.getValue(buf, 'float'); + const b = m.pdfium.getValue(buf + 4, 'float'); + const r = m.pdfium.getValue(buf + 8, 'float'); + const t = m.pdfium.getValue(buf + 12, 'float'); + const w = Math.abs(r - l); + const h = Math.abs(t - b); + if (w < 0.01 || h < 0.01) return null; // degenerate + return { left: Math.min(l, r), bottom: Math.min(b, t), right: Math.max(l, r), top: Math.max(b, t) }; + }; + + let result: PageBox | null = null; + try { + // CropBox is the effective visible area + if ((m as any).FPDFPage_GetCropBox(pagePtr, buf, buf + 4, buf + 8, buf + 12)) { + result = read(); + } + // Fall back to MediaBox + if (!result && (m as any).FPDFPage_GetMediaBox(pagePtr, buf, buf + 4, buf + 8, buf + 12)) { + result = read(); + } + } catch { + // If the API calls fail, fall through to dimension fallback + } + + m.pdfium.wasmExports.free(buf); + + if (!result) { + // Last resort: assume origin (0,0) and use page dimensions + return { + left: 0, + bottom: 0, + right: m.FPDF_GetPageWidthF(pagePtr), + top: m.FPDF_GetPageHeightF(pagePtr), + }; + } + return result; +} + +/** + * Copy bytes into WASM heap safely. + * Creates a fresh Uint8Array view of the WASM memory buffer AFTER malloc + * so it is never stale even if malloc triggered a memory growth. + */ +function copyToWasmHeap(m: WrappedPdfiumModule, bytes: Uint8Array, ptr: number): void { + new Uint8Array( + (m.pdfium.wasmExports as any).memory.buffer, + ).set(bytes, ptr); +} + +/** + * Load a PDF into PDFium memory and return the document pointer. + * Caller MUST call `closeRawDocument(docPtr)` when finished. + */ +export async function openRawDocument( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const m = await getPdfiumModule(); + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const len = bytes.length; + const ptr = m.pdfium.wasmExports.malloc(len); + copyToWasmHeap(m, bytes, ptr); + + const docPtr = m.FPDF_LoadMemDocument(ptr, len, password ?? ''); + if (!docPtr) { + m.pdfium.wasmExports.free(ptr); + const err = m.FPDF_GetLastError(); + throw new Error(`PDFium: failed to open document (error ${err})`); + } + // Keep the buffer alive — freed in closeRawDocument() + _docDataPtrs.set(docPtr, ptr); + return docPtr; +} + +/** + * Open a raw document — convenience alias that delegates to {@link openRawDocument}. + * Kept for API compatibility with callers that were updated to use the "Safe" variant. + */ +export async function openRawDocumentSafe( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + return openRawDocument(data, password); +} + +/** + * Close a raw document pointer and free its backing data buffer. + */ +export async function closeRawDocument(docPtr: number): Promise { + const m = await getPdfiumModule(); + closeDocAndFreeBuffer(m, docPtr); +} + +/** + * Synchronous close + buffer free — for use inside `finally` blocks that + * already have the module reference. + */ +export function closeDocAndFreeBuffer(m: WrappedPdfiumModule, docPtr: number): void { + m.FPDF_CloseDocument(docPtr); + const dataPtr = _docDataPtrs.get(docPtr); + if (dataPtr) { + m.pdfium.wasmExports.free(dataPtr); + _docDataPtrs.delete(docPtr); + } +} + +/** + * Get page count for a raw document pointer. + */ +export async function getRawPageCount(docPtr: number): Promise { + const m = await getPdfiumModule(); + return m.FPDF_GetPageCount(docPtr); +} + +/** + * Get raw page dimensions { width, height } for a page. + */ +export async function getRawPageSize( + docPtr: number, + pageIndex: number, +): Promise<{ width: number; height: number }> { + const m = await getPdfiumModule(); + const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); + if (!pagePtr) throw new Error(`PDFium: failed to load page ${pageIndex}`); + const width = m.FPDF_GetPageWidthF(pagePtr); + const height = m.FPDF_GetPageHeightF(pagePtr); + m.FPDF_ClosePage(pagePtr); + return { width, height }; +} + +/** + * Read a UTF-16LE string from PDFium memory at the given pointer up to `len` + * bytes (including the trailing NUL pair). + */ +export function readUtf16(m: WrappedPdfiumModule, ptr: number, byteLen: number): string { + if (byteLen <= 2 || !ptr) return ''; + return m.pdfium.UTF16ToString(ptr); +} + +/** + * Allocate a UTF-16LE buffer in WASM memory, write the string, and return the + * pointer. Caller must free with `m.pdfium.wasmExports.free(ptr)`. + */ +export function writeUtf16(m: WrappedPdfiumModule, str: string): number { + // UTF-16 encodes each char as 2 bytes + 2-byte NUL terminator + const byteLen = (str.length + 1) * 2; + const ptr = m.pdfium.wasmExports.malloc(byteLen); + m.pdfium.stringToUTF16(str, ptr, byteLen); + return ptr; +} + + +export interface PdfiumFormField { + name: string; + type: PDF_FORM_FIELD_TYPE; + value: string; + isChecked: boolean; + isReadOnly: boolean; + isRequired: boolean; + /** Raw PDF field flags bitmask (from FPDFAnnot_GetFormFieldFlags). */ + flags: number; + options: Array<{ label: string; isSelected: boolean }>; + widgets: PdfiumWidgetRect[]; +} + +export interface PdfiumWidgetRect { + pageIndex: number; + x: number; // CSS upper-left origin (after y-flip) + y: number; + width: number; + height: number; + exportValue?: string; + fontSize?: number; + /** Whether this specific widget is checked (radio/checkbox only). */ + isChecked?: boolean; +} + +/** + * Extract all form fields (Widget annotations) from every page of a document. + * + * Returns an array of parsed form fields with their widget rectangles already + * converted to CSS coordinate space (upper-left origin). + */ +export async function extractFormFields( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const m = await getPdfiumModule(); + let docPtr: number; + try { + docPtr = await openRawDocumentSafe(data, password); + } catch (err) { + console.error('[extractFormFields] openRawDocumentSafe failed:', err); + if (err instanceof WebAssembly.RuntimeError) resetPdfiumModule(); + throw err; + } + + try { + // Init form fill environment + const formInfoPtr = m.PDFiumExt_OpenFormFillInfo(); + const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr); + + const pageCount = m.FPDF_GetPageCount(docPtr); + console.debug('[extractFormFields] docPtr=%d formEnvPtr=%d pageCount=%d dataSize=%d', + docPtr, formEnvPtr, pageCount, data instanceof Uint8Array ? data.length : data.byteLength); + // Map: fieldName → PdfiumFormField (to merge widgets across pages) + const fieldMap = new Map(); + + for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) { + let pagePtr: number; + try { + pagePtr = m.FPDF_LoadPage(docPtr, pageIdx); + } catch (err) { + console.warn('[extractFormFields] FPDF_LoadPage crashed for page', pageIdx, err); + if (err instanceof WebAssembly.RuntimeError) { + resetPdfiumModule(); + throw err; + } + continue; + } + if (!pagePtr) { + console.warn('[extractFormFields] FPDF_LoadPage returned 0 for page', pageIdx); + continue; + } + + // Notify form system about the page + if (formEnvPtr) { + try { + m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr); + } catch (err) { + console.warn('[extractFormFields] FORM_OnAfterLoadPage crashed for page', pageIdx, err); + } + } + + // Read effective page box (CropBox if available, else MediaBox) + // Mirrors PDFBox's page.getCropBox() approach for coordinate adjustment. + const pageBox = readEffectivePageBox(m, pagePtr); + const cropWidth = pageBox.right - pageBox.left; + const cropHeight = pageBox.top - pageBox.bottom; + const annotCount = m.FPDFPage_GetAnnotCount(pagePtr); + + if (pageIdx === 0) { + console.debug( + '[extractFormFields] page 0 box: left=%.2f bottom=%.2f right=%.2f top=%.2f cropW=%.2f cropH=%.2f FPDF_H=%.2f', + pageBox.left, pageBox.bottom, pageBox.right, pageBox.top, + cropWidth, cropHeight, m.FPDF_GetPageHeightF(pagePtr), + ); + } + + for (let annotIdx = 0; annotIdx < annotCount; annotIdx++) { + try { + this_extractAnnotation(m, formEnvPtr, pagePtr, pageIdx, pageBox, cropWidth, cropHeight, annotIdx, fieldMap); + } catch (annotErr) { + console.warn('[extractFormFields] Annotation %d on page %d crashed:', annotIdx, pageIdx, annotErr); + } + } + + if (formEnvPtr) { + try { m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr); } catch { /* best-effort */ } + } + try { m.FPDF_ClosePage(pagePtr); } catch { /* best-effort */ } + } + + // Cleanup form environment + if (formEnvPtr) { + try { m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr); } catch { /* */ } + } + if (formInfoPtr) { + try { m.PDFiumExt_CloseFormFillInfo(formInfoPtr); } catch { /* */ } + } + + console.debug('[extractFormFields] Extracted %d fields', fieldMap.size); + return Array.from(fieldMap.values()); + } catch (err) { + if (err instanceof WebAssembly.RuntimeError) { + console.error('[extractFormFields] WASM RuntimeError — resetting module:', err); + resetPdfiumModule(); + } + throw err; + } finally { + try { closeDocAndFreeBuffer(m, docPtr); } catch { /* best-effort */ } + } +} + +/** + * Process a single annotation and merge into fieldMap. + * Extracted as a helper so that extractFormFields can catch per-annotation WASM traps. + * + * Coordinate conversion mirrors the Java PDFBox backend exactly: + * relativeX = annotLeft − cropBox.left + * relativeY = annotBottom − cropBox.bottom + * cssX = relativeX + * cssY = cropHeight − relativeY − annotHeight + */ +function this_extractAnnotation( + m: WrappedPdfiumModule, + formEnvPtr: number, + pagePtr: number, + pageIdx: number, + pageBox: PageBox, + _cropWidth: number, + cropHeight: number, + annotIdx: number, + fieldMap: Map, +): void { + const annotPtr = m.FPDFPage_GetAnnot(pagePtr, annotIdx); + if (!annotPtr) return; + + try { + const subtype = m.FPDFAnnot_GetSubtype(annotPtr); + // FPDF_ANNOT_WIDGET = 20 + if (subtype !== 20) return; + + // Get form field type + const fieldType = formEnvPtr + ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) + : 0; + + // Get field name (requires a valid form environment pointer) + const nameLen = formEnvPtr ? m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0) : 0; + let fieldName = ''; + if (nameLen > 0) { + const nameBuf = m.pdfium.wasmExports.malloc(nameLen); + m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen); + fieldName = readUtf16(m, nameBuf, nameLen); + m.pdfium.wasmExports.free(nameBuf); + } + + // Get field value (requires a valid form environment pointer) + const valLen = formEnvPtr ? m.FPDFAnnot_GetFormFieldValue(formEnvPtr, annotPtr, 0, 0) : 0; + let fieldValue = ''; + if (valLen > 0) { + const valBuf = m.pdfium.wasmExports.malloc(valLen); + m.FPDFAnnot_GetFormFieldValue(formEnvPtr, annotPtr, valBuf, valLen); + fieldValue = readUtf16(m, valBuf, valLen); + m.pdfium.wasmExports.free(valBuf); + } + + // Get field flags (requires a valid form environment pointer) + const fieldFlags = formEnvPtr ? m.FPDFAnnot_GetFormFieldFlags(formEnvPtr, annotPtr) : 0; + const isReadOnly = (fieldFlags & 1) !== 0; // FORMFLAG_READONLY = 1 + const isRequired = (fieldFlags & 2) !== 0; // FORMFLAG_REQUIRED = 2 + + // Is checked (for checkboxes/radios) + const isChecked = formEnvPtr ? m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr) : false; + + // Get rect using standard FPDFAnnot_GetRect for known struct layout, + // then apply explicit CropBox adjustment (mirrors the Java PDFBox backend). + // Standard FS_RECTF layout: { left(f32), bottom(f32), right(f32), top(f32) } + const rectBuf = m.pdfium.wasmExports.malloc(4 * 4); // FS_RECTF = 4 floats + let hasRect = false; + try { + hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf); + } catch { + m.pdfium.wasmExports.free(rectBuf); + throw new Error('FPDFAnnot_GetRect crashed'); + } + + let widgetRect: PdfiumWidgetRect | null = null; + if (hasRect) { + // Standard FS_RECTF: {left, bottom, right, top} — raw MediaBox coordinates + const rawLeft = m.pdfium.getValue(rectBuf, 'float'); + const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float'); + const rawRight = m.pdfium.getValue(rectBuf + 8, 'float'); + const rawTop = m.pdfium.getValue(rectBuf + 12, 'float'); + + const annotLeft = Math.min(rawLeft, rawRight); + const annotBottom = Math.min(rawBottom, rawTop); + const annotRight = Math.max(rawLeft, rawRight); + const annotTop = Math.max(rawBottom, rawTop); + const pdfW = annotRight - annotLeft; + const pdfH = annotTop - annotBottom; + + // Adjust relative to CropBox origin (identical to PDFBox approach) + const relativeX = annotLeft - pageBox.left; + const relativeY = annotBottom - pageBox.bottom; + + // Y-flip: CSS upper-left origin, using CropBox height + const cssX = relativeX; + const cssY = cropHeight - relativeY - pdfH; + + // Diagnostic log for first annotation on first page + if (pageIdx === 0 && annotIdx < 2) { + console.debug( + '[extractFormFields] annot[%d] raw rect: L=%.2f B=%.2f R=%.2f T=%.2f → css: x=%.2f y=%.2f w=%.2f h=%.2f (cropAdj: relX=%.2f relY=%.2f)', + annotIdx, annotLeft, annotBottom, annotRight, annotTop, + cssX, cssY, pdfW, pdfH, relativeX, relativeY, + ); + } + + widgetRect = { + pageIndex: pageIdx, + x: cssX, + y: cssY, + width: pdfW, + height: pdfH, + }; + + // Get font size from default appearance + try { + const daLen = m.FPDFAnnot_GetStringValue(annotPtr, 'DA', 0, 0); + if (daLen > 0) { + const daBuf = m.pdfium.wasmExports.malloc(daLen); + m.FPDFAnnot_GetStringValue(annotPtr, 'DA', daBuf, daLen); + const daStr = readUtf16(m, daBuf, daLen); + m.pdfium.wasmExports.free(daBuf); + const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/); + if (tfMatch) { + const fs = parseFloat(tfMatch[1]); + if (fs > 0) widgetRect.fontSize = fs; + } + } + } catch { + // DA extraction is non-critical + } + + // Get export value and checked state for checkbox/radio + if (fieldType === PDF_FORM_FIELD_TYPE.CHECKBOX || fieldType === PDF_FORM_FIELD_TYPE.RADIOBUTTON) { + try { + const expLen = m.FPDFAnnot_GetFormFieldExportValue(formEnvPtr, annotPtr, 0, 0); + if (expLen > 0) { + const expBuf = m.pdfium.wasmExports.malloc(expLen); + m.FPDFAnnot_GetFormFieldExportValue(formEnvPtr, annotPtr, expBuf, expLen); + widgetRect.exportValue = readUtf16(m, expBuf, expLen); + m.pdfium.wasmExports.free(expBuf); + } + } catch { + // Export value extraction is non-critical + } + widgetRect.isChecked = isChecked; + } + } + m.pdfium.wasmExports.free(rectBuf); + + // Get options (for combo/list/radio) + const options: Array<{ label: string; isSelected: boolean }> = []; + if ( + fieldType === PDF_FORM_FIELD_TYPE.COMBOBOX || + fieldType === PDF_FORM_FIELD_TYPE.LISTBOX || + fieldType === PDF_FORM_FIELD_TYPE.RADIOBUTTON + ) { + try { + const optCount = m.FPDFAnnot_GetOptionCount(formEnvPtr, annotPtr); + for (let oi = 0; oi < optCount; oi++) { + const optLabelLen = m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, 0, 0); + let optLabel = ''; + if (optLabelLen > 0) { + const optBuf = m.pdfium.wasmExports.malloc(optLabelLen); + m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, optBuf, optLabelLen); + optLabel = readUtf16(m, optBuf, optLabelLen); + m.pdfium.wasmExports.free(optBuf); + } + const isSel = m.FPDFAnnot_IsOptionSelected(formEnvPtr, annotPtr, oi); + options.push({ label: optLabel, isSelected: isSel }); + } + } catch { + // Options extraction non-critical + } + } + + // Merge into field map (multiple widgets can share a field name) + if (fieldName) { + const existing = fieldMap.get(fieldName); + if (existing) { + if (widgetRect) existing.widgets.push(widgetRect); + } else { + fieldMap.set(fieldName, { + name: fieldName, + type: fieldType as PDF_FORM_FIELD_TYPE, + value: fieldValue, + isChecked, + isReadOnly, + isRequired, + flags: fieldFlags, + options, + widgets: widgetRect ? [widgetRect] : [], + }); + } + } + } finally { + m.FPDFPage_CloseAnnot(annotPtr); + } +} + +export interface PdfiumSignature { + /** Raw DER-encoded PKCS#7 contents */ + contents: Uint8Array; + /** ByteRange array */ + byteRange: number[]; + /** SubFilter string (e.g. "adbe.pkcs7.detached") */ + subFilter: string; + /** Reason string */ + reason: string; + /** Time string (PDF date format: D:YYYYMMDDHHmmSS...) */ + time: string; + /** DocMDP permission level */ + docMDP: number; +} + +/** + * Extract digital signature objects from a PDF. + */ +export async function extractSignatures( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + + try { + const sigCount = m.FPDF_GetSignatureCount(docPtr); + const results: PdfiumSignature[] = []; + + for (let i = 0; i < sigCount; i++) { + const sigPtr = m.FPDF_GetSignatureObject(docPtr, i); + if (!sigPtr) continue; + + // Contents + const contentsLen = m.FPDFSignatureObj_GetContents(sigPtr, 0, 0); + let contents = new Uint8Array(0); + if (contentsLen > 0) { + const buf = m.pdfium.wasmExports.malloc(contentsLen); + m.FPDFSignatureObj_GetContents(sigPtr, buf, contentsLen); + contents = new Uint8Array(contentsLen); + for (let j = 0; j < contentsLen; j++) { + contents[j] = m.pdfium.getValue(buf + j, 'i8') & 0xff; + } + m.pdfium.wasmExports.free(buf); + } + + // ByteRange + const brLen = m.FPDFSignatureObj_GetByteRange(sigPtr, 0, 0); + const byteRange: number[] = []; + if (brLen > 0) { + const brBuf = m.pdfium.wasmExports.malloc(brLen * 4); + m.FPDFSignatureObj_GetByteRange(sigPtr, brBuf, brLen); + for (let j = 0; j < brLen; j++) { + byteRange.push(m.pdfium.getValue(brBuf + j * 4, 'i32')); + } + m.pdfium.wasmExports.free(brBuf); + } + + // SubFilter + const sfLen = m.FPDFSignatureObj_GetSubFilter(sigPtr, 0, 0); + let subFilter = ''; + if (sfLen > 0) { + const sfBuf = m.pdfium.wasmExports.malloc(sfLen); + m.FPDFSignatureObj_GetSubFilter(sigPtr, sfBuf, sfLen); + subFilter = m.pdfium.UTF8ToString(sfBuf); + m.pdfium.wasmExports.free(sfBuf); + } + + // Reason + const reasonLen = m.FPDFSignatureObj_GetReason(sigPtr, 0, 0); + let reason = ''; + if (reasonLen > 0) { + const reasonBuf = m.pdfium.wasmExports.malloc(reasonLen); + m.FPDFSignatureObj_GetReason(sigPtr, reasonBuf, reasonLen); + reason = readUtf16(m, reasonBuf, reasonLen); + m.pdfium.wasmExports.free(reasonBuf); + } + + // Time + const timeLen = m.FPDFSignatureObj_GetTime(sigPtr, 0, 0); + let time = ''; + if (timeLen > 0) { + const timeBuf = m.pdfium.wasmExports.malloc(timeLen); + m.FPDFSignatureObj_GetTime(sigPtr, timeBuf, timeLen); + time = m.pdfium.UTF8ToString(timeBuf); + m.pdfium.wasmExports.free(timeBuf); + } + + // DocMDP + const docMDP = m.FPDFSignatureObj_GetDocMDPPermission(sigPtr); + + results.push({ contents, byteRange, subFilter, reason, time, docMDP }); + } + + return results; + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + + +/** + * Render a single page to an ImageData-like bitmap. + */ +export async function renderPageToBitmap( + data: ArrayBuffer | Uint8Array, + pageIndex: number, + scale: number = 1, + password?: string, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + + try { + const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); + if (!pagePtr) throw new Error(`PDFium: failed to load page ${pageIndex}`); + + const rawW = m.FPDF_GetPageWidthF(pagePtr); + const rawH = m.FPDF_GetPageHeightF(pagePtr); + const w = Math.round(rawW * scale); + const h = Math.round(rawH * scale); + + // Create bitmap (BGRA format = 4) + const bitmapPtr = m.FPDFBitmap_Create(w, h, 1); + // Fill with white + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff); + + // Render + m.FPDF_RenderPageBitmap(bitmapPtr, pagePtr, 0, 0, w, h, 0, 0x01 | 0x10); + + // Read pixel data + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + const pixelData = new Uint8ClampedArray(w * h * 4); + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const srcOff = y * stride + x * 4; + const dstOff = (y * w + x) * 4; + // BGRA → RGBA + pixelData[dstOff] = m.pdfium.getValue(bufferPtr + srcOff + 2, 'i8') & 0xff; + pixelData[dstOff + 1] = m.pdfium.getValue(bufferPtr + srcOff + 1, 'i8') & 0xff; + pixelData[dstOff + 2] = m.pdfium.getValue(bufferPtr + srcOff, 'i8') & 0xff; + pixelData[dstOff + 3] = m.pdfium.getValue(bufferPtr + srcOff + 3, 'i8') & 0xff; + } + } + + m.FPDFBitmap_Destroy(bitmapPtr); + m.FPDF_ClosePage(pagePtr); + + return new ImageData(pixelData, w, h); + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +export interface PdfiumLink { + id: string; + annotIndex: number; + rect: { x: number; y: number; width: number; height: number }; + type: 'internal' | 'external' | 'unknown'; + targetPage?: number; + uri?: string; +} + +/** + * Extract all link annotations from a specific page. + */ +export async function extractLinksFromPage( + data: ArrayBuffer | Uint8Array, + pageIndex: number, + password?: string, +): Promise<{ links: PdfiumLink[]; pageWidth: number; pageHeight: number }> { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + + try { + const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); + if (!pagePtr) return { links: [], pageWidth: 0, pageHeight: 0 }; + + const pageWidth = m.FPDF_GetPageWidthF(pagePtr); + const pageHeight = m.FPDF_GetPageHeightF(pagePtr); + + const links: PdfiumLink[] = []; + const annotCount = m.FPDFPage_GetAnnotCount(pagePtr); + + for (let i = 0; i < annotCount; i++) { + const annotPtr = m.FPDFPage_GetAnnot(pagePtr, i); + if (!annotPtr) continue; + + const subtype = m.FPDFAnnot_GetSubtype(annotPtr); + // FPDF_ANNOT_LINK = 4 + if (subtype !== 4) { + m.FPDFPage_CloseAnnot(annotPtr); + 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 rect = parseRectToCss(m, rectBuf, pageHeight); + m.pdfium.wasmExports.free(rectBuf); + + // Try to get link object + const linkPtr = m.FPDFAnnot_GetLink(annotPtr); + let linkType: 'internal' | 'external' | 'unknown' = 'unknown'; + let targetPage: number | undefined; + let uri: string | undefined; + + if (linkPtr) { + // Check for URI action + const actionPtr = m.FPDFLink_GetAction(linkPtr); + if (actionPtr) { + const actionType = m.FPDFAction_GetType(actionPtr); + if (actionType === 3) { + // PDFACTION_URI = 3 + 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 === 1) { + // PDFACTION_GOTO = 1 + const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr); + if (destPtr) { + targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr); + linkType = 'internal'; + } + } + } + + // Check for direct destination (no action) + if (linkType === 'unknown') { + const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr); + if (destPtr) { + targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr); + linkType = 'internal'; + } + } + } + + links.push({ + id: `link-${pageIndex}-${i}`, + annotIndex: i, + rect, + type: linkType, + targetPage, + uri, + }); + + m.FPDFPage_CloseAnnot(annotPtr); + } + + m.FPDF_ClosePage(pagePtr); + return { links, pageWidth, pageHeight }; + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + + +/** + * Create a new empty PDF document, returning its raw data. + */ +export async function createEmptyDocument(): Promise { + const m = await getPdfiumModule(); + const docPtr = m.FPDF_CreateNewDocument(); + if (!docPtr) throw new Error('PDFium: failed to create new document'); + const writerPtr = m.PDFiumExt_OpenFileWriter(); + m.PDFiumExt_SaveAsCopy(docPtr, writerPtr); + const size = m.PDFiumExt_GetFileWriterSize(writerPtr); + const outBuf = m.pdfium.wasmExports.malloc(size); + m.PDFiumExt_GetFileWriterData(writerPtr, outBuf, size); + const result = new ArrayBuffer(size); + const view = new Uint8Array(result); + for (let i = 0; i < size; i++) { + view[i] = m.pdfium.getValue(outBuf + i, 'i8') & 0xff; + } + m.pdfium.wasmExports.free(outBuf); + m.PDFiumExt_CloseFileWriter(writerPtr); + closeDocAndFreeBuffer(m, docPtr); + return result; +} + +/** + * Save a raw document pointer to an ArrayBuffer. + */ +export async function saveRawDocument(docPtr: number): Promise { + const m = await getPdfiumModule(); + const writerPtr = m.PDFiumExt_OpenFileWriter(); + m.PDFiumExt_SaveAsCopy(docPtr, writerPtr); + const size = m.PDFiumExt_GetFileWriterSize(writerPtr); + const outBuf = m.pdfium.wasmExports.malloc(size); + m.PDFiumExt_GetFileWriterData(writerPtr, outBuf, size); + const result = new ArrayBuffer(size); + const view = new Uint8Array(result); + for (let i = 0; i < size; i++) { + view[i] = m.pdfium.getValue(outBuf + i, 'i8') & 0xff; + } + m.pdfium.wasmExports.free(outBuf); + m.PDFiumExt_CloseFileWriter(writerPtr); + return result; +} + +/** + * Import pages from one PDF document into another. + * Both source and dest must already be opened with `openRawDocumentSafe()`. + */ +export async function importPages( + destDocPtr: number, + srcDocPtr: number, + pageRange?: string, + insertIndex?: number, +): Promise { + const m = await getPdfiumModule(); + return m.FPDF_ImportPages(destDocPtr, srcDocPtr, pageRange ?? '', insertIndex ?? 0); +} + +/** + * Set page rotation on a raw document. + * @param rotation 0, 1, 2, 3 for 0°, 90°, 180°, 270° + */ +export async function setPageRotation( + docPtr: number, + pageIndex: number, + rotation: number, +): Promise { + const m = await getPdfiumModule(); + const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); + if (!pagePtr) throw new Error(`PDFium: failed to load page ${pageIndex}`); + m.FPDFPage_SetRotation(pagePtr, rotation); + m.FPDF_ClosePage(pagePtr); +} + +/** + * Create a new page in a document. + */ +export async function addNewPage( + docPtr: number, + insertIndex: number, + width: number, + height: number, +): Promise { + const m = await getPdfiumModule(); + const pagePtr = m.FPDFPage_New(docPtr, insertIndex, width, height); + if (pagePtr) m.FPDF_ClosePage(pagePtr); +} + +/** + * Get metadata from a document. + */ +export async function getMetadata( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise> { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + try { + const tags = ['Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer']; + const meta: Record = {}; + for (const tag of tags) { + const len = m.FPDF_GetMetaText(docPtr, tag, 0, 0); + if (len > 0) { + const buf = m.pdfium.wasmExports.malloc(len); + m.FPDF_GetMetaText(docPtr, tag, buf, len); + meta[tag] = readUtf16(m, buf, len); + m.pdfium.wasmExports.free(buf); + } + } + return meta; + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +export interface PdfiumSignatureFieldRect { + pageIndex: number; + x: number; + y: number; + width: number; + height: number; + fieldName: string; +} + +/** + * Extract the visual rectangles of signature form fields. + * These are the Widget annotations whose form field type is SIGNATURE. + */ +export async function extractSignatureFieldRects( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + try { + const formInfoPtr = m.PDFiumExt_OpenFormFillInfo(); + const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr); + const pageCount = m.FPDF_GetPageCount(docPtr); + const results: PdfiumSignatureFieldRect[] = []; + + for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) { + const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx); + if (!pagePtr) continue; + if (formEnvPtr) m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr); + + // Use CropBox-adjusted coordinates (same approach as extractFormFields) + const pageBox = readEffectivePageBox(m, pagePtr); + const cropHeight = pageBox.top - pageBox.bottom; + const annotCount = m.FPDFPage_GetAnnotCount(pagePtr); + + for (let annotIdx = 0; annotIdx < annotCount; annotIdx++) { + const annotPtr = m.FPDFPage_GetAnnot(pagePtr, annotIdx); + if (!annotPtr) continue; + + const subtype = m.FPDFAnnot_GetSubtype(annotPtr); + if (subtype !== 20) { // WIDGET + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + + const fieldType = formEnvPtr + ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) + : 0; + + if (fieldType !== PDF_FORM_FIELD_TYPE.SIGNATURE) { + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + + // Get field name + const nameLen = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0); + let name = ''; + if (nameLen > 0) { + const nameBuf = m.pdfium.wasmExports.malloc(nameLen); + m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen); + name = readUtf16(m, nameBuf, nameLen); + m.pdfium.wasmExports.free(nameBuf); + } + + // Get rect — use standard FPDFAnnot_GetRect + CropBox adjustment + const rectBuf = m.pdfium.wasmExports.malloc(4 * 4); + const hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf); + if (hasRect) { + const rawLeft = m.pdfium.getValue(rectBuf, 'float'); + const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float'); + const rawRight = m.pdfium.getValue(rectBuf + 8, 'float'); + const rawTop = m.pdfium.getValue(rectBuf + 12, 'float'); + + const aLeft = Math.min(rawLeft, rawRight); + const aBottom = Math.min(rawBottom, rawTop); + const aRight = Math.max(rawLeft, rawRight); + const aTop = Math.max(rawBottom, rawTop); + const pdfW = aRight - aLeft; + const pdfH = aTop - aBottom; + + const relX = aLeft - pageBox.left; + const relY = aBottom - pageBox.bottom; + + results.push({ + pageIndex: pageIdx, + x: relX, + y: cropHeight - relY - pdfH, + width: pdfW, + height: pdfH, + fieldName: name, + }); + } + m.pdfium.wasmExports.free(rectBuf); + m.FPDFPage_CloseAnnot(annotPtr); + } + + if (formEnvPtr) m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr); + m.FPDF_ClosePage(pagePtr); + } + + if (formEnvPtr) m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr); + if (formInfoPtr) m.PDFiumExt_CloseFormFillInfo(formInfoPtr); + + return results; + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +/* ------------------------------------------------------------------ */ +/* Signature appearance bitmap rendering */ +/* ------------------------------------------------------------------ */ + +/** Result from rendering a single signature widget's visual appearance. */ +export interface SignatureFieldAppearance { + pageIndex: number; + /** CSS x position (PDF points, CropBox-adjusted). */ + x: number; + /** CSS y position (PDF points, CropBox-adjusted, top-left origin). */ + y: number; + /** Width in PDF points. */ + width: number; + /** Height in PDF points. */ + height: number; + fieldName: string; + /** + * RGBA pixel data of the rendered appearance stream, or `null` when the + * annotation has no visible appearance (e.g. unsigned fields). + */ + imageData: ImageData | null; + /** Source PDF page width (points) used for coordinate computation. */ + sourcePageWidth: number; + /** Source PDF page height (points) used for coordinate computation. */ + sourcePageHeight: number; +} + +/** FPDF_REVERSE_BYTE_ORDER — causes PDFium to output RGBA instead of BGRA. */ +const RENDER_FLAG_REVERSE_BYTE_ORDER = 0x10; +/** AppearanceMode.Normal = 0 in @embedpdf/models. */ +const AP_MODE_NORMAL = 0; + +/** Shared appearance-rendering helper. Renders a widget annotation to ImageData. */ +async function renderWidgetAppearance( + m: WrappedPdfiumModule, + bitmapPtr: number, + heapPtr: number, + wDev: number, + hDev: number, + stride: number, + bytes: number, + pagePtr: number, + annotPtr: number, + annotLeft: number, + annotTop: number, + pdfW: number, + pdfH: number, + cssX: number, + cssY: number, + cropWidth: number, + cropHeight: number, + formEnvPtr: number, + dpr: number, +): Promise { + const pdfiumWasm = m.pdfium as any; + const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); + const matrixView = new Float32Array(pdfiumWasm.HEAPF32.buffer, matrixPtr, 6); + const sx = wDev / pdfW; + const sy = hDev / pdfH; + matrixView.set([sx, 0, 0, -sy, -sx * annotLeft, sy * annotTop]); + + let ok = false; + try { + ok = !!m.EPDF_RenderAnnotBitmap( + bitmapPtr, pagePtr, annotPtr, + AP_MODE_NORMAL, matrixPtr, + RENDER_FLAG_REVERSE_BYTE_ORDER, + ); + } catch { /* Extension not available */ } + m.pdfium.wasmExports.free(matrixPtr); + m.FPDFBitmap_Destroy(bitmapPtr); + + let imageData: ImageData | null = null; + if (ok) { + const rgba = new Uint8ClampedArray(pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes)); + let hasVisible = false; + for (let i = 3; i < rgba.length; i += 4) { + if (rgba[i] > 0) { hasVisible = true; break; } + } + if (hasVisible) imageData = new ImageData(rgba, wDev, hDev); + } + m.pdfium.wasmExports.free(heapPtr); + + // Fallback: render form fill layer into a page-sized bitmap cropped to the annotation. + if (!imageData && formEnvPtr) { + const heap2 = m.pdfium.wasmExports.malloc(bytes); + const bmp2 = m.FPDFBitmap_CreateEx(wDev, hDev, 4, heap2, stride); + m.FPDFBitmap_FillRect(bmp2, 0, 0, wDev, hDev, 0x00000000); + + const fullW = Math.round(cropWidth * dpr); + const fullH = Math.round(cropHeight * dpr); + const startX = Math.round(-cssX * dpr); + const startY = Math.round(-cssY * dpr); + + try { + m.FPDF_RenderPageBitmap(bmp2, pagePtr, startX, startY, fullW, fullH, 0, 0x01 | 0x10); + m.FPDF_FFLDraw(formEnvPtr, bmp2, pagePtr, startX, startY, fullW, fullH, 0, 0x01 | 0x10); + } catch { /* fallback not available */ } + + m.FPDFBitmap_Destroy(bmp2); + + const rgba2 = new Uint8ClampedArray(pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes)); + let hasVisible2 = false; + for (let i = 3; i < rgba2.length; i += 4) { + if (rgba2[i] > 0) { hasVisible2 = true; break; } + } + if (hasVisible2) imageData = new ImageData(rgba2, wDev, hDev); + m.pdfium.wasmExports.free(heap2); + } + + return imageData; +} + +/** + * Extract signature widget positions AND render their appearance bitmaps. + * + * For each Widget annotation whose form-field type is SIGNATURE we: + * 1. Compute CropBox-adjusted CSS coordinates (same approach as extractFormFields + * for coordinate consistency with the overlay system). + * 2. Attempt to render the annotation's appearance stream via + * `EPDF_RenderAnnotBitmap` (an @embedpdf PDFium extension). + * 3. If the extension is unavailable or produces no visible pixels, fall back + * to `FPDF_FFLDraw` which renders form elements (including signatures) + * into a small bitmap covering just the annotation rect. + * 4. Return the RGBA `ImageData` so the caller can paint it into a + * `` element positioned at the correct overlay location. + * + * When the annotation has no appearance stream (unsigned fields, or fields + * whose PDF writer didn't embed one) `imageData` will be `null`. + */ +export async function renderSignatureFieldAppearances( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + + try { + const formInfoPtr = m.PDFiumExt_OpenFormFillInfo(); + const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr); + const pageCount = m.FPDF_GetPageCount(docPtr); + const results: SignatureFieldAppearance[] = []; + + for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) { + const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx); + if (!pagePtr) continue; + if (formEnvPtr) m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr); + + // Use CropBox dimensions (same as extractFormFields) for coordinate + // computation. This matches EmbedPDF's pdfPage.size and the overlay + // coordinate system. + const pageBox = readEffectivePageBox(m, pagePtr); + const cropWidth = pageBox.right - pageBox.left; + const cropHeight = pageBox.top - pageBox.bottom; + const annotCount = m.FPDFPage_GetAnnotCount(pagePtr); + + for (let annotIdx = 0; annotIdx < annotCount; annotIdx++) { + const annotPtr = m.FPDFPage_GetAnnot(pagePtr, annotIdx); + if (!annotPtr) continue; + + const subtype = m.FPDFAnnot_GetSubtype(annotPtr); + if (subtype !== 20) { // FPDF_ANNOT_WIDGET + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + + const fieldType = formEnvPtr + ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) + : 0; + + if (fieldType !== PDF_FORM_FIELD_TYPE.SIGNATURE) { + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + + // --- field name --- + const nameLen = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0); + let name = ''; + if (nameLen > 0) { + const nameBuf = m.pdfium.wasmExports.malloc(nameLen); + m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen); + name = readUtf16(m, nameBuf, nameLen); + m.pdfium.wasmExports.free(nameBuf); + } + // Use standard FPDFAnnot_GetRect + explicit CropBox adjustment, + // identical to extractFormFields (this_extractAnnotation). This + // ensures coordinates are in the same space as FormFieldOverlay. + const rectBuf = m.pdfium.wasmExports.malloc(4 * 4); + let hasRect = false; + try { + hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf); + } catch { + m.pdfium.wasmExports.free(rectBuf); + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + if (!hasRect) { + m.pdfium.wasmExports.free(rectBuf); + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + + // Standard FS_RECTF layout: {left, bottom, right, top} + const rawLeft = m.pdfium.getValue(rectBuf, 'float'); + const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float'); + const rawRight = m.pdfium.getValue(rectBuf + 8, 'float'); + const rawTop = m.pdfium.getValue(rectBuf + 12, 'float'); + m.pdfium.wasmExports.free(rectBuf); + + // Normalise + const annotLeft = Math.min(rawLeft, rawRight); + const annotBottom = Math.min(rawBottom, rawTop); + const annotRight = Math.max(rawLeft, rawRight); + const annotTop = Math.max(rawBottom, rawTop); + const pdfW = annotRight - annotLeft; + const pdfH = annotTop - annotBottom; + + // CropBox adjustment (mirrors extractFormFields exactly) + const relativeX = annotLeft - pageBox.left; + const relativeY = annotBottom - pageBox.bottom; + const cssX = relativeX; + const cssY = cropHeight - relativeY - pdfH; + + let imageData: ImageData | null = null; + + if (pdfW > 0.5 && pdfH > 0.5) { + const dpr = typeof window !== 'undefined' + ? Math.min(window.devicePixelRatio || 1, 3) + : 1; + const wDev = Math.max(1, Math.round(pdfW * dpr)); + const hDev = Math.max(1, Math.round(pdfH * dpr)); + const stride = wDev * 4; + const bytes = stride * hDev; + const pdfiumWasm = m.pdfium as any; + + const heapPtr = m.pdfium.wasmExports.malloc(bytes); + const bitmapPtr = m.FPDFBitmap_CreateEx(wDev, hDev, 4 /* BGRA */, heapPtr, stride); + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, wDev, hDev, 0x00000000); + + // Build user→device matrix: maps annotation rect to bitmap. + // PDF coords: origin bottom-left, y-up. Device: origin top-left, y-down. + const sx = wDev / pdfW; + const sy = hDev / pdfH; + const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); + const matrixView = new Float32Array( + pdfiumWasm.HEAPF32.buffer, matrixPtr, 6, + ); + matrixView.set([sx, 0, 0, -sy, -sx * annotLeft, sy * annotTop]); + + let ok = false; + try { + ok = !!m.EPDF_RenderAnnotBitmap( + bitmapPtr, pagePtr, annotPtr, + AP_MODE_NORMAL, matrixPtr, + RENDER_FLAG_REVERSE_BYTE_ORDER, + ); + } catch { + // Extension not available — fall through to FPDF_FFLDraw. + } + + m.pdfium.wasmExports.free(matrixPtr); + m.FPDFBitmap_Destroy(bitmapPtr); + + if (ok) { + const rgba = new Uint8ClampedArray( + pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes), + ); + let hasVisible = false; + for (let i = 3; i < rgba.length; i += 4) { + if (rgba[i] > 0) { hasVisible = true; break; } + } + if (hasVisible) { + imageData = new ImageData(rgba, wDev, hDev); + } + } + m.pdfium.wasmExports.free(heapPtr); + + // Renders the form fill layer (which includes signature appearances) + // into a small bitmap covering just the annotation rect. + if (!imageData && formEnvPtr) { + const heap2 = m.pdfium.wasmExports.malloc(bytes); + const bmp2 = m.FPDFBitmap_CreateEx(wDev, hDev, 4, heap2, stride); + m.FPDFBitmap_FillRect(bmp2, 0, 0, wDev, hDev, 0x00000000); + + // Map the full page into device coords, then offset so the + // annotation rect starts at bitmap (0, 0). + const fullW = Math.round(cropWidth * dpr); + const fullH = Math.round(cropHeight * dpr); + const startX = Math.round(-cssX * dpr); + const startY = Math.round(-cssY * dpr); + + try { + // Draw page content first (provides background under signature) + m.FPDF_RenderPageBitmap( + bmp2, pagePtr, startX, startY, fullW, fullH, 0, + 0x01 | 0x10, // FPDF_ANNOT | FPDF_REVERSE_BYTE_ORDER + ); + // Draw form fill layer on top (includes signature appearances) + m.FPDF_FFLDraw( + formEnvPtr, bmp2, pagePtr, + startX, startY, fullW, fullH, + 0, 0x01 | 0x10, + ); + } catch { + // FPDF_FFLDraw not available or failed. + } + + m.FPDFBitmap_Destroy(bmp2); + + const rgba2 = new Uint8ClampedArray( + pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes), + ); + let hasVisible2 = false; + for (let i = 3; i < rgba2.length; i += 4) { + if (rgba2[i] > 0) { hasVisible2 = true; break; } + } + if (hasVisible2) { + imageData = new ImageData(rgba2, wDev, hDev); + } + m.pdfium.wasmExports.free(heap2); + } + } + + m.FPDFPage_CloseAnnot(annotPtr); + + results.push({ + pageIndex: pageIdx, + x: cssX, + y: cssY, + width: pdfW, + height: pdfH, + fieldName: name, + imageData, + sourcePageWidth: cropWidth, + sourcePageHeight: cropHeight, + }); + } + + if (formEnvPtr) m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr); + m.FPDF_ClosePage(pagePtr); + } + + if (formEnvPtr) m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr); + if (formInfoPtr) m.PDFiumExt_CloseFormFillInfo(formInfoPtr); + + return results; + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +/** + * Render push-button widget appearances to bitmaps. + * + * Same rendering pipeline as renderSignatureFieldAppearances but filtered + * to PUSHBUTTON field type. The resulting ImageData can be painted into + * elements to give buttons their PDF-native visual appearance. + */ +export async function renderButtonFieldAppearances( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(data, password); + + try { + const formInfoPtr = m.PDFiumExt_OpenFormFillInfo(); + const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr); + const pageCount = m.FPDF_GetPageCount(docPtr); + const buttonResults: SignatureFieldAppearance[] = []; + + for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) { + const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx); + if (!pagePtr) continue; + if (formEnvPtr) m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr); + + const pageBox = readEffectivePageBox(m, pagePtr); + const cropWidth = pageBox.right - pageBox.left; + const cropHeight = pageBox.top - pageBox.bottom; + const annotCount = m.FPDFPage_GetAnnotCount(pagePtr); + + for (let annotIdx = 0; annotIdx < annotCount; annotIdx++) { + const annotPtr = m.FPDFPage_GetAnnot(pagePtr, annotIdx); + if (!annotPtr) continue; + + const subtype = m.FPDFAnnot_GetSubtype(annotPtr); + if (subtype !== 20) { m.FPDFPage_CloseAnnot(annotPtr); continue; } + + const fieldType = formEnvPtr ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) : 0; + if (fieldType !== PDF_FORM_FIELD_TYPE.PUSHBUTTON) { + m.FPDFPage_CloseAnnot(annotPtr); + continue; + } + + const nameLen = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0); + let btnName = ''; + if (nameLen > 0) { + const nameBuf = m.pdfium.wasmExports.malloc(nameLen); + m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen); + btnName = readUtf16(m, nameBuf, nameLen); + m.pdfium.wasmExports.free(nameBuf); + } + + const rectBuf = m.pdfium.wasmExports.malloc(4 * 4); + let hasRect = false; + try { hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf); } + catch { m.pdfium.wasmExports.free(rectBuf); m.FPDFPage_CloseAnnot(annotPtr); continue; } + if (!hasRect) { + m.pdfium.wasmExports.free(rectBuf); m.FPDFPage_CloseAnnot(annotPtr); continue; + } + + const rawLeft = m.pdfium.getValue(rectBuf, 'float'); + const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float'); + const rawRight = m.pdfium.getValue(rectBuf + 8, 'float'); + const rawTop = m.pdfium.getValue(rectBuf + 12, 'float'); + m.pdfium.wasmExports.free(rectBuf); + + const annotLeft = Math.min(rawLeft, rawRight); + const annotBottom = Math.min(rawBottom, rawTop); + const annotRight = Math.max(rawLeft, rawRight); + const annotTopVal = Math.max(rawBottom, rawTop); + const pdfW = annotRight - annotLeft; + const pdfH = annotTopVal - annotBottom; + + const relativeX = annotLeft - pageBox.left; + const relativeY = annotBottom - pageBox.bottom; + const cssX = relativeX; + const cssY = cropHeight - relativeY - pdfH; + + let imageData: ImageData | null = null; + + if (pdfW > 0.5 && pdfH > 0.5) { + const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 3) : 1; + const wDev = Math.max(1, Math.round(pdfW * dpr)); + const hDev = Math.max(1, Math.round(pdfH * dpr)); + const stride = wDev * 4; + const bytes = stride * hDev; + const heapPtr = m.pdfium.wasmExports.malloc(bytes); + const bitmapPtr = m.FPDFBitmap_CreateEx(wDev, hDev, 4, heapPtr, stride); + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, wDev, hDev, 0x00000000); + + imageData = await renderWidgetAppearance( + m, bitmapPtr, heapPtr, wDev, hDev, stride, bytes, + pagePtr, annotPtr, annotLeft, annotTopVal, pdfW, pdfH, + cssX, cssY, cropWidth, cropHeight, formEnvPtr, dpr, + ); + } + + m.FPDFPage_CloseAnnot(annotPtr); + buttonResults.push({ + pageIndex: pageIdx, x: cssX, y: cssY, + width: pdfW, height: pdfH, + fieldName: btnName, imageData, + sourcePageWidth: cropWidth, sourcePageHeight: cropHeight, + }); + } + + if (formEnvPtr) m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr); + m.FPDF_ClosePage(pagePtr); + } + + if (formEnvPtr) m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr); + if (formInfoPtr) m.PDFiumExt_CloseFormFillInfo(formInfoPtr); + + return buttonResults; + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +/** + * Fetch signature fields with their rendered appearances. + * + * This combines extractSignatureFieldRects and renderSignatureFieldAppearances + * to return FormField objects suitable for use in pdfbox mode where signature + * fields are not returned by the backend. + * + * @param data - PDF file data + * @param password - Optional PDF password + * @returns Array of FormField objects for signature fields with appearanceDataUrl populated + */ +export async function fetchSignatureFieldsWithAppearances( + data: ArrayBuffer | Uint8Array, + password?: string, +): Promise { + const appearances = await renderSignatureFieldAppearances(data, password); + const formFields: FormField[] = []; + + for (const appearance of appearances) { + const widget: WidgetCoordinates = { + pageIndex: appearance.pageIndex, + x: appearance.x, + y: appearance.y, + width: appearance.width, + height: appearance.height, + }; + + // Convert ImageData to data URL for the appearance + let appearanceDataUrl: string | undefined; + if (appearance.imageData) { + const canvas = document.createElement('canvas'); + canvas.width = appearance.imageData.width; + canvas.height = appearance.imageData.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(appearance.imageData, 0, 0); + appearanceDataUrl = canvas.toDataURL('image/png'); + } + } + + formFields.push({ + name: appearance.fieldName, + label: appearance.fieldName, + type: 'signature', + value: '', + options: null, + displayOptions: null, + required: false, + readOnly: true, + multiSelect: false, + multiline: false, + tooltip: null, + widgets: [widget], + appearanceDataUrl, + }); + } + + return formFields; +} diff --git a/frontend/src/core/styles/index.css b/frontend/src/core/styles/index.css index c5a6049004..21298512e2 100644 --- a/frontend/src/core/styles/index.css +++ b/frontend/src/core/styles/index.css @@ -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; diff --git a/frontend/src/core/tools/formFill/ButtonAppearanceOverlay.tsx b/frontend/src/core/tools/formFill/ButtonAppearanceOverlay.tsx new file mode 100644 index 0000000000..5d6c00e455 --- /dev/null +++ b/frontend/src/core/tools/formFill/ButtonAppearanceOverlay.tsx @@ -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 | null = null; + +async function resolveButtonAppearances(source: File | Blob): Promise { + 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(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 ( + + ); +} +function ButtonAppearanceOverlayInner({ + pageIndex, + pdfSource, + pageWidth, + pageHeight, +}: ButtonAppearanceOverlayProps) { + const [appearances, setAppearances] = useState([]); + + 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 ( +
+ {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 ( +
+ +
+ ); + })} +
+ ); +} + +export const ButtonAppearanceOverlay = memo(ButtonAppearanceOverlayInner); diff --git a/frontend/src/core/tools/formFill/FieldInput.tsx b/frontend/src/core/tools/formFill/FieldInput.tsx index d310a63bc3..979822468d 100644 --- a/frontend/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/src/core/tools/formFill/FieldInput.tsx @@ -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 ( 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)?.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 (
{ - 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 ( - {field.label - ); + // 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 (
{ + 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} /> ); }) diff --git a/frontend/src/core/tools/formFill/FormFillContext.tsx b/frontend/src/core/tools/formFill/FormFillContext.tsx index dc2ce924c3..6b03529274 100644 --- a/frontend/src/core/tools/formFill/FormFillContext.tsx +++ b/frontend/src/core/tools/formFill/FormFillContext.tsx @@ -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 { } /** 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; diff --git a/frontend/src/core/tools/formFill/index.ts b/frontend/src/core/tools/formFill/index.ts index 31b5b4c90c..1b71f393c0 100644 --- a/frontend/src/core/tools/formFill/index.ts +++ b/frontend/src/core/tools/formFill/index.ts @@ -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'; diff --git a/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts b/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts deleted file mode 100644 index 05876cdb1c..0000000000 --- a/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts +++ /dev/null @@ -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 { - 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 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 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 { - 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 { - 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(); - 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 { - 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, - flatten: boolean, - ): Promise { - 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' }); - } -} diff --git a/frontend/src/core/tools/formFill/providers/PdfiumFormProvider.ts b/frontend/src/core/tools/formFill/providers/PdfiumFormProvider.ts new file mode 100644 index 0000000000..a951d25347 --- /dev/null +++ b/frontend/src/core/tools/formFill/providers/PdfiumFormProvider.ts @@ -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 { + 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 { + 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(); + + 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> { + const result = new Map(); + 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> { + const result = new Map(); + 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, + flatten: boolean, + ): Promise { + 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(); + + 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); + } + } +} diff --git a/frontend/src/core/tools/formFill/providers/index.ts b/frontend/src/core/tools/formFill/providers/index.ts index ec0a3663e7..b71780b106 100644 --- a/frontend/src/core/tools/formFill/providers/index.ts +++ b/frontend/src/core/tools/formFill/providers/index.ts @@ -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'; diff --git a/frontend/src/core/tools/formFill/types.ts b/frontend/src/core/tools/formFill/types.ts index a19d9cb9ac..693c7ec2e4 100644 --- a/frontend/src/core/tools/formFill/types.ts +++ b/frontend/src/core/tools/formFill/types.ts @@ -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[]; diff --git a/frontend/src/core/utils/imageToPdfUtils.ts b/frontend/src/core/utils/imageToPdfUtils.ts index 1139553c7a..b52106e2b6 100644 --- a/frontend/src/core/utils/imageToPdfUtils.ts +++ b/frontend/src/core/utils/imageToPdfUtils.ts @@ -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 { - 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 { /** * 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); diff --git a/frontend/src/core/utils/pdfLinkUtils.ts b/frontend/src/core/utils/pdfLinkUtils.ts index 61ba0ef58f..e53070e72b 100644 --- a/frontend/src/core/utils/pdfLinkUtils.ts +++ b/frontend/src/core/utils/pdfLinkUtils.ts @@ -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 { 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 = { - 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 { + 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; -} diff --git a/frontend/src/core/utils/pdfiumBitmapUtils.ts b/frontend/src/core/utils/pdfiumBitmapUtils.ts new file mode 100644 index 0000000000..6501cb6efd --- /dev/null +++ b/frontend/src/core/utils/pdfiumBitmapUtils.ts @@ -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 { + 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; + }); +} diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 1779de956e..fdfa1a628a 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -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 { + 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 { 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;