mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-16 23:08:38 +02:00
feat(pdf): replace PdfLib with Pdfium for form handling and general rendering tasks (#5899)
# Description of Changes Improves PDF rendering in the viewer by adding digital signature field support, cleaning up overlay rendering, and migrating the contrast tool off pdf-lib to PDFium WASM. ### Signature Field Overlay - Added `SignatureFieldOverlay` component that renders digital signature form fields - Renders appearance streams when present; shows a fallback badge for unsigned fields - Uses PDFium WASM for bitmap extraction ### Overlay Rendering - Integrated `SignatureFieldOverlay` and `ButtonAppearanceOverlay` into `LocalEmbedPDF` - Overlays are now clipped to page boundaries - Clarified in `EmbedPdfViewer` that frontend overlays use PDFium WASM, backend overlays use PDFBox ### Contrast Tool Migration - Replaced pdf-lib with PDFium WASM in `useAdjustContrastOperation` - PDF page creation and image embedding now go through PDFium APIs directly - Updated bitmap handling and memory management accordingly ### Cleanup - Fixed import ordering in viewer components - Removed stale comments in the contrast operation hook <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
This commit is contained in:
@@ -20,12 +20,11 @@ import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
|
||||
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
||||
|
||||
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
|
||||
|
||||
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
||||
|
||||
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
|
||||
@@ -38,7 +37,7 @@ async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales |
|
||||
if (!(measureObj instanceof PDFDict)) return null;
|
||||
const rObj = measureObj.lookup(PDFName.of('R'));
|
||||
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
|
||||
? rObj.decodeText() : '';
|
||||
? rObj.decodeText() : '';
|
||||
// D = distance array, X = x-axis fallback
|
||||
let fmtArray = measureObj.lookup(PDFName.of('D'));
|
||||
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
|
||||
@@ -213,7 +212,7 @@ const EmbedPdfViewerContent = ({
|
||||
const isFormFillToolActive = (selectedTool as string) === 'formFill';
|
||||
|
||||
// Form overlays are shown in BOTH modes:
|
||||
// - Normal viewer: form overlays visible (pdf-lib, frontend-only)
|
||||
// - Normal viewer: form overlays visible (PDFium WASM, frontend-only)
|
||||
// - formFill tool: form overlays visible (PDFBox, backend)
|
||||
const shouldEnableFormFill = true;
|
||||
|
||||
@@ -881,7 +880,7 @@ const EmbedPdfViewerContent = ({
|
||||
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
||||
|
||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||
// In normal viewer mode, this uses pdf-lib (frontend-only).
|
||||
// In normal viewer mode, this uses PDFium WASM (frontend-only).
|
||||
// In formFill tool mode, this uses PDFBox (backend).
|
||||
const formFillFileIdRef = useRef<string | null>(null);
|
||||
const formFillProviderRef = useRef(isFormFillToolActive);
|
||||
|
||||
@@ -61,6 +61,8 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
|
||||
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
|
||||
import { ButtonAppearanceOverlay } from '@app/tools/formFill/ButtonAppearanceOverlay';
|
||||
import SignatureFieldOverlay from '@app/components/viewer/SignatureFieldOverlay';
|
||||
import { CommentsSidebar } from '@app/components/viewer/CommentsSidebar';
|
||||
import { CommentAuthorProvider } from '@app/contexts/CommentAuthorContext';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
@@ -770,6 +772,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
width,
|
||||
height,
|
||||
position: 'relative',
|
||||
overflow: 'hidden', // clip overlays (buttons, fields) that extend beyond the page rect
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
@@ -790,6 +793,16 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
</div>
|
||||
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
|
||||
|
||||
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
|
||||
{enableFormFill && file && (
|
||||
<ButtonAppearanceOverlay
|
||||
pageIndex={pageIndex}
|
||||
pdfSource={file}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FormFieldOverlay for interactive form filling */}
|
||||
{enableFormFill && (
|
||||
<FormFieldOverlay
|
||||
@@ -801,6 +814,17 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
|
||||
{file && (
|
||||
<SignatureFieldOverlay
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
pdfSource={file}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
||||
{(enableAnnotations || enableRedaction) && (
|
||||
<AnnotationLayer
|
||||
|
||||
250
frontend/src/core/components/viewer/SignatureFieldOverlay.tsx
Normal file
250
frontend/src/core/components/viewer/SignatureFieldOverlay.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* SignatureFieldOverlay — Renders digital-signature form fields on top of a
|
||||
* PDF page.
|
||||
*
|
||||
* When a signature widget has an appearance stream (i.e. a visible graphic
|
||||
* embedded by the signing tool), we render it via `EPDF_RenderAnnotBitmap`
|
||||
* (an @embedpdf PDFium WASM extension) and paint the result into a `<canvas>`
|
||||
* positioned at the correct overlay location. This is the same rendering
|
||||
* path the engine itself uses for individual annotation bitmaps.
|
||||
*
|
||||
* For widgets without an appearance stream (unsigned fields, or fields whose
|
||||
* PDF writer didn't embed one), we fall back to a translucent badge overlay.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||
import {
|
||||
renderSignatureFieldAppearances,
|
||||
extractSignatures,
|
||||
type SignatureFieldAppearance,
|
||||
} from '@app/services/pdfiumService';
|
||||
|
||||
interface SignatureFieldOverlayProps {
|
||||
pageIndex: number;
|
||||
/** URL or File for the current PDF — used to extract signature data. */
|
||||
pdfSource: File | Blob | null;
|
||||
/** Document ID from EmbedPDF (kept for caller compatibility). */
|
||||
documentId: string;
|
||||
/** Rendered page width from Scroller (pixel space). */
|
||||
pageWidth: number;
|
||||
/** Rendered page height from Scroller (pixel space). */
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
interface ResolvedSignatureField extends SignatureFieldAppearance {
|
||||
/** Whether a cryptographic signature was found for this field. */
|
||||
isSigned: boolean;
|
||||
/** Signer reason string (if available). */
|
||||
reason?: string;
|
||||
/** Signing time string (if available). */
|
||||
time?: string;
|
||||
}
|
||||
let _cachedSource: File | Blob | null = null;
|
||||
let _cachedFields: ResolvedSignatureField[] = [];
|
||||
let _cachePromise: Promise<ResolvedSignatureField[]> | null = null;
|
||||
|
||||
async function resolveFields(
|
||||
source: File | Blob,
|
||||
): Promise<ResolvedSignatureField[]> {
|
||||
if (source === _cachedSource && _cachePromise) return _cachePromise;
|
||||
_cachedSource = source;
|
||||
|
||||
_cachePromise = (async () => {
|
||||
const buf = await source.arrayBuffer();
|
||||
const [appearances, signatures] = await Promise.all([
|
||||
renderSignatureFieldAppearances(buf),
|
||||
extractSignatures(buf),
|
||||
]);
|
||||
|
||||
return appearances.map((f, i) => {
|
||||
// Positional correlation is only reliable when both arrays have the same
|
||||
// length — i.e. one signature object per signature field in document order.
|
||||
// When the counts differ we cannot safely attribute reason/time per-field,
|
||||
// so we fall back to a whole-document "is signed" indicator.
|
||||
const exactMatch = appearances.length === signatures.length;
|
||||
const matchedSig = exactMatch ? signatures[i] : undefined;
|
||||
return {
|
||||
...f,
|
||||
isSigned: exactMatch ? i < signatures.length : signatures.length > 0,
|
||||
reason: matchedSig?.reason,
|
||||
time: matchedSig?.time,
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
_cachedFields = await _cachePromise;
|
||||
return _cachedFields;
|
||||
}
|
||||
|
||||
function SignatureBitmapCanvas({
|
||||
imageData,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
}: {
|
||||
imageData: ImageData;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.putImageData(imageData, 0, 0);
|
||||
}, [imageData]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: cssWidth,
|
||||
height: cssHeight,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureFieldOverlayInner({
|
||||
pageIndex,
|
||||
pdfSource,
|
||||
documentId: _documentId,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: SignatureFieldOverlayProps) {
|
||||
const [fields, setFields] = useState<ResolvedSignatureField[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfSource) {
|
||||
setFields([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
resolveFields(pdfSource).then((res) => {
|
||||
if (!cancelled) setFields(res);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setFields([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [pdfSource]);
|
||||
|
||||
const pageFields = useMemo(
|
||||
() => fields.filter((f) => f.pageIndex === pageIndex),
|
||||
[fields, pageIndex],
|
||||
);
|
||||
|
||||
if (pageFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 6,
|
||||
}}
|
||||
data-signature-overlay-page={pageIndex}
|
||||
>
|
||||
{pageFields.map((field, idx) => {
|
||||
// Use the source PDF page dimensions that the extraction used for
|
||||
// coordinate computation. This avoids mismatches with pdfPage.size
|
||||
// from EmbedPDF which may report different dimensions.
|
||||
const sx = field.sourcePageWidth > 0 ? pageWidth / field.sourcePageWidth : 1;
|
||||
const sy = field.sourcePageHeight > 0 ? pageHeight / field.sourcePageHeight : 1;
|
||||
const left = field.x * sx;
|
||||
const top = field.y * sy;
|
||||
const width = field.width * sx;
|
||||
const height = field.height * sy;
|
||||
|
||||
// If we have a rendered appearance bitmap, paint it via <canvas>.
|
||||
if (field.imageData) {
|
||||
return (
|
||||
<div
|
||||
key={`sig-${field.fieldName}-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'default',
|
||||
}}
|
||||
title={
|
||||
field.isSigned
|
||||
? `Signed${field.reason ? `: ${field.reason}` : ''}${field.time ? ` (${field.time})` : ''}`
|
||||
: `Signature field: ${field.fieldName}`
|
||||
}
|
||||
>
|
||||
<SignatureBitmapCanvas
|
||||
imageData={field.imageData}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: translucent badge for fields without an appearance.
|
||||
return (
|
||||
<div
|
||||
key={`sig-${field.fieldName}-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
border: field.isSigned
|
||||
? '2px solid rgba(34, 139, 34, 0.7)'
|
||||
: '2px dashed rgba(180, 180, 180, 0.7)',
|
||||
borderRadius: 4,
|
||||
background: field.isSigned
|
||||
? 'rgba(34, 139, 34, 0.08)'
|
||||
: 'rgba(200, 200, 200, 0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'default',
|
||||
}}
|
||||
title={
|
||||
field.isSigned
|
||||
? `Signed${field.reason ? `: ${field.reason}` : ''}${field.time ? ` (${field.time})` : ''}`
|
||||
: `Unsigned signature field: ${field.fieldName}`
|
||||
}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: Math.min(height * 0.35, 14),
|
||||
color: field.isSigned ? 'rgba(34, 139, 34, 0.85)' : 'rgba(120, 120, 120, 0.85)',
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
padding: '2px 4px',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{field.isSigned ? '🔒 Signed' : '✎ Signature'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SignatureFieldOverlay = memo(SignatureFieldOverlayInner);
|
||||
export default SignatureFieldOverlay;
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import { PDFDocument as PDFLibDocument } from '@cantoo/pdf-lib';
|
||||
import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
|
||||
import {
|
||||
getPdfiumModule,
|
||||
saveRawDocument,
|
||||
} from '@app/services/pdfiumService';
|
||||
import { copyRgbaToBgraHeap } from '@app/utils/pdfiumBitmapUtils';
|
||||
|
||||
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
@@ -18,37 +22,94 @@ async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// adjustment logic moved to shared util
|
||||
|
||||
// Render, adjust, and assemble all pages of a single PDF into a new PDF
|
||||
// Render, adjust, and assemble all pages of a single PDF into a new PDF using PDFium
|
||||
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
|
||||
const m = await getPdfiumModule();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
|
||||
const pageCount = pdf.numPages;
|
||||
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
const docPtr = m.FPDF_CreateNewDocument();
|
||||
if (!docPtr) throw new Error('PDFium: failed to create document');
|
||||
|
||||
for (let p = 1; p <= pageCount; p++) {
|
||||
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
|
||||
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
|
||||
const pngUrl = adjusted.toDataURL('image/png');
|
||||
const res = await fetch(pngUrl);
|
||||
const pngBytes = new Uint8Array(await res.arrayBuffer());
|
||||
const embedded = await newDoc.embedPng(pngBytes);
|
||||
const { width, height } = embedded.scale(1);
|
||||
const page = newDoc.addPage([width, height]);
|
||||
page.drawImage(embedded, { x: 0, y: 0, width, height });
|
||||
try {
|
||||
for (let p = 1; p <= pageCount; p++) {
|
||||
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
|
||||
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
|
||||
const ctx = adjusted.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.warn(`[adjustContrast] Skipping page ${p}: failed to get canvas context`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, adjusted.width, adjusted.height);
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
// Since we render at scale 2, the actual PDF page size is half
|
||||
const pdfPageWidth = imgWidth / 2;
|
||||
const pdfPageHeight = imgHeight / 2;
|
||||
|
||||
const pagePtr = m.FPDFPage_New(docPtr, p - 1, pdfPageWidth, pdfPageHeight);
|
||||
if (!pagePtr) {
|
||||
console.warn(`[adjustContrast] Skipping page ${p}: failed to create PDFium page`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let bitmapPtr = 0;
|
||||
try {
|
||||
bitmapPtr = m.FPDFBitmap_Create(imgWidth, imgHeight, 1);
|
||||
if (!bitmapPtr) {
|
||||
console.warn(`[adjustContrast] Skipping page ${p}: failed to create bitmap`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||
copyRgbaToBgraHeap(m, new Uint8Array(imageData.data.buffer), bufferPtr, imgWidth, imgHeight, stride);
|
||||
|
||||
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
|
||||
if (imageObjPtr) {
|
||||
const setBitmapOk = m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr);
|
||||
if (setBitmapOk) {
|
||||
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
|
||||
try {
|
||||
m.pdfium.setValue(matrixPtr, pdfPageWidth, 'float');
|
||||
m.pdfium.setValue(matrixPtr + 4, 0, 'float');
|
||||
m.pdfium.setValue(matrixPtr + 8, 0, 'float');
|
||||
m.pdfium.setValue(matrixPtr + 12, pdfPageHeight, 'float');
|
||||
m.pdfium.setValue(matrixPtr + 16, 0, 'float');
|
||||
m.pdfium.setValue(matrixPtr + 20, 0, 'float');
|
||||
|
||||
if (m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
|
||||
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
|
||||
} else {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
}
|
||||
} finally {
|
||||
m.pdfium.wasmExports.free(matrixPtr);
|
||||
}
|
||||
} else {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (bitmapPtr) m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
m.FPDFPage_GenerateContent(pagePtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await saveRawDocument(docPtr);
|
||||
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return out;
|
||||
} finally {
|
||||
m.FPDF_CloseDocument(docPtr);
|
||||
}
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<CustomProcessorResult> {
|
||||
// Limit concurrency to avoid exhausting memory/CPU while still getting speedups
|
||||
// Heuristic: use up to 4 workers on capable machines, otherwise 2-3
|
||||
let CONCURRENCY_LIMIT = 2;
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
|
||||
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
|
||||
@@ -85,7 +146,6 @@ export const adjustContrastOperationConfig = {
|
||||
customProcessor: processPdfClientSide,
|
||||
operationType: 'adjustContrast',
|
||||
defaultParameters,
|
||||
// Single-step settings component for Automate
|
||||
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
|
||||
} as const;
|
||||
|
||||
@@ -96,4 +156,3 @@ export const useAdjustContrastOperation = () => {
|
||||
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,73 +2,49 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
||||
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
|
||||
import { PDFDocument, PDFName, PDFRef, PDFDict } from '@cantoo/pdf-lib';
|
||||
// Client-side PDF processing using PDF-lib
|
||||
import {
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
closeDocAndFreeBuffer,
|
||||
saveRawDocument,
|
||||
} from '@app/services/pdfiumService';
|
||||
|
||||
// Client-side PDF processing using PDFium WASM
|
||||
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<CustomProcessorResult> => {
|
||||
const processedFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Load the PDF
|
||||
const m = await getPdfiumModule();
|
||||
const fileArrayBuffer = await file.arrayBuffer();
|
||||
const pdfBytesIn = new Uint8Array(fileArrayBuffer);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytesIn, { ignoreEncryption: true });
|
||||
const ctx = pdfDoc.context;
|
||||
const docPtr = await openRawDocumentSafe(fileArrayBuffer);
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
|
||||
// Annots() returns PDFArray | undefined
|
||||
const annots = page.node.Annots();
|
||||
if (!annots || annots.size() === 0) continue;
|
||||
|
||||
// Delete each annotation object (they are usually PDFRef)
|
||||
for (let j = annots.size() - 1; j >= 0; j--) {
|
||||
try {
|
||||
const entry = annots.get(j);
|
||||
if (entry instanceof PDFRef) {
|
||||
ctx.delete(entry);
|
||||
} else if (entry instanceof PDFDict) {
|
||||
// In practice, Annots array should contain refs; if not, just remove the array linkage.
|
||||
// (We avoid poking internal maps to find a ref for the dict.)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove annotation ${j} on page ${i + 1}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Annots key entirely
|
||||
try {
|
||||
if (page.node.has(PDFName.of('Annots'))) {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete /Annots on page ${i + 1}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: if removing ALL annotations across the doc, strip AcroForm to avoid dangling widget refs
|
||||
try {
|
||||
const catalog = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Root);
|
||||
if (catalog && 'has' in catalog && 'delete' in catalog) {
|
||||
const catalogDict = catalog as any;
|
||||
if (catalogDict.has(PDFName.of('AcroForm'))) {
|
||||
catalogDict.delete(PDFName.of('AcroForm'));
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, i);
|
||||
if (!pagePtr) continue;
|
||||
|
||||
// Remove all annotations from the page (iterate backward)
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (let j = annotCount - 1; j >= 0; j--) {
|
||||
try {
|
||||
m.FPDFPage_RemoveAnnot(pagePtr, j);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove annotation ${j} on page ${i + 1}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to remove /AcroForm:', err);
|
||||
|
||||
const outBytes = await saveRawDocument(docPtr);
|
||||
const processedFile = new File([outBytes], file.name, { type: 'application/pdf' });
|
||||
processedFiles.push(processedFile);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
|
||||
// Save returns Uint8Array — safe for Blob
|
||||
const outBytes = await pdfDoc.save();
|
||||
const outBlob = new Blob([new Uint8Array(outBytes)], { type: 'application/pdf' });
|
||||
|
||||
// Create new file with original name
|
||||
const processedFile = new File([outBlob], file.name, { type: 'application/pdf' });
|
||||
|
||||
processedFiles.push(processedFile);
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', file.name, error);
|
||||
throw new Error(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '-' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PDFDocument, PDFPage, StandardFonts } from '@cantoo/pdf-lib';
|
||||
import { PdfiumDocument, PdfiumPage, StandardFonts } from '@app/services/pdfiumDocBuilder';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
|
||||
import { REPORT_PDF_FILENAME } from '@app/hooks/tools/validateSignature/utils/signatureUtils';
|
||||
@@ -16,7 +16,7 @@ const MARGIN_Y = 22;
|
||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
|
||||
const COLUMN_GAP = 18;
|
||||
|
||||
const drawDivider = (page: PDFPage, marginX: number, contentWidth: number, y: number) => {
|
||||
const drawDivider = (page: PdfiumPage, marginX: number, contentWidth: number, y: number) => {
|
||||
page.drawLine({
|
||||
start: { x: marginX, y },
|
||||
end: { x: marginX + contentWidth, y },
|
||||
@@ -29,7 +29,7 @@ export const createReportPdf = async (
|
||||
entries: SignatureValidationReportEntry[],
|
||||
t: TFunction<'translation'>
|
||||
): Promise<File> => {
|
||||
const doc = await PDFDocument.create();
|
||||
const doc = await PdfiumDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
|
||||
const loadThumbnail = createThumbnailLoader(doc);
|
||||
@@ -101,7 +101,6 @@ export const createReportPdf = async (
|
||||
}
|
||||
|
||||
for (let i = 0; i < entry.signatures.length; i += 1) {
|
||||
// After the first signature, start a new page per signature
|
||||
if (i > 0) {
|
||||
({ page, cursorY } = startReportPage({
|
||||
doc,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PDFDocument, PDFFont, PDFImage } from '@cantoo/pdf-lib';
|
||||
import { PdfiumDocument, PdfiumFont, PdfiumImage } from '@app/services/pdfiumDocBuilder';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
||||
|
||||
interface StartPageParams {
|
||||
doc: PDFDocument;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
doc: PdfiumDocument;
|
||||
font: PdfiumFont;
|
||||
fontBold: PdfiumFont;
|
||||
marginX: number;
|
||||
marginY: number;
|
||||
contentWidth: number;
|
||||
@@ -63,8 +63,8 @@ export const startReportPage = ({
|
||||
return { page, cursorY };
|
||||
};
|
||||
|
||||
export const createThumbnailLoader = (doc: PDFDocument) => {
|
||||
const cache = new Map<string, { image: PDFImage } | null>();
|
||||
export const createThumbnailLoader = (doc: PdfiumDocument) => {
|
||||
const cache = new Map<string, { image: PdfiumImage } | null>();
|
||||
|
||||
return async (url: string) => {
|
||||
if (cache.has(url)) {
|
||||
@@ -75,7 +75,7 @@ export const createThumbnailLoader = (doc: PDFDocument) => {
|
||||
const response = await fetch(url);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let image: PDFImage;
|
||||
let image: PdfiumImage;
|
||||
|
||||
if (contentType.includes('png')) {
|
||||
image = await doc.embedPng(bytes);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
PDFDocument,
|
||||
} from '@cantoo/pdf-lib';
|
||||
import {
|
||||
PdfLibLink,
|
||||
extractLinksFromPage,
|
||||
@@ -20,14 +17,14 @@ export interface PdfLibLinksResult {
|
||||
}
|
||||
|
||||
interface CachedDoc {
|
||||
doc: PDFDocument;
|
||||
data: ArrayBuffer;
|
||||
/** Number of active consumers (hook instances) holding this entry. */
|
||||
refCount: number;
|
||||
/** Per-page extracted links (lazy, filled on first request). */
|
||||
pageLinks: Map<number, { links: PdfLibLink[]; width: number; height: number }>;
|
||||
/** Set to true when the PDF catalog/pages tree is invalid, so we
|
||||
/** Set to true when the PDF is invalid, so we
|
||||
* skip link extraction on all subsequent calls without retrying. */
|
||||
invalidCatalog?: boolean;
|
||||
invalidDocument?: boolean;
|
||||
}
|
||||
|
||||
const docCache = new Map<string, Promise<CachedDoc>>();
|
||||
@@ -37,13 +34,8 @@ async function acquireDocument(url: string): Promise<CachedDoc> {
|
||||
const promise = (async (): Promise<CachedDoc> => {
|
||||
const response = await fetch(url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const doc = await PDFDocument.load(new Uint8Array(buffer), {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
return { doc, refCount: 0, pageLinks: new Map() };
|
||||
return { data: buffer, refCount: 0, pageLinks: new Map() };
|
||||
})();
|
||||
docCache.set(url, promise);
|
||||
|
||||
@@ -107,7 +99,7 @@ export function usePdfLibLinks(
|
||||
return;
|
||||
}
|
||||
|
||||
if (cached.invalidCatalog) {
|
||||
if (cached.invalidDocument) {
|
||||
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
|
||||
releaseDocument(url);
|
||||
return;
|
||||
@@ -115,27 +107,12 @@ export function usePdfLibLinks(
|
||||
|
||||
let pageData = cached.pageLinks.get(pageIndex);
|
||||
if (!pageData) {
|
||||
let pageCount: number;
|
||||
try {
|
||||
pageCount = cached.doc.getPageCount();
|
||||
} catch {
|
||||
cached.invalidCatalog = true;
|
||||
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
|
||||
releaseDocument(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageIndex < 0 || pageIndex >= pageCount) {
|
||||
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
|
||||
releaseDocument(url);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = cached.doc.getPage(pageIndex);
|
||||
const { width, height } = page.getSize();
|
||||
const links = extractLinksFromPage(cached.doc, page, pageIndex);
|
||||
pageData = { links, width, height };
|
||||
const { links, pdfPageWidth, pdfPageHeight } = await extractLinksFromPage(
|
||||
cached.data,
|
||||
pageIndex,
|
||||
);
|
||||
pageData = { links, width: pdfPageWidth, height: pdfPageHeight };
|
||||
cached.pageLinks.set(pageIndex, pageData);
|
||||
} catch (pageError) {
|
||||
console.warn(`[usePdfLibLinks] Failed to read page ${pageIndex}:`, pageError);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from '@cantoo/pdf-lib';
|
||||
import {
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
closeRawDocument,
|
||||
saveRawDocument,
|
||||
importPages,
|
||||
setPageRotation,
|
||||
addNewPage,
|
||||
} from '@app/services/pdfiumService';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
|
||||
// A4 dimensions in PDF points (72 dpi)
|
||||
const A4_WIDTH = 595.276;
|
||||
const A4_HEIGHT = 841.890;
|
||||
|
||||
export interface ExportOptions {
|
||||
selectedOnly?: boolean;
|
||||
filename?: string;
|
||||
@@ -19,7 +31,6 @@ export class PDFExportService {
|
||||
const { selectedOnly = false, filename } = options;
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
@@ -28,10 +39,8 @@ export class PDFExportService {
|
||||
throw new Error('No pages to export');
|
||||
}
|
||||
|
||||
// Load original PDF and create new document
|
||||
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||
const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
|
||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
||||
const blob = await this.createSingleDocument(originalPDFBytes, pagesToExport);
|
||||
const exportFilename = this.generateFilename(filename || pdfDocument.name);
|
||||
|
||||
return { blob, filename: exportFilename };
|
||||
@@ -56,7 +65,6 @@ export class PDFExportService {
|
||||
const { selectedOnly = false, filename } = options;
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
@@ -79,102 +87,140 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDF document from multiple source files
|
||||
* Create a PDF document from multiple source files using PDFium WASM.
|
||||
*/
|
||||
private async createMultiSourceDocument(
|
||||
sourceFiles: Map<string, File>,
|
||||
pages: PDFPage[]
|
||||
): Promise<Blob> {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
const m = await getPdfiumModule();
|
||||
|
||||
// Create destination document
|
||||
const destDocPtr = m.FPDF_CreateNewDocument();
|
||||
if (!destDocPtr) throw new Error('PDFium: failed to create destination document');
|
||||
|
||||
// Load all source documents once and cache them
|
||||
const loadedDocs = new Map<string, PDFLibDocument>();
|
||||
const loadedDocs = new Map<string, number>();
|
||||
|
||||
for (const [fileId, file] of sourceFiles) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
|
||||
loadedDocs.set(fileId, doc);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load source file ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
||||
// Create a blank page
|
||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
||||
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
|
||||
// Get the correct source document for this page
|
||||
const sourceDoc = loadedDocs.get(page.originalFileId)!;
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page from the correct source document
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
try {
|
||||
for (const [fileId, file] of sourceFiles) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const docPtr = await openRawDocumentSafe(arrayBuffer);
|
||||
loadedDocs.set(fileId, docPtr);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load source file ${fileId}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
|
||||
}
|
||||
|
||||
let insertIdx = 0;
|
||||
for (const page of pages) {
|
||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
||||
// Insert a blank A4 page
|
||||
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
|
||||
// Apply rotation
|
||||
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
|
||||
if (pdfiumRotation !== 0) {
|
||||
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
|
||||
}
|
||||
insertIdx++;
|
||||
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
|
||||
const srcDocPtr = loadedDocs.get(page.originalFileId)!;
|
||||
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
|
||||
// Import the specific page (1-based page range for FPDF_ImportPages)
|
||||
const pageRange = String(sourcePageIndex + 1);
|
||||
const imported = await importPages(destDocPtr, srcDocPtr, pageRange, insertIdx);
|
||||
if (!imported) {
|
||||
console.warn(
|
||||
`[PDFExport] importPages failed for fileId=${page.originalFileId} pageRange=${pageRange} — page will be missing from output.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
|
||||
if (pdfiumRotation !== 0) {
|
||||
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
|
||||
}
|
||||
insertIdx++;
|
||||
}
|
||||
} else {
|
||||
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save the assembled document
|
||||
const resultBuf = await saveRawDocument(destDocPtr);
|
||||
return new Blob([resultBuf], { type: 'application/pdf' });
|
||||
} finally {
|
||||
// Cleanup all loaded source documents
|
||||
for (const docPtr of loadedDocs.values()) {
|
||||
await closeRawDocument(docPtr);
|
||||
}
|
||||
m.FPDF_CloseDocument(destDocPtr);
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setCreationDate(new Date());
|
||||
newDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single PDF document with all operations applied (single source)
|
||||
* Create a single PDF document with all operations applied (single source) using PDFium.
|
||||
*/
|
||||
private async createSingleDocument(
|
||||
sourceDoc: PDFLibDocument,
|
||||
sourceData: ArrayBuffer,
|
||||
pages: PDFPage[]
|
||||
): Promise<Blob> {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
const m = await getPdfiumModule();
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
||||
// Create a blank page
|
||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
||||
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
} else {
|
||||
// Get the original page from source document using originalPageNumber
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
// Open source document
|
||||
const srcDocPtr = await openRawDocumentSafe(sourceData);
|
||||
const destDocPtr = m.FPDF_CreateNewDocument();
|
||||
if (!destDocPtr) {
|
||||
await closeRawDocument(srcDocPtr);
|
||||
throw new Error('PDFium: failed to create destination document');
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setCreationDate(new Date());
|
||||
newDoc.setModificationDate(new Date());
|
||||
try {
|
||||
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
|
||||
let insertIdx = 0;
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
||||
for (const page of pages) {
|
||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
||||
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
|
||||
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
|
||||
if (pdfiumRotation !== 0) {
|
||||
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
|
||||
}
|
||||
insertIdx++;
|
||||
} else {
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
|
||||
const pageRange = String(sourcePageIndex + 1);
|
||||
const imported = await importPages(destDocPtr, srcDocPtr, pageRange, insertIdx);
|
||||
if (!imported) {
|
||||
console.warn(
|
||||
`[PDFExport] importPages failed for page ${page.originalPageNumber} pageRange=${pageRange} — page will be missing from output.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
|
||||
if (pdfiumRotation !== 0) {
|
||||
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
|
||||
}
|
||||
insertIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultBuf = await saveRawDocument(destDocPtr);
|
||||
return new Blob([resultBuf], { type: 'application/pdf' });
|
||||
} finally {
|
||||
await closeRawDocument(srcDocPtr);
|
||||
m.FPDF_CloseDocument(destDocPtr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate appropriate filename for export
|
||||
*/
|
||||
@@ -183,7 +229,6 @@ export class PDFExportService {
|
||||
return `${baseName}.pdf`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Download a single file
|
||||
*/
|
||||
@@ -198,7 +243,7 @@ export class PDFExportService {
|
||||
blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
this.downloadFile(blob, filenames[index]);
|
||||
}, index * 500); // Stagger downloads
|
||||
}, index * 500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,9 +286,8 @@ export class PDFExportService {
|
||||
|
||||
const splitCount = pagesToExport.reduce((count, page) => {
|
||||
return count + (page.splitAfter ? 1 : 0);
|
||||
}, 1); // At least 1 document
|
||||
}, 1);
|
||||
|
||||
// Rough size estimation (very approximate)
|
||||
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
|
||||
const estimatedBytes = avgPageSize * pagesToExport.length;
|
||||
const estimatedSize = this.formatFileSize(estimatedBytes);
|
||||
@@ -267,5 +311,18 @@ export class PDFExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees (0, 90, 180, 270) to PDFium rotation enum (0, 1, 2, 3).
|
||||
*/
|
||||
function degreesToPdfiumRotation(degrees: number): number {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
switch (normalized) {
|
||||
case 90: return 1;
|
||||
case 180: return 2;
|
||||
case 270: return 3;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfExportService = new PDFExportService();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
405
frontend/src/core/services/pdfiumDocBuilder.ts
Normal file
405
frontend/src/core/services/pdfiumDocBuilder.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* pdfiumDocBuilder — High-level PDF document builder powered by PDFium WASM.
|
||||
*
|
||||
* Provides an API surface compatible with common pdf-lib patterns
|
||||
* (drawText, drawRectangle, drawLine, drawImage, font metrics, etc.)
|
||||
* so that code previously using pdf-lib for PDF *generation* can be
|
||||
* migrated with minimal changes.
|
||||
*
|
||||
* Used by the signature validation report system.
|
||||
*/
|
||||
import { getPdfiumModule, writeUtf16, saveRawDocument } from '@app/services/pdfiumService';
|
||||
import { embedBitmapImageOnPage } from '@app/utils/pdfiumBitmapUtils';
|
||||
import type { WrappedPdfiumModule } from '@embedpdf/pdfium';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color type (replaces pdf-lib's `rgb()`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PdfiumColor {
|
||||
_r: number;
|
||||
_g: number;
|
||||
_b: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an RGB color (each component 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<string, PdfiumFont> = new Map();
|
||||
|
||||
private constructor(m: WrappedPdfiumModule, docPtr: number) {
|
||||
this._m = m;
|
||||
this._docPtr = docPtr;
|
||||
}
|
||||
|
||||
/** Create a new empty PDF document. Drop-in replacement for `PDFDocument.create()`. */
|
||||
static async create(): Promise<PdfiumDocument> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = m.FPDF_CreateNewDocument();
|
||||
if (!docPtr) throw new Error('PDFium: failed to create document');
|
||||
return new PdfiumDocument(m, docPtr);
|
||||
}
|
||||
|
||||
/** Add a new page to the document. */
|
||||
addPage(dimensions: [number, number]): PdfiumPage {
|
||||
const [width, height] = dimensions;
|
||||
const insertIdx = this._pages.length;
|
||||
const pagePtr = this._m.FPDFPage_New(this._docPtr, insertIdx, width, height);
|
||||
if (!pagePtr) throw new Error('PDFium: failed to create page');
|
||||
const page = new PdfiumPage(this._m, this._docPtr, pagePtr, width, height);
|
||||
this._pages.push(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** Embed a standard PDF font. Returns a PdfiumFont for text measurement and drawing. */
|
||||
async embedFont(fontName: string): Promise<PdfiumFont> {
|
||||
if (this._fonts.has(fontName)) return this._fonts.get(fontName)!;
|
||||
const font = new PdfiumFont(fontName);
|
||||
this._fonts.set(fontName, font);
|
||||
return font;
|
||||
}
|
||||
|
||||
/** Embed a PNG image from raw bytes. */
|
||||
async embedPng(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
|
||||
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), 'image/png');
|
||||
}
|
||||
|
||||
/** Embed a JPEG image from raw bytes. */
|
||||
async embedJpg(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
|
||||
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), 'image/jpeg');
|
||||
}
|
||||
|
||||
/** Get the number of pages. */
|
||||
getPageCount(): number {
|
||||
return this._pages.length;
|
||||
}
|
||||
|
||||
/** Save the document and return the PDF bytes. */
|
||||
async save(): Promise<Uint8Array> {
|
||||
// Generate content for all pages before saving
|
||||
for (const page of this._pages) {
|
||||
page._generateContent();
|
||||
}
|
||||
|
||||
const buf = await saveRawDocument(this._docPtr);
|
||||
|
||||
// Close all pages and the document
|
||||
for (const page of this._pages) {
|
||||
page._close();
|
||||
}
|
||||
this._m.FPDF_CloseDocument(this._docPtr);
|
||||
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode image bytes to RGBA pixel data via canvas.
|
||||
* PDFium's image object APIs require bitmap data.
|
||||
*/
|
||||
private _decodeImage(bytes: Uint8Array, mimeType: string): Promise<PdfiumImage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([bytes as BlobPart], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Canvas 2D context unavailable'));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(new PdfiumImage(new Uint8Array(imageData.data.buffer), canvas.width, canvas.height));
|
||||
} catch (err) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to decode image'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
1672
frontend/src/core/services/pdfiumService.ts
Normal file
1672
frontend/src/core/services/pdfiumService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
121
frontend/src/core/tools/formFill/ButtonAppearanceOverlay.tsx
Normal file
121
frontend/src/core/tools/formFill/ButtonAppearanceOverlay.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* ButtonAppearanceOverlay — Renders PDF push-button widget appearances as
|
||||
* canvas bitmaps on top of a PDF page.
|
||||
*
|
||||
* This is a visual-only layer (pointerEvents: none). Click handling is done
|
||||
* separately by FormFieldOverlay's transparent hit-target divs.
|
||||
*
|
||||
* Uses the same EPDF_RenderAnnotBitmap / FPDF_FFLDraw pipeline as
|
||||
* SignatureFieldOverlay to produce the button's native PDF appearance.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||
import { renderButtonFieldAppearances, type SignatureFieldAppearance } from '@app/services/pdfiumService';
|
||||
|
||||
interface ButtonAppearanceOverlayProps {
|
||||
pageIndex: number;
|
||||
pdfSource: File | Blob | null;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
let _cachedSource: File | Blob | null = null;
|
||||
let _cachePromise: Promise<SignatureFieldAppearance[]> | null = null;
|
||||
|
||||
async function resolveButtonAppearances(source: File | Blob): Promise<SignatureFieldAppearance[]> {
|
||||
if (source === _cachedSource && _cachePromise) return _cachePromise;
|
||||
_cachedSource = source;
|
||||
_cachePromise = source.arrayBuffer().then((buf) => renderButtonFieldAppearances(buf));
|
||||
return _cachePromise;
|
||||
}
|
||||
function ButtonBitmapCanvas({ imageData, cssWidth, cssHeight }: {
|
||||
imageData: ImageData;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.putImageData(imageData, 0, 0);
|
||||
}, [imageData]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: cssWidth, height: cssHeight, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function ButtonAppearanceOverlayInner({
|
||||
pageIndex,
|
||||
pdfSource,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: ButtonAppearanceOverlayProps) {
|
||||
const [appearances, setAppearances] = useState<SignatureFieldAppearance[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfSource) { setAppearances([]); return; }
|
||||
let cancelled = false;
|
||||
resolveButtonAppearances(pdfSource)
|
||||
.then((res) => { if (!cancelled) setAppearances(res); })
|
||||
.catch(() => { if (!cancelled) setAppearances([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pdfSource]);
|
||||
|
||||
const pageAppearances = useMemo(
|
||||
() => appearances.filter((a) => a.pageIndex === pageIndex && a.imageData !== null),
|
||||
[appearances, pageIndex],
|
||||
);
|
||||
|
||||
if (pageAppearances.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 4,
|
||||
}}
|
||||
data-button-appearance-page={pageIndex}
|
||||
>
|
||||
{pageAppearances.map((btn, idx) => {
|
||||
const sx = btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
|
||||
const sy = btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
|
||||
const left = btn.x * sx;
|
||||
const top = btn.y * sy;
|
||||
const width = btn.width * sx;
|
||||
const height = btn.height * sy;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`btn-appearance-${btn.fieldName}-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ButtonBitmapCanvas
|
||||
imageData={btn.imageData!}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ButtonAppearanceOverlay = memo(ButtonAppearanceOverlayInner);
|
||||
@@ -138,15 +138,13 @@ function FieldInputInner({
|
||||
case 'radio': {
|
||||
const radioOptions: { value: string; label: string }[] = [];
|
||||
if (field.widgets && field.widgets.length > 0) {
|
||||
for (const w of field.widgets) {
|
||||
if (w.exportValue && !radioOptions.some((o) => o.value === w.exportValue)) {
|
||||
radioOptions.push({ value: w.exportValue, label: w.exportValue });
|
||||
}
|
||||
for (let i = 0; i < field.widgets.length; i++) {
|
||||
const w = field.widgets[i];
|
||||
// Use widget index as value; display option label, export value, or index
|
||||
const label = (field.options && field.options[i]) || w.exportValue || String(i);
|
||||
radioOptions.push({ value: String(i), label });
|
||||
}
|
||||
}
|
||||
if (radioOptions.length === 0 && field.options) {
|
||||
radioOptions.push(...field.options.map((o) => ({ value: o, label: o })));
|
||||
}
|
||||
return (
|
||||
<Radio.Group
|
||||
value={value}
|
||||
|
||||
@@ -20,7 +20,110 @@
|
||||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import { useDocumentState } from '@embedpdf/core/react';
|
||||
import { useFormFill, useFieldValue } from '@app/tools/formFill/FormFillContext';
|
||||
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import type { FormField, WidgetCoordinates, ButtonAction } from '@app/tools/formFill/types';
|
||||
|
||||
/**
|
||||
* Execute PDF JavaScript in a minimally sandboxed context.
|
||||
*
|
||||
* Implements a heuristic security check by statically rejecting scripts containing
|
||||
* common browser globals (`window`, `document`, `fetch`), reflection APIs,
|
||||
* or execution sinks (`eval`, `Function`).
|
||||
*
|
||||
* Valid scripts run in strict mode with dangerous globals explicitly masked
|
||||
* to `undefined`, allowing safe Acrobat APIs like `this.print()` or `app.alert()`.
|
||||
*/
|
||||
function executePdfJs(
|
||||
js: string,
|
||||
handlers: {
|
||||
print: () => void;
|
||||
save: () => void;
|
||||
submitForm: (url: string) => void;
|
||||
resetForm: () => void;
|
||||
},
|
||||
): void {
|
||||
// 1. Static sanitization: Reject scripts with potentially harmful or unneeded keywords.
|
||||
// This blocks most elementary exploits and prevents prototype tampering.
|
||||
const forbidden = [
|
||||
'window', 'document', 'fetch', 'xmlhttprequest', 'websocket', 'worker',
|
||||
'eval', 'settimeout', 'setinterval', 'function', 'constructor',
|
||||
'__proto__', 'prototype', 'globalthis', 'import', 'require'
|
||||
];
|
||||
|
||||
const lowerJs = js.toLowerCase();
|
||||
for (const word of forbidden) {
|
||||
if (lowerJs.includes(word)) {
|
||||
console.warn(`[PDF JS] Execution blocked: Script contains suspicious keyword "${word}".`, 'Script:', js);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mock Acrobat API
|
||||
const doOpenUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch { /* invalid URL — ignore */ }
|
||||
};
|
||||
|
||||
const app = {
|
||||
print: (_params?: unknown) => handlers.print(),
|
||||
alert: (msg: unknown) => { console.debug('[PDF JS] alert:', msg); },
|
||||
beep: () => {},
|
||||
response: () => null,
|
||||
execMenuItem: (item: string) => {
|
||||
switch (item) {
|
||||
case 'Print': handlers.print(); break;
|
||||
case 'Save': handlers.save(); break;
|
||||
case 'Close': break; // no-op in browser context
|
||||
default: console.debug('[PDF JS] execMenuItem: unhandled item:', item);
|
||||
}
|
||||
},
|
||||
// Prevent prototype walking
|
||||
__proto__: null
|
||||
};
|
||||
|
||||
const doc = {
|
||||
print: (_params?: unknown) => handlers.print(),
|
||||
save: (_params?: unknown) => handlers.save(),
|
||||
saveAs: (_params?: unknown) => handlers.save(),
|
||||
submitForm: (urlOrParams: unknown) => {
|
||||
const url =
|
||||
typeof urlOrParams === 'string'
|
||||
? urlOrParams
|
||||
: (urlOrParams as Record<string, unknown>)?.cURL as string ?? '';
|
||||
if (url) doOpenUrl(url); else handlers.submitForm(url);
|
||||
},
|
||||
resetForm: (_fields?: unknown) => handlers.resetForm(),
|
||||
getField: (_name: string) => null,
|
||||
getAnnot: () => null,
|
||||
getURL: (url: string) => doOpenUrl(url),
|
||||
numPages: 1,
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
// Stub event object — used by field calculation/validation scripts
|
||||
const event = {
|
||||
value: '',
|
||||
changeEx: '',
|
||||
change: '',
|
||||
rc: true,
|
||||
willCommit: false,
|
||||
target: null as null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Pass doc, app, event as both `this` AND named parameters so scripts that
|
||||
// reference them as free variables (not just via `this`) work correctly.
|
||||
const fn = new Function('app', 'doc', 'event', js);
|
||||
fn.call(doc, app, doc, event);
|
||||
} catch (err) {
|
||||
// Swallow errors from missing PDF APIs; log in debug mode for tracing
|
||||
console.debug('[PDF JS] Script execution error (expected for unsupported APIs):', err, '\nScript:', js.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
interface WidgetInputProps {
|
||||
field: FormField;
|
||||
@@ -31,6 +134,7 @@ interface WidgetInputProps {
|
||||
scaleY: number;
|
||||
onFocus: (fieldName: string) => void;
|
||||
onChange: (fieldName: string, value: string) => void;
|
||||
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +151,7 @@ function WidgetInputInner({
|
||||
scaleY,
|
||||
onFocus,
|
||||
onChange,
|
||||
onButtonClick,
|
||||
}: WidgetInputProps) {
|
||||
// Per-field value subscription — only this widget re-renders when its value changes
|
||||
const value = useFieldValue(field.name);
|
||||
@@ -287,10 +392,12 @@ function WidgetInputInner({
|
||||
}
|
||||
|
||||
case 'radio': {
|
||||
// Each radio widget has an exportValue set by the backend
|
||||
const optionValue = widget.exportValue || '';
|
||||
if (!optionValue) return null; // no export value, skip
|
||||
const isSelected = value === optionValue;
|
||||
// Identify this widget by its index within the field's widgets array.
|
||||
// This avoids issues with duplicate exportValues (e.g., all "Yes").
|
||||
const widgetIndex = field.widgets?.indexOf(widget) ?? -1;
|
||||
if (widgetIndex < 0) return null;
|
||||
const widgetIndexStr = String(widgetIndex);
|
||||
const isSelected = value === widgetIndexStr;
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
@@ -304,11 +411,11 @@ function WidgetInputInner({
|
||||
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
|
||||
cursor: field.readOnly ? 'default' : 'pointer',
|
||||
}}
|
||||
title={error || field.tooltip || `${field.label}: ${optionValue}`}
|
||||
title={error || field.tooltip || `${field.label}: ${widget.exportValue || widgetIndexStr}`}
|
||||
onClick={(e) => {
|
||||
if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
|
||||
if (field.readOnly || value === widgetIndexStr) return; // Don't deselect radio buttons
|
||||
handleFocus();
|
||||
onChange(field.name, optionValue);
|
||||
onChange(field.name, widgetIndexStr);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
>
|
||||
@@ -317,8 +424,8 @@ function WidgetInputInner({
|
||||
width: Math.min(width, height) * 0.8,
|
||||
height: Math.min(width, height) * 0.8,
|
||||
borderRadius: '50%',
|
||||
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
|
||||
background: isSelected ? '#2196F3' : '#FFF',
|
||||
border: `1.5px solid ${isSelected ? '#2196F3' : isActive ? '#2196F3' : '#999'}`,
|
||||
background: isSelected ? '#2196F3' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -331,44 +438,58 @@ function WidgetInputInner({
|
||||
}
|
||||
|
||||
case 'signature':
|
||||
// Signed signatures have a pre-rendered appearance image; render it.
|
||||
if (field.appearanceDataUrl) {
|
||||
return (
|
||||
<img
|
||||
src={field.appearanceDataUrl}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
alt={field.label || 'Signature'}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
// Signature fields are handled entirely by SignatureFieldOverlay (bitmap canvas).
|
||||
// Rendering a placeholder here creates a visible grey overlay on top of the
|
||||
// signature appearance, so we skip it entirely.
|
||||
return null;
|
||||
|
||||
case 'button': {
|
||||
// Transparent hit-target only — visual appearance is rendered by ButtonAppearanceOverlay
|
||||
// (which paints the PDF's native /AP bitmap onto a canvas behind this div).
|
||||
const buttonLabel = field.buttonLabel || field.value || field.label || 'Button';
|
||||
const isClickable = !field.readOnly;
|
||||
|
||||
let actionHint = '';
|
||||
if (field.buttonAction) {
|
||||
switch (field.buttonAction.type) {
|
||||
case 'named': actionHint = field.buttonAction.namedAction ?? ''; break;
|
||||
case 'resetForm': actionHint = 'Reset Form'; break;
|
||||
case 'submitForm': actionHint = `Submit to: ${field.buttonAction.url ?? ''}`.trim(); break;
|
||||
case 'uri': actionHint = field.buttonAction.url ?? ''; break;
|
||||
case 'javascript': actionHint = 'Script'; break;
|
||||
}
|
||||
}
|
||||
// Unsigned signature — fall through to placeholder
|
||||
// falls through
|
||||
case 'button':
|
||||
// Just render a highlighted area — not editable
|
||||
const titleText = field.tooltip || (actionHint ? `${buttonLabel} (${actionHint})` : buttonLabel);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
background: 'rgba(200,200,200,0.3)',
|
||||
border: '1px dashed #999',
|
||||
cursor: 'default',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
}}
|
||||
title={titleText}
|
||||
role="button"
|
||||
tabIndex={isClickable ? 0 : -1}
|
||||
aria-label={buttonLabel}
|
||||
onClick={(e) => {
|
||||
handleFocus();
|
||||
if (isClickable) onButtonClick(field, field.buttonAction);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onButtonClick(field, field.buttonAction);
|
||||
}
|
||||
stopPropagation(e);
|
||||
}}
|
||||
title={field.tooltip || `${field.type}: ${field.label}`}
|
||||
onClick={handleFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
@@ -407,6 +528,7 @@ export function FormFieldOverlay({
|
||||
}: FormFieldOverlayProps) {
|
||||
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
||||
const { activeFieldName, validationErrors } = state;
|
||||
const { printActions, scrollActions, exportActions } = useViewer();
|
||||
|
||||
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||
// NOTE: All hooks must be called unconditionally (before any early returns)
|
||||
@@ -416,20 +538,28 @@ export function FormFieldOverlay({
|
||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||
const s = documentState?.scale ?? 1;
|
||||
if (pageIndex === 0) {
|
||||
console.debug('[FormFieldOverlay] page 0 using fallback scale=%f (missing pdfPage.size)', s);
|
||||
}
|
||||
return { scaleX: s, scaleY: s };
|
||||
}
|
||||
|
||||
// pdfPage.size contains un-rotated (MediaBox) dimensions;
|
||||
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
|
||||
return {
|
||||
scaleX: pageWidth / pdfPage.size.width,
|
||||
scaleY: pageHeight / pdfPage.size.height,
|
||||
};
|
||||
const sx = pageWidth / pdfPage.size.width;
|
||||
const sy = pageHeight / pdfPage.size.height;
|
||||
if (pageIndex === 0) {
|
||||
console.debug(
|
||||
'[FormFieldOverlay] page 0 scale: pageW=%f pageH=%f pdfW=%f pdfH=%f → scaleX=%f scaleY=%f docScale=%f',
|
||||
pageWidth, pageHeight, pdfPage.size.width, pdfPage.size.height, sx, sy, documentState?.scale,
|
||||
);
|
||||
}
|
||||
// pdfPage.size contains un-rotated dimensions from the engine;
|
||||
// pageWidth/pageHeight from Scroller = pdfPage.size * documentScale
|
||||
return { scaleX: sx, scaleY: sy };
|
||||
}, [documentState, pageIndex, pageWidth, pageHeight]);
|
||||
|
||||
const pageFields = useMemo(
|
||||
() => fieldsByPage.get(pageIndex) || [],
|
||||
[fieldsByPage, pageIndex],
|
||||
[fieldsByPage, pageIndex]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
@@ -442,6 +572,64 @@ export function FormFieldOverlay({
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(field: FormField, action?: ButtonAction | null) => {
|
||||
const doOpenUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (['http:', 'https:', 'mailto:'].includes(u.protocol)) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch { /* invalid URL */ }
|
||||
};
|
||||
const doResetForm = () => {
|
||||
for (const f of state.fields) setValue(f.name, f.value ?? '');
|
||||
};
|
||||
const doSave = () => { exportActions.saveAsCopy(); };
|
||||
|
||||
if (!action) {
|
||||
// Action extraction failed — fall back to label matching as a last resort
|
||||
const label = (field.buttonLabel || field.label || '').toLowerCase();
|
||||
if (/print/.test(label)) printActions.print();
|
||||
else if (/save|download/.test(label)) doSave();
|
||||
else if (/reset|clear/.test(label)) doResetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'named':
|
||||
switch (action.namedAction) {
|
||||
case 'Print': printActions.print(); break;
|
||||
case 'Save': doSave(); break;
|
||||
case 'NextPage': scrollActions.scrollToNextPage(); break;
|
||||
case 'PrevPage': scrollActions.scrollToPreviousPage(); break;
|
||||
case 'FirstPage': scrollActions.scrollToFirstPage(); break;
|
||||
case 'LastPage': scrollActions.scrollToLastPage(); break;
|
||||
}
|
||||
break;
|
||||
case 'resetForm':
|
||||
doResetForm();
|
||||
break;
|
||||
case 'submitForm':
|
||||
case 'uri':
|
||||
if (action.url) doOpenUrl(action.url);
|
||||
break;
|
||||
case 'javascript':
|
||||
// Execute in a sandboxed PDF JS environment instead of just logging
|
||||
if (action.javascript) {
|
||||
executePdfJs(action.javascript, {
|
||||
print: () => printActions.print(),
|
||||
save: doSave,
|
||||
submitForm: doOpenUrl,
|
||||
resetForm: doResetForm,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[printActions, scrollActions, exportActions, state.fields, setValue],
|
||||
);
|
||||
|
||||
// Guard: don't render fields from a previous document.
|
||||
// If fileId is provided and doesn't match what the context fetched for, render nothing.
|
||||
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
||||
@@ -485,6 +673,7 @@ export function FormFieldOverlay({
|
||||
scaleY={scaleY}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
onButtonClick={handleButtonClick}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -32,8 +32,9 @@ import React, {
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
import { PdfLibFormProvider, fetchSignatureFieldsWithAppearances } from '@app/tools/formFill/providers/PdfLibFormProvider';
|
||||
import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||
import { PdfiumFormProvider } from '@app/tools/formFill/providers/PdfiumFormProvider';
|
||||
import { fetchSignatureFieldsWithAppearances } from '@app/services/pdfiumService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FormValuesStore — external store for field values (outside React state)
|
||||
@@ -268,7 +269,7 @@ export function useAllFormValues(): Record<string, string> {
|
||||
}
|
||||
|
||||
/** Singleton provider instances */
|
||||
const pdfLibProvider = new PdfLibFormProvider();
|
||||
const pdfiumProvider = new PdfiumFormProvider();
|
||||
const pdfBoxProvider = new PdfBoxFormProvider();
|
||||
|
||||
export function FormFillProvider({
|
||||
@@ -283,7 +284,7 @@ export function FormFillProvider({
|
||||
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
|
||||
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
|
||||
providerModeRef.current = providerMode;
|
||||
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
|
||||
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider);
|
||||
const providerRef = useRef(provider);
|
||||
providerRef.current = provider;
|
||||
|
||||
@@ -322,7 +323,7 @@ export function FormFillProvider({
|
||||
let fields = await providerRef.current.fetchFields(file);
|
||||
// If another fetch or reset happened while we were waiting, discard this result
|
||||
if (fetchVersionRef.current !== version) {
|
||||
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||
console.debug('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,7 +331,9 @@ export function FormFillProvider({
|
||||
// (they're not fillable). Fetch them via pdflib so their appearances still render.
|
||||
if (providerModeRef.current === 'pdfbox') {
|
||||
try {
|
||||
const sigFields = await fetchSignatureFieldsWithAppearances(file);
|
||||
// Convert File/Blob to ArrayBuffer for pdfiumService
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const sigFields = await fetchSignatureFieldsWithAppearances(arrayBuffer);
|
||||
if (fetchVersionRef.current !== version) return; // stale check after async
|
||||
if (sigFields.length > 0) {
|
||||
fields = [...fields, ...sigFields];
|
||||
@@ -420,7 +423,7 @@ export function FormFillProvider({
|
||||
if (providerModeRef.current === mode) return;
|
||||
|
||||
// provider (pdfbox vs pdflib).
|
||||
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
|
||||
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider;
|
||||
providerRef.current = newProvider;
|
||||
providerModeRef.current = mode;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,865 +0,0 @@
|
||||
/**
|
||||
* PdfLibFormProvider — Frontend-only form data provider using pdf-lib.
|
||||
*
|
||||
* Extracts form fields directly from the PDF byte stream and fills them
|
||||
* without any backend calls. This avoids sending large PDFs (potentially
|
||||
* hundreds of MB) to the server for a feature that can be done entirely
|
||||
* on the client.
|
||||
*
|
||||
* Used in normal viewer mode when the user views a PDF with form fields.
|
||||
*
|
||||
* Coordinate system:
|
||||
* pdf-lib provides widget rectangles in PDF user space (lower-left origin).
|
||||
* We transform them to CSS space (top-left origin) matching what the backend
|
||||
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
|
||||
* for both providers.
|
||||
*/
|
||||
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
|
||||
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
|
||||
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage,
|
||||
PDFString, PDFHexString, PDFStream } from '@cantoo/pdf-lib';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import type { FormField, FormFieldType, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
|
||||
/**
|
||||
* Read a File/Blob as ArrayBuffer.
|
||||
*/
|
||||
async function readAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page index for a widget annotation by finding which page contains it.
|
||||
*/
|
||||
function getWidgetPageIndex(
|
||||
widget: PDFDict,
|
||||
pages: PDFPage[],
|
||||
): number {
|
||||
// Check /P entry first (direct page reference)
|
||||
const pRef = widget.get(PDFName.of('P'));
|
||||
if (pRef instanceof PDFRef) {
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (pages[i].ref === pRef) return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to scanning each page's /Annots array
|
||||
const widgetRef = findWidgetRef(widget, pages);
|
||||
if (widgetRef !== undefined) return widgetRef;
|
||||
|
||||
return 0; // default to first page
|
||||
}
|
||||
|
||||
function findWidgetRef(widget: PDFDict, pages: PDFPage[]): number | undefined {
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const annots = pages[i].node.lookup(PDFName.of('Annots'));
|
||||
if (annots instanceof PDFArray) {
|
||||
for (let j = 0; j < annots.size(); j++) {
|
||||
const annotRef = annots.get(j);
|
||||
const annotDict = annots.lookup(j);
|
||||
if (annotDict === widget || annotRef === (widget as any).ref) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page rotation in degrees (0, 90, 180, 270).
|
||||
*/
|
||||
function getPageRotation(page: PDFPage): number {
|
||||
const rot = page.getRotation();
|
||||
return rot?.angle ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract widget rectangles from a PDFField, transforming from PDF space
|
||||
* (lower-left origin) to CSS space (top-left origin).
|
||||
*
|
||||
* Widget /Rect coordinates are always in un-rotated PDF user space
|
||||
* (defined by the MediaBox/CropBox). We only need a y-flip to convert
|
||||
* from PDF's lower-left origin to CSS's upper-left origin.
|
||||
*
|
||||
* The embedpdf viewer wraps all page content (including this overlay)
|
||||
* inside a <Rotate> CSS component that handles visual rotation.
|
||||
* Therefore we must NOT apply any rotation here — doing so would
|
||||
* double-rotate the widgets.
|
||||
*/
|
||||
function extractWidgets(
|
||||
field: PDFField,
|
||||
pages: PDFPage[],
|
||||
_doc: PDFDocument,
|
||||
): WidgetCoordinates[] {
|
||||
const widgets: WidgetCoordinates[] = [];
|
||||
// Access the underlying PDFDict from the acro field
|
||||
const acroFieldDict = (field.acroField as any).dict as PDFDict;
|
||||
|
||||
// Get all widget annotations for this field
|
||||
const widgetDicts = getFieldWidgets(acroFieldDict);
|
||||
|
||||
for (const wDict of widgetDicts) {
|
||||
const rect = wDict.lookup(PDFName.of('Rect'));
|
||||
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
|
||||
|
||||
const x1 = numberVal(rect.lookup(0));
|
||||
const y1 = numberVal(rect.lookup(1));
|
||||
const x2 = numberVal(rect.lookup(2));
|
||||
const y2 = numberVal(rect.lookup(3));
|
||||
|
||||
const pageIndex = getWidgetPageIndex(wDict, pages);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) continue;
|
||||
|
||||
// Get CropBox dimensions (un-rotated) for coordinate transformation
|
||||
const cropBox = getCropBox(page);
|
||||
const cropHeight = cropBox.height;
|
||||
const cropX = cropBox.x;
|
||||
const cropY = cropBox.y;
|
||||
|
||||
// Widget rect in PDF space (lower-left origin, un-rotated)
|
||||
const pdfX = Math.min(x1, x2);
|
||||
const pdfY = Math.min(y1, y2);
|
||||
const pdfW = Math.abs(x2 - x1);
|
||||
const pdfH = Math.abs(y2 - y1);
|
||||
|
||||
// Adjust relative to CropBox origin
|
||||
const relativeX = pdfX - cropX;
|
||||
const relativeY = pdfY - cropY;
|
||||
|
||||
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||
// No rotation transform here — the <Rotate> CSS component in the viewer
|
||||
// handles page rotation for all overlays including form fields.
|
||||
const finalX = relativeX;
|
||||
const finalY = cropHeight - relativeY - pdfH;
|
||||
const finalW = pdfW;
|
||||
const finalH = pdfH;
|
||||
|
||||
// Extract export value for checkboxes/radios
|
||||
let exportValue: string | undefined;
|
||||
const ap = wDict.lookup(PDFName.of('AP'));
|
||||
if (ap instanceof PDFDict) {
|
||||
const normal = ap.lookup(PDFName.of('N'));
|
||||
if (normal instanceof PDFDict) {
|
||||
// The keys of /N (other than /Off) are the export values.
|
||||
// PDFDict.entries() reliably returns [PDFName, PDFObject][] in
|
||||
// @cantoo/pdf-lib — no optional chaining needed.
|
||||
try {
|
||||
const entries = normal.entries();
|
||||
const keys = entries
|
||||
.map(([k]) => k.decodeText())
|
||||
.filter((k) => k !== 'Off');
|
||||
if (keys.length > 0) exportValue = keys[0];
|
||||
} catch {
|
||||
// Malformed AP dict — skip export value extraction
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check /AS for current appearance state
|
||||
if (!exportValue) {
|
||||
const asEntry = wDict.lookup(PDFName.of('AS'));
|
||||
if (asEntry instanceof PDFName) {
|
||||
const asVal = asEntry.decodeText();
|
||||
if (asVal !== 'Off') exportValue = asVal;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract font size from default appearance string
|
||||
let fontSize: number | undefined;
|
||||
const da = wDict.lookup(PDFName.of('DA'));
|
||||
if (da) {
|
||||
const daStr = da.toString();
|
||||
const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/);
|
||||
if (tfMatch) {
|
||||
fontSize = parseFloat(tfMatch[1]);
|
||||
if (fontSize === 0) fontSize = undefined; // 0 means auto-size
|
||||
}
|
||||
}
|
||||
|
||||
widgets.push({
|
||||
pageIndex,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalW,
|
||||
height: finalH,
|
||||
exportValue,
|
||||
fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function numberVal(obj: any): number {
|
||||
if (obj instanceof PDFNumber) return obj.asNumber();
|
||||
if (typeof obj === 'number') return obj;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CropBox (or MediaBox fallback) dimensions in un-rotated PDF space.
|
||||
* These are the raw dictionary values without any rotation adjustment.
|
||||
*/
|
||||
function getCropBox(page: PDFPage): { x: number; y: number; width: number; height: number } {
|
||||
// Check direct CropBox entry
|
||||
const cropBox = page.node.lookup(PDFName.of('CropBox'));
|
||||
if (cropBox instanceof PDFArray && cropBox.size() >= 4) {
|
||||
return {
|
||||
x: numberVal(cropBox.lookup(0)),
|
||||
y: numberVal(cropBox.lookup(1)),
|
||||
width: numberVal(cropBox.lookup(2)) - numberVal(cropBox.lookup(0)),
|
||||
height: numberVal(cropBox.lookup(3)) - numberVal(cropBox.lookup(1)),
|
||||
};
|
||||
}
|
||||
// Check direct MediaBox entry
|
||||
const mediaBox = page.node.lookup(PDFName.of('MediaBox'));
|
||||
if (mediaBox instanceof PDFArray && mediaBox.size() >= 4) {
|
||||
return {
|
||||
x: numberVal(mediaBox.lookup(0)),
|
||||
y: numberVal(mediaBox.lookup(1)),
|
||||
width: numberVal(mediaBox.lookup(2)) - numberVal(mediaBox.lookup(0)),
|
||||
height: numberVal(mediaBox.lookup(3)) - numberVal(mediaBox.lookup(1)),
|
||||
};
|
||||
}
|
||||
// Traverse parent page-tree nodes for inherited MediaBox
|
||||
let node: any = page.node;
|
||||
while (node) {
|
||||
const parentNode = node.lookup(PDFName.of('Parent'));
|
||||
if (parentNode instanceof PDFDict) {
|
||||
const inheritedBox = parentNode.lookup(PDFName.of('MediaBox'));
|
||||
if (inheritedBox instanceof PDFArray && inheritedBox.size() >= 4) {
|
||||
return {
|
||||
x: numberVal(inheritedBox.lookup(0)),
|
||||
y: numberVal(inheritedBox.lookup(1)),
|
||||
width: numberVal(inheritedBox.lookup(2)) - numberVal(inheritedBox.lookup(0)),
|
||||
height: numberVal(inheritedBox.lookup(3)) - numberVal(inheritedBox.lookup(1)),
|
||||
};
|
||||
}
|
||||
node = parentNode;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Last resort: use page.getSize() but un-rotate the dimensions
|
||||
const { width, height } = page.getSize();
|
||||
const rotation = getPageRotation(page);
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
return { x: 0, y: 0, width: height, height: width };
|
||||
}
|
||||
return { x: 0, y: 0, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget annotation dictionaries for a field.
|
||||
* A field can either BE a widget (merged) or have child /Kids that are widgets.
|
||||
*/
|
||||
function getFieldWidgets(acroField: PDFDict): PDFDict[] {
|
||||
const kids = acroField.lookup(PDFName.of('Kids'));
|
||||
if (kids instanceof PDFArray) {
|
||||
const result: PDFDict[] = [];
|
||||
for (let i = 0; i < kids.size(); i++) {
|
||||
const kid = kids.lookup(i);
|
||||
if (kid instanceof PDFDict) {
|
||||
// Check if this kid is a widget (has /Rect) vs another field node
|
||||
const subtype = kid.lookup(PDFName.of('Subtype'));
|
||||
if (subtype instanceof PDFName && subtype.decodeText() === 'Widget') {
|
||||
result.push(kid);
|
||||
} else if (kid.lookup(PDFName.of('Rect'))) {
|
||||
// Merged field/widget — has Rect but maybe no explicit Subtype
|
||||
result.push(kid);
|
||||
} else {
|
||||
// Intermediate field node — recurse
|
||||
result.push(...getFieldWidgets(kid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// No Kids — the field dict itself is the widget (merged field/widget)
|
||||
if (acroField.lookup(PDFName.of('Rect'))) {
|
||||
return [acroField];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the FormFieldType from a pdf-lib PDFField.
|
||||
*/
|
||||
function getFieldType(field: PDFField): FormFieldType {
|
||||
if (field instanceof PDFTextField) return 'text';
|
||||
if (field instanceof PDFCheckBox) return 'checkbox';
|
||||
if (field instanceof PDFDropdown) return 'combobox';
|
||||
if (field instanceof PDFRadioGroup) return 'radio';
|
||||
if (field instanceof PDFOptionList) return 'listbox';
|
||||
if (field instanceof PDFButton) return 'button';
|
||||
if (field instanceof PDFSignature) return 'signature';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of a field as a string.
|
||||
*/
|
||||
function getFieldValue(field: PDFField): string {
|
||||
try {
|
||||
if (field instanceof PDFTextField) {
|
||||
return field.getText() ?? '';
|
||||
}
|
||||
if (field instanceof PDFCheckBox) {
|
||||
return field.isChecked() ? 'Yes' : 'Off';
|
||||
}
|
||||
if (field instanceof PDFDropdown) {
|
||||
const selected = field.getSelected();
|
||||
return selected.length > 0 ? selected[0] : '';
|
||||
}
|
||||
if (field instanceof PDFRadioGroup) {
|
||||
return getRadioValue(field);
|
||||
}
|
||||
if (field instanceof PDFOptionList) {
|
||||
const selected = field.getSelected();
|
||||
return selected.join(',');
|
||||
}
|
||||
} catch {
|
||||
// Some fields may throw on getValue if malformed
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRadioValue(field: PDFRadioGroup): string {
|
||||
const selected = field.getSelected() ?? '';
|
||||
if (!selected || selected === 'Off') return selected;
|
||||
|
||||
const options = field.getOptions();
|
||||
|
||||
if (options.includes(selected)) return selected;
|
||||
|
||||
const mappedOption = mapAppearanceStateToOption(field, selected, options);
|
||||
if (mappedOption) return mappedOption;
|
||||
|
||||
const index = parseInt(selected, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < options.length) {
|
||||
return options[index];
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
function mapAppearanceStateToOption(
|
||||
field: PDFRadioGroup,
|
||||
stateName: string,
|
||||
options: string[],
|
||||
): string | undefined {
|
||||
try {
|
||||
const acroFieldDict = (field.acroField as any).dict as PDFDict;
|
||||
const widgets = getFieldWidgets(acroFieldDict);
|
||||
|
||||
for (let i = 0; i < widgets.length; i++) {
|
||||
const ap = widgets[i].lookup(PDFName.of('AP'));
|
||||
if (!(ap instanceof PDFDict)) continue;
|
||||
|
||||
const normal = ap.lookup(PDFName.of('N'));
|
||||
if (!(normal instanceof PDFDict)) continue;
|
||||
|
||||
let keys: string[] = [];
|
||||
try {
|
||||
keys = normal.entries().map(([k]) => k.decodeText());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (keys.includes(stateName) && i < options.length) {
|
||||
return options[i];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback handled by caller
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRadioValueForSelect(
|
||||
field: PDFRadioGroup,
|
||||
value: string,
|
||||
): string | null {
|
||||
const options = field.getOptions();
|
||||
|
||||
if (options.includes(value)) return value;
|
||||
|
||||
const mappedOption = mapAppearanceStateToOption(field, value, options);
|
||||
if (mappedOption) return mappedOption;
|
||||
|
||||
const index = parseInt(value, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < options.length) {
|
||||
return options[index];
|
||||
}
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
const match = options.find((o: string) => o.toLowerCase() === lower);
|
||||
if (match) return match;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field options (for dropdowns, listboxes, radios).
|
||||
*/
|
||||
function getFieldOptions(field: PDFField): string[] | null {
|
||||
try {
|
||||
if (field instanceof PDFDropdown) {
|
||||
return field.getOptions();
|
||||
}
|
||||
if (field instanceof PDFOptionList) {
|
||||
return field.getOptions();
|
||||
}
|
||||
if (field instanceof PDFRadioGroup) {
|
||||
return field.getOptions();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract display labels from the /Opt array if it contains [export, display]
|
||||
* pairs. PDF spec §12.7.4.4: each element of /Opt may be either a text
|
||||
* string (export value == display value) or a two-element array where the
|
||||
* first element is the export value and the second is the display text.
|
||||
*
|
||||
* Returns null when every display value equals its export value (no distinct
|
||||
* display labels exist), keeping the interface lean for the common case.
|
||||
*/
|
||||
function getFieldDisplayOptions(field: PDFField): string[] | null {
|
||||
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const acroDict = (field.acroField as any).dict as PDFDict;
|
||||
const optRaw = acroDict.lookup(PDFName.of('Opt'));
|
||||
if (!(optRaw instanceof PDFArray)) return null;
|
||||
|
||||
const displays: string[] = [];
|
||||
let hasDifference = false;
|
||||
|
||||
for (let i = 0; i < optRaw.size(); i++) {
|
||||
try {
|
||||
const entry = optRaw.lookup(i);
|
||||
|
||||
if (entry instanceof PDFArray && entry.size() >= 2) {
|
||||
// [exportValue, displayValue] pair
|
||||
const exp = decodeText(entry.lookup(0));
|
||||
const disp = decodeText(entry.lookup(1));
|
||||
displays.push(disp);
|
||||
if (exp !== disp) hasDifference = true;
|
||||
} else {
|
||||
// Plain string — export and display are the same
|
||||
const val = decodeText(entry);
|
||||
displays.push(val);
|
||||
}
|
||||
} catch {
|
||||
// Malformed /Opt entry — skip but continue processing remaining entries
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (displays.length === 0) return null;
|
||||
return hasDifference ? displays : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a PDFString, PDFHexString, or PDFName to a JS string. */
|
||||
function decodeText(obj: unknown): string {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString) {
|
||||
return obj.decodeText();
|
||||
}
|
||||
if (obj instanceof PDFName) {
|
||||
return obj.decodeText();
|
||||
}
|
||||
if (typeof obj === 'string') return obj;
|
||||
return String(obj ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field is read-only.
|
||||
*/
|
||||
function isFieldReadOnly(field: PDFField): boolean {
|
||||
try {
|
||||
return field.isReadOnly();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field is required.
|
||||
*/
|
||||
function isFieldRequired(field: PDFField): boolean {
|
||||
try {
|
||||
return field.isRequired();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field tooltip (TU entry).
|
||||
* Uses proper PDFString/PDFHexString decoding for correct Unicode support.
|
||||
*/
|
||||
function getFieldTooltip(acroField: PDFDict): string | null {
|
||||
const tu = acroField.lookup(PDFName.of('TU'));
|
||||
if (!tu) return null;
|
||||
|
||||
try {
|
||||
// Prefer decodeText() for proper Unicode handling (UTF-16BE / PDFDocEncoding)
|
||||
if (tu instanceof PDFString || tu instanceof PDFHexString) {
|
||||
return tu.decodeText();
|
||||
}
|
||||
// Fallback: strip parentheses from raw toString() for other object types
|
||||
return tu.toString().replace(/^\(|\)$/g, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text field is multiline (flag bit 13 set in /Ff).
|
||||
*/
|
||||
function isMultiline(field: PDFField): boolean {
|
||||
if (!(field instanceof PDFTextField)) return false;
|
||||
try {
|
||||
return field.isMultiline();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label for a field — use the partial name or the full qualified name.
|
||||
*/
|
||||
function getFieldLabel(field: PDFField): string {
|
||||
const name = field.getName();
|
||||
// Use the last segment of the qualified name as the label
|
||||
const parts = name.split('.');
|
||||
return parts[parts.length - 1] || name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts only signed signature fields (those with an /AP/N stream) from a PDF,
|
||||
* renders their appearances via PDF.js, and returns them as FormField objects.
|
||||
*
|
||||
* Used by FormFillContext to inject signature overlays when the pdfbox provider
|
||||
* is active (fill form tool), where the backend doesn't return signature fields.
|
||||
*/
|
||||
export async function fetchSignatureFieldsWithAppearances(file: File | Blob): Promise<FormField[]> {
|
||||
const arrayBuffer = await readAsArrayBuffer(file);
|
||||
let doc: PDFDocument;
|
||||
try {
|
||||
doc = await PDFDocument.load(arrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
} catch { return []; }
|
||||
|
||||
let form: PDFForm;
|
||||
try { form = doc.getForm(); } catch { return []; }
|
||||
|
||||
let pdfFields: PDFField[];
|
||||
try { pdfFields = form.getFields(); } catch { return []; }
|
||||
|
||||
let pages: PDFPage[];
|
||||
try { pages = doc.getPages(); } catch { return []; }
|
||||
|
||||
const result: FormField[] = [];
|
||||
|
||||
for (const field of pdfFields) {
|
||||
if (!(field instanceof PDFSignature)) continue;
|
||||
if (!signatureHasAppearance(field)) continue;
|
||||
|
||||
const widgets = extractWidgets(field, pages, doc);
|
||||
if (widgets.length === 0) continue;
|
||||
|
||||
result.push({
|
||||
name: field.getName(),
|
||||
label: getFieldLabel(field),
|
||||
type: 'signature',
|
||||
value: '',
|
||||
options: null,
|
||||
displayOptions: null,
|
||||
required: false,
|
||||
readOnly: true,
|
||||
multiSelect: false,
|
||||
multiline: false,
|
||||
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
|
||||
widgets,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.length > 0) {
|
||||
await attachSignatureAppearances(result, arrayBuffer);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the signature field has at least one widget with a normal
|
||||
* (/AP/N) appearance stream — i.e. the signature has actually been signed.
|
||||
*/
|
||||
function signatureHasAppearance(field: PDFField): boolean {
|
||||
if (!(field instanceof PDFSignature)) return false;
|
||||
try {
|
||||
const acroDict = (field.acroField as any).dict as PDFDict;
|
||||
const widgets = getFieldWidgets(acroDict);
|
||||
for (const wDict of widgets) {
|
||||
const ap = wDict.lookup(PDFName.of('AP'));
|
||||
if (ap instanceof PDFDict) {
|
||||
const n = ap.lookup(PDFName.of('N'));
|
||||
if (n instanceof PDFStream) return true;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed fields */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each signature FormField that has an /AP/N stream, renders the
|
||||
* containing page via PDF.js and crops out the widget rectangle,
|
||||
* attaching the result as field.appearanceDataUrl.
|
||||
*
|
||||
* Uses the existing pdfWorkerManager for proper worker lifecycle management.
|
||||
*/
|
||||
async function attachSignatureAppearances(
|
||||
signatureFields: FormField[],
|
||||
arrayBuffer: ArrayBuffer,
|
||||
): Promise<void> {
|
||||
if (signatureFields.length === 0) return;
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
try {
|
||||
// Slice so pdf-lib's retained references to the original buffer are unaffected
|
||||
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer.slice(0));
|
||||
|
||||
// Group fields by the pageIndex of their first widget
|
||||
const byPage = new Map<number, FormField[]>();
|
||||
for (const field of signatureFields) {
|
||||
for (const w of field.widgets ?? []) {
|
||||
const arr = byPage.get(w.pageIndex) ?? [];
|
||||
arr.push(field);
|
||||
byPage.set(w.pageIndex, arr);
|
||||
break; // first widget identifies the page
|
||||
}
|
||||
}
|
||||
|
||||
const RENDER_SCALE = 2; // 2× for crisp appearance
|
||||
|
||||
for (const [pageIndex, fields] of byPage) {
|
||||
const page = await pdfDoc.getPage(pageIndex + 1); // PDF.js is 1-indexed
|
||||
const viewport = page.getViewport({ scale: RENDER_SCALE });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
|
||||
for (const field of fields) {
|
||||
for (const widget of field.widgets ?? []) {
|
||||
if (widget.pageIndex !== pageIndex) continue;
|
||||
|
||||
// widget.x/y are PDF points, CSS upper-left origin (relative to CropBox).
|
||||
// PDF.js renders the CropBox starting at canvas (0,0), so multiplying by
|
||||
// RENDER_SCALE gives the correct canvas pixel coordinates for the crop.
|
||||
const cx = Math.round(widget.x * RENDER_SCALE);
|
||||
const cy = Math.round(widget.y * RENDER_SCALE);
|
||||
const cw = Math.max(1, Math.round(widget.width * RENDER_SCALE));
|
||||
const ch = Math.max(1, Math.round(widget.height * RENDER_SCALE));
|
||||
|
||||
const crop = document.createElement('canvas');
|
||||
crop.width = cw;
|
||||
crop.height = ch;
|
||||
const cropCtx = crop.getContext('2d');
|
||||
if (!cropCtx) continue;
|
||||
|
||||
cropCtx.drawImage(canvas, cx, cy, cw, ch, 0, 0, cw, ch);
|
||||
field.appearanceDataUrl = crop.toDataURL('image/png');
|
||||
break; // first widget is representative
|
||||
}
|
||||
}
|
||||
|
||||
page.cleanup();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfLibFormProvider] Failed to extract signature appearances:', e);
|
||||
} finally {
|
||||
if (pdfDoc) pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
}
|
||||
}
|
||||
|
||||
export class PdfLibFormProvider implements IFormDataProvider {
|
||||
readonly name = 'pdf-lib';
|
||||
|
||||
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||
const arrayBuffer = await readAsArrayBuffer(file);
|
||||
let doc: PDFDocument;
|
||||
try {
|
||||
doc = await PDFDocument.load(arrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
} catch (loadError) {
|
||||
console.warn('[PdfLibFormProvider] Failed to load PDF document:', loadError);
|
||||
return [];
|
||||
}
|
||||
|
||||
let form: PDFForm;
|
||||
try {
|
||||
form = doc.getForm();
|
||||
} catch (formError) {
|
||||
// No AcroForm or broken catalog — return empty
|
||||
console.warn('[PdfLibFormProvider] Failed to access AcroForm:', formError);
|
||||
return [];
|
||||
}
|
||||
|
||||
let fields: PDFField[];
|
||||
try {
|
||||
fields = form.getFields();
|
||||
} catch (fieldsError) {
|
||||
console.warn('[PdfLibFormProvider] Failed to enumerate form fields:', fieldsError);
|
||||
return [];
|
||||
}
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
let pages: PDFPage[];
|
||||
try {
|
||||
pages = doc.getPages();
|
||||
} catch (pagesError) {
|
||||
// Pages tree is invalid (same issue as usePdfLibLinks "invalid catalog").
|
||||
// Without page references we can't place widgets, so return empty.
|
||||
// The viewer will fall back to native form rendering via withForms.
|
||||
console.warn(
|
||||
'[PdfLibFormProvider] PDF pages tree is invalid — cannot place form widgets.',
|
||||
'Native form rendering will be used as fallback.',
|
||||
pagesError,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: FormField[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldName = field.getName();
|
||||
try {
|
||||
const type = getFieldType(field);
|
||||
const widgets = extractWidgets(field, pages, doc);
|
||||
|
||||
// Skip fields with no visible widgets
|
||||
if (widgets.length === 0) continue;
|
||||
|
||||
const formField: FormField = {
|
||||
name: field.getName(),
|
||||
label: getFieldLabel(field),
|
||||
type,
|
||||
value: getFieldValue(field),
|
||||
options: getFieldOptions(field),
|
||||
displayOptions: getFieldDisplayOptions(field),
|
||||
required: isFieldRequired(field),
|
||||
readOnly: isFieldReadOnly(field),
|
||||
multiSelect: field instanceof PDFOptionList,
|
||||
multiline: isMultiline(field),
|
||||
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
|
||||
widgets,
|
||||
};
|
||||
|
||||
result.push(formField);
|
||||
} catch (fieldError) {
|
||||
// Skip individual malformed fields but continue processing
|
||||
console.warn(`[PdfLibFormProvider] Skipping field "${fieldName}":`, fieldError);
|
||||
}
|
||||
}
|
||||
|
||||
// Render appearance streams for signed signature fields so the overlay
|
||||
// can display them as images instead of placeholder boxes.
|
||||
const sigFieldsWithAp = result.filter(
|
||||
f => f.type === 'signature' && signatureHasAppearance(form.getField(f.name)),
|
||||
);
|
||||
if (sigFieldsWithAp.length > 0) {
|
||||
await attachSignatureAppearances(sigFieldsWithAp, arrayBuffer);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await readAsArrayBuffer(file);
|
||||
const doc = await PDFDocument.load(arrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
const form = doc.getForm();
|
||||
const fields = form.getFields();
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldName = field.getName();
|
||||
if (!(fieldName in values)) continue;
|
||||
|
||||
const value = values[fieldName];
|
||||
|
||||
try {
|
||||
if (field instanceof PDFTextField) {
|
||||
field.setText(value || undefined);
|
||||
} else if (field instanceof PDFCheckBox) {
|
||||
if (value && value !== 'Off') {
|
||||
field.check();
|
||||
} else {
|
||||
field.uncheck();
|
||||
}
|
||||
} else if (field instanceof PDFDropdown) {
|
||||
if (value) {
|
||||
field.select(value);
|
||||
} else {
|
||||
field.clear();
|
||||
}
|
||||
} else if (field instanceof PDFRadioGroup) {
|
||||
if (value && value !== 'Off') {
|
||||
const resolved = resolveRadioValueForSelect(field, value);
|
||||
if (resolved) {
|
||||
field.select(resolved);
|
||||
} else {
|
||||
console.warn(
|
||||
`[PdfLibFormProvider] Radio value "${value}" could not be mapped to options [${field.getOptions().join(', ')}] for field "${fieldName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (field instanceof PDFOptionList) {
|
||||
if (value) {
|
||||
const vals = value.split(',').filter(Boolean);
|
||||
field.select(vals[0]); // PDFOptionList.select takes single value
|
||||
} else {
|
||||
field.clear();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[PdfLibFormProvider] Failed to set value for field "${fieldName}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (flatten) {
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
return new Blob([pdfBytes.slice().buffer as ArrayBuffer], { type: 'application/pdf' });
|
||||
}
|
||||
}
|
||||
599
frontend/src/core/tools/formFill/providers/PdfiumFormProvider.ts
Normal file
599
frontend/src/core/tools/formFill/providers/PdfiumFormProvider.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* PdfiumFormProvider Frontend-only form data provider using PDFium WASM.
|
||||
*
|
||||
* Replaces the old pdf-lib based PdfLibFormProvider. Extracts form fields
|
||||
* directly from the PDF byte stream via @embedpdf/pdfium WASM and fills
|
||||
* them without any backend calls.
|
||||
*
|
||||
* Used in normal viewer mode when the user views a PDF with form fields.
|
||||
*
|
||||
* Coordinate system:
|
||||
* PDFium provides widget rectangles in PDF user space (lower-left origin).
|
||||
* We transform them to CSS space (top-left origin) matching what the backend
|
||||
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
|
||||
* for both providers.
|
||||
*/
|
||||
import { PDF_FORM_FIELD_TYPE } from '@app/services/pdfiumService';
|
||||
import { FPDF_ANNOT_WIDGET, FLAT_PRINT } from '@app/utils/pdfiumBitmapUtils';
|
||||
import type { FormField, FormFieldType, WidgetCoordinates, ButtonAction } from '@app/tools/formFill/types';
|
||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
import {
|
||||
closeDocAndFreeBuffer,
|
||||
extractFormFields,
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
readUtf16,
|
||||
saveRawDocument,
|
||||
type PdfiumFormField,
|
||||
} from '@app/services/pdfiumService';
|
||||
|
||||
/**
|
||||
* Map PDFium form field type enum to our FormFieldType string.
|
||||
*/
|
||||
function mapFieldType(t: PDF_FORM_FIELD_TYPE): FormFieldType {
|
||||
switch (t) {
|
||||
case PDF_FORM_FIELD_TYPE.TEXTFIELD:
|
||||
return 'text';
|
||||
case PDF_FORM_FIELD_TYPE.CHECKBOX:
|
||||
return 'checkbox';
|
||||
case PDF_FORM_FIELD_TYPE.COMBOBOX:
|
||||
return 'combobox';
|
||||
case PDF_FORM_FIELD_TYPE.RADIOBUTTON:
|
||||
return 'radio';
|
||||
case PDF_FORM_FIELD_TYPE.LISTBOX:
|
||||
return 'listbox';
|
||||
case PDF_FORM_FIELD_TYPE.PUSHBUTTON:
|
||||
return 'button';
|
||||
case PDF_FORM_FIELD_TYPE.SIGNATURE:
|
||||
return 'signature';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PdfiumFormField (from pdfiumService) to the UI FormField shape.
|
||||
* @param optInfo When provided, overrides options/displayOptions for combo/listbox fields.
|
||||
* @param buttonInfo When provided, sets buttonLabel and buttonAction for push buttons.
|
||||
*/
|
||||
function toFormField(
|
||||
f: PdfiumFormField & { _tooltip?: string | null },
|
||||
optInfo?: { exportValues: string[]; displayValues: string[] } | null,
|
||||
buttonInfo?: { label?: string; action?: ButtonAction } | null,
|
||||
): FormField {
|
||||
const type = mapFieldType(f.type);
|
||||
const optionLabels = f.options.map((o) => o.label);
|
||||
|
||||
// Build WidgetCoordinates from the PDFium widget rects
|
||||
const widgets: WidgetCoordinates[] = f.widgets.map((w) => ({
|
||||
pageIndex: w.pageIndex,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
exportValue: w.exportValue,
|
||||
fontSize: w.fontSize,
|
||||
}));
|
||||
|
||||
// Derive value string
|
||||
let value = f.value;
|
||||
if (type === 'checkbox') {
|
||||
value = f.isChecked ? 'Yes' : 'Off';
|
||||
} else if (type === 'radio') {
|
||||
// Use widget index as the canonical radio value.
|
||||
// This avoids issues with duplicate exportValues across widgets
|
||||
// (e.g., all widgets having exportValue "Yes").
|
||||
value = '';
|
||||
for (let i = 0; i < f.widgets.length; i++) {
|
||||
if (f.widgets[i].isChecked) {
|
||||
value = String(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use pdf-lib extracted export/display values when available
|
||||
let options: string[] | null = optionLabels.length > 0 ? optionLabels : null;
|
||||
let displayOptions: string[] | null = null;
|
||||
if (optInfo && optInfo.exportValues.length > 0) {
|
||||
options = optInfo.exportValues;
|
||||
displayOptions = optInfo.displayValues;
|
||||
}
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
label: f.name.split('.').pop() || f.name,
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
displayOptions,
|
||||
required: f.isRequired,
|
||||
readOnly: f.isReadOnly,
|
||||
multiSelect: f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
|
||||
multiline: type === 'text' && (f.flags & 0x1000) !== 0, // bit 13 = Multiline
|
||||
tooltip: f._tooltip ?? null,
|
||||
widgets,
|
||||
buttonLabel: buttonInfo?.label ?? null,
|
||||
buttonAction: buttonInfo?.action ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PdfLibFormProvider — now backed by PDFium WASM.
|
||||
*
|
||||
* The class name is kept for backwards-compatibility with existing imports.
|
||||
* Internally everything goes through @embedpdf/pdfium.
|
||||
*/
|
||||
export class PdfiumFormProvider implements IFormDataProvider {
|
||||
/** Provider identifier — kept as 'pdf-lib' for backwards-compatibility. */
|
||||
readonly name = 'pdf-lib';
|
||||
|
||||
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfiumFields = await extractFormFields(arrayBuffer);
|
||||
|
||||
// Enrich with alternate names (tooltips)
|
||||
await this.enrichWithAlternateNames(arrayBuffer, pdfiumFields);
|
||||
|
||||
// Enrich combo/listbox fields with export/display values from pdf-lib
|
||||
const optMap = await this.extractDisplayOptions(arrayBuffer, pdfiumFields);
|
||||
|
||||
// Enrich push buttons with label (/MK/CA) and action (/A) from pdf-lib
|
||||
const buttonInfoMap = await this.extractButtonInfo(arrayBuffer, pdfiumFields);
|
||||
|
||||
return pdfiumFields
|
||||
.filter((f) => f.widgets.length > 0)
|
||||
.map((f) => toFormField(f, optMap.get(f.name) ?? null, buttonInfoMap.get(f.name) ?? null));
|
||||
} catch (err) {
|
||||
console.warn('[PdfiumFormProvider] Failed to extract form fields:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich fields with alternate names (tooltip / TU entry) via PDFium.
|
||||
*/
|
||||
private async enrichWithAlternateNames(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data);
|
||||
try {
|
||||
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
|
||||
if (!formEnvPtr) return;
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
const nameToField = new Map(fields.map((f) => [f.name, f]));
|
||||
const enriched = new Set<string>();
|
||||
|
||||
for (let pageIdx = 0; pageIdx < pageCount && enriched.size < nameToField.size; pageIdx++) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
if (!pagePtr) continue;
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (let ai = 0; ai < annotCount && enriched.size < nameToField.size; ai++) {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
|
||||
if (!annotPtr) continue;
|
||||
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let name = '';
|
||||
if (nl > 0) {
|
||||
const nb = m.pdfium.wasmExports.malloc(nl);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
|
||||
name = readUtf16(m, nb, nl);
|
||||
m.pdfium.wasmExports.free(nb);
|
||||
}
|
||||
|
||||
if (name && nameToField.has(name) && !enriched.has(name)) {
|
||||
const altLen = m.FPDFAnnot_GetFormFieldAlternateName(formEnvPtr, annotPtr, 0, 0);
|
||||
if (altLen > 0) {
|
||||
const altBuf = m.pdfium.wasmExports.malloc(altLen);
|
||||
m.FPDFAnnot_GetFormFieldAlternateName(formEnvPtr, annotPtr, altBuf, altLen);
|
||||
const altName = readUtf16(m, altBuf, altLen);
|
||||
m.pdfium.wasmExports.free(altBuf);
|
||||
(nameToField.get(name) as any)._tooltip = altName || null;
|
||||
}
|
||||
enriched.add(name);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfiumFormProvider] Failed to enrich alternate names:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pdf-lib to read /Opt arrays for combo/listbox fields.
|
||||
* Returns a map of fieldName → { exportValues, displayValues }.
|
||||
* PDFium only exposes display labels; pdf-lib can read the raw /Opt entries
|
||||
* to separate [export, display] pairs.
|
||||
*/
|
||||
private async extractDisplayOptions(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<Map<string, { exportValues: string[]; displayValues: string[] }>> {
|
||||
const result = new Map<string, { exportValues: string[]; displayValues: string[] }>();
|
||||
const comboOrList = fields.filter(
|
||||
(f) => f.type === PDF_FORM_FIELD_TYPE.COMBOBOX || f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
|
||||
);
|
||||
if (comboOrList.length === 0) return result;
|
||||
|
||||
try {
|
||||
const { PDFDocument, PDFName, PDFArray, PDFString, PDFHexString, PDFDropdown, PDFOptionList } =
|
||||
await import('@cantoo/pdf-lib');
|
||||
const doc = await PDFDocument.load(data, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
const form = doc.getForm();
|
||||
|
||||
const decodeText = (obj: unknown): string => {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
|
||||
return String(obj ?? '');
|
||||
};
|
||||
|
||||
for (const pf of comboOrList) {
|
||||
try {
|
||||
const field = form.getField(pf.name);
|
||||
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) continue;
|
||||
|
||||
const acroDict = (field.acroField as any).dict;
|
||||
const optRaw = acroDict.lookup(PDFName.of('Opt'));
|
||||
if (!(optRaw instanceof PDFArray)) continue;
|
||||
|
||||
const exportValues: string[] = [];
|
||||
const displayValues: string[] = [];
|
||||
let hasDifference = false;
|
||||
|
||||
for (let i = 0; i < optRaw.size(); i++) {
|
||||
try {
|
||||
const entry = optRaw.lookup(i);
|
||||
if (entry instanceof PDFArray && entry.size() >= 2) {
|
||||
const exp = decodeText(entry.lookup(0));
|
||||
const disp = decodeText(entry.lookup(1));
|
||||
exportValues.push(exp);
|
||||
displayValues.push(disp);
|
||||
if (exp !== disp) hasDifference = true;
|
||||
} else {
|
||||
const val = decodeText(entry);
|
||||
exportValues.push(val);
|
||||
displayValues.push(val);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (exportValues.length > 0) {
|
||||
result.set(pf.name, {
|
||||
exportValues,
|
||||
displayValues: hasDifference ? displayValues : exportValues,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip individual field errors
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfiumFormProvider] Failed to extract display options:', e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pdf-lib to extract push button labels (/MK/CA) and actions (/A) for each button field.
|
||||
* Returns a map of fieldName → { label?, action? }.
|
||||
*/
|
||||
private async extractButtonInfo(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<Map<string, { label?: string; action?: ButtonAction }>> {
|
||||
const result = new Map<string, { label?: string; action?: ButtonAction }>();
|
||||
const buttons = fields.filter((f) => f.type === PDF_FORM_FIELD_TYPE.PUSHBUTTON);
|
||||
if (buttons.length === 0) return result;
|
||||
|
||||
try {
|
||||
const { PDFDocument, PDFName, PDFString, PDFHexString, PDFDict } =
|
||||
await import('@cantoo/pdf-lib');
|
||||
|
||||
const doc = await PDFDocument.load(data, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
const form = doc.getForm();
|
||||
|
||||
const decodeText = (obj: unknown): string | null => {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
|
||||
if (obj instanceof PDFName) return (obj as any).asString?.() ?? obj.toString().replace(/^\//, '');
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseActionDict = (aObj: unknown): ButtonAction | null => {
|
||||
if (!(aObj instanceof PDFDict)) return null;
|
||||
const sObj = aObj.lookup(PDFName.of('S'));
|
||||
if (!(sObj instanceof PDFName)) return null;
|
||||
const actionType: string = (sObj as any).asString?.() ?? sObj.toString().replace(/^\//, '');
|
||||
|
||||
switch (actionType) {
|
||||
case 'Named': {
|
||||
const nObj = aObj.lookup(PDFName.of('N'));
|
||||
const name = nObj instanceof PDFName
|
||||
? ((nObj as any).asString?.() ?? nObj.toString().replace(/^\//, ''))
|
||||
: '';
|
||||
return { type: 'named', namedAction: name };
|
||||
}
|
||||
case 'JavaScript': {
|
||||
const jsObj = aObj.lookup(PDFName.of('JS'));
|
||||
const js = decodeText(jsObj) ?? jsObj?.toString() ?? '';
|
||||
return { type: 'javascript', javascript: js };
|
||||
}
|
||||
case 'SubmitForm': {
|
||||
const fObj = aObj.lookup(PDFName.of('F'));
|
||||
let url = '';
|
||||
if (fObj instanceof PDFDict) {
|
||||
url = decodeText(fObj.lookup(PDFName.of('F'))) ?? '';
|
||||
} else if (fObj) {
|
||||
url = decodeText(fObj) ?? fObj.toString();
|
||||
}
|
||||
const flagsObj = aObj.lookup(PDFName.of('Flags'));
|
||||
const flags = typeof (flagsObj as any)?.asNumber === 'function' ? (flagsObj as any).asNumber() : 0;
|
||||
return { type: 'submitForm', url, submitFlags: flags };
|
||||
}
|
||||
case 'ResetForm':
|
||||
return { type: 'resetForm' };
|
||||
case 'URI': {
|
||||
const uriObj = aObj.lookup(PDFName.of('URI'));
|
||||
return { type: 'uri', url: decodeText(uriObj) ?? '' };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMkCaption = (dict: any): string | null => {
|
||||
try {
|
||||
const mkObj = dict.lookup(PDFName.of('MK'));
|
||||
if (!(mkObj instanceof PDFDict)) return null;
|
||||
const caObj = mkObj.lookup(PDFName.of('CA'));
|
||||
return decodeText(caObj);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionFromDict = (dict: any): ButtonAction | null => {
|
||||
try {
|
||||
return parseActionDict(dict.lookup(PDFName.of('A')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buttonNames = new Set(buttons.map((b) => b.name));
|
||||
|
||||
for (const field of form.getFields()) {
|
||||
const name = field.getName();
|
||||
if (!buttonNames.has(name)) continue;
|
||||
|
||||
try {
|
||||
const acroField = (field as any).acroField;
|
||||
if (!acroField?.dict) continue;
|
||||
|
||||
const info: { label?: string; action?: ButtonAction } = {};
|
||||
|
||||
// Try widget dicts first (each widget can have its own /MK and /A)
|
||||
const widgets: any[] = (acroField as any).getWidgets?.() ?? [];
|
||||
for (const widget of widgets) {
|
||||
if (!info.label) {
|
||||
const label = getMkCaption(widget.dict);
|
||||
if (label) info.label = label;
|
||||
}
|
||||
if (!info.action) {
|
||||
const action = getActionFromDict(widget.dict);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
if (info.label && info.action) break;
|
||||
}
|
||||
|
||||
// Fall back to field-level dict
|
||||
if (!info.label) {
|
||||
const label = getMkCaption(acroField.dict);
|
||||
if (label) info.label = label;
|
||||
}
|
||||
if (!info.action) {
|
||||
const action = getActionFromDict(acroField.dict);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
|
||||
// Also check /AA (Additional Actions) → /U (Mouse Up) if no /A found
|
||||
if (!info.action) {
|
||||
try {
|
||||
const aaObj = acroField.dict.lookup(PDFName.of('AA'));
|
||||
if (aaObj instanceof PDFDict) {
|
||||
const uObj = aaObj.lookup(PDFName.of('U'));
|
||||
const action = parseActionDict(uObj);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
if (info.label || info.action) {
|
||||
result.set(name, info);
|
||||
}
|
||||
} catch { /* skip individual field errors */ }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PdfiumFormProvider] Failed to extract button info:', e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(arrayBuffer);
|
||||
|
||||
try {
|
||||
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
|
||||
if (!formEnvPtr) {
|
||||
throw new Error('PDFium: failed to initialise form environment');
|
||||
}
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
|
||||
// Track radio widget index per field for index-based matching.
|
||||
// The UI stores radio values as widget indices (e.g., "0", "1", "2").
|
||||
const radioWidgetIdx = new Map<string, number>();
|
||||
|
||||
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
if (!pagePtr) continue;
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (let ai = 0; ai < annotCount; ai++) {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
|
||||
if (!annotPtr) continue;
|
||||
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let fieldName = '';
|
||||
if (nl > 0) {
|
||||
const nb = m.pdfium.wasmExports.malloc(nl);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
|
||||
fieldName = readUtf16(m, nb, nl);
|
||||
m.pdfium.wasmExports.free(nb);
|
||||
}
|
||||
|
||||
if (!fieldName || !(fieldName in values)) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = values[fieldName];
|
||||
const fieldType = m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr);
|
||||
|
||||
try {
|
||||
if (fieldType === PDF_FORM_FIELD_TYPE.TEXTFIELD) {
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_SelectAllText(formEnvPtr, pagePtr);
|
||||
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
|
||||
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
|
||||
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
|
||||
m.pdfium.wasmExports.free(wPtr);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
} else if (fieldType === PDF_FORM_FIELD_TYPE.CHECKBOX) {
|
||||
// Toggle checkbox using the same approach as @embedpdf engine:
|
||||
// Focus → Enter key (FORM_OnChar with keycode 13) → Kill focus.
|
||||
// Click simulation (FORM_OnLButtonDown/Up) does NOT reliably
|
||||
// persist checkbox state changes in headless/offscreen mode.
|
||||
const isCurrentlyChecked = m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr);
|
||||
const shouldBeChecked = value !== '' && value !== 'Off';
|
||||
if (isCurrentlyChecked !== shouldBeChecked) {
|
||||
const ENTER_KEY = 13;
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
} else if (fieldType === PDF_FORM_FIELD_TYPE.RADIOBUTTON) {
|
||||
// Radio values are stored as widget indices (e.g., "0", "1", "2").
|
||||
// Track the current widget index for this field and toggle only
|
||||
// the widget whose index matches the stored value.
|
||||
const currentIdx = radioWidgetIdx.get(fieldName) ?? 0;
|
||||
radioWidgetIdx.set(fieldName, currentIdx + 1);
|
||||
|
||||
const targetIdx = parseInt(value, 10);
|
||||
if (!isNaN(targetIdx) && currentIdx === targetIdx) {
|
||||
const isAlreadyChecked = m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr);
|
||||
if (!isAlreadyChecked) {
|
||||
const ENTER_KEY = 13;
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
fieldType === PDF_FORM_FIELD_TYPE.COMBOBOX ||
|
||||
fieldType === PDF_FORM_FIELD_TYPE.LISTBOX
|
||||
) {
|
||||
// FORM_SetIndexSelected requires the annotation to be focused first.
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
|
||||
let matched = false;
|
||||
const optCount = m.FPDFAnnot_GetOptionCount(formEnvPtr, annotPtr);
|
||||
for (let oi = 0; oi < optCount; oi++) {
|
||||
const optLen = m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, 0, 0);
|
||||
if (optLen > 0) {
|
||||
const ob = m.pdfium.wasmExports.malloc(optLen);
|
||||
m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, ob, optLen);
|
||||
const optLabel = readUtf16(m, ob, optLen);
|
||||
m.pdfium.wasmExports.free(ob);
|
||||
if (optLabel === value) {
|
||||
m.FORM_SetIndexSelected(formEnvPtr, pagePtr, oi, true);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: set as text (handles editable combos or
|
||||
// cases where export values differ from display labels).
|
||||
if (!matched && value) {
|
||||
m.FORM_SelectAllText(formEnvPtr, pagePtr);
|
||||
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
|
||||
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
|
||||
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
|
||||
m.pdfium.wasmExports.free(wPtr);
|
||||
}
|
||||
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[PdfiumFormProvider] Failed to set "${fieldName}":`, err);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
|
||||
if (flatten) {
|
||||
m.FPDFPage_Flatten(pagePtr, FLAT_PRINT);
|
||||
}
|
||||
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
|
||||
const savedBytes = await saveRawDocument(docPtr);
|
||||
return new Blob([savedBytes], { type: 'application/pdf' });
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PDFDocument, PageSizes } from '@cantoo/pdf-lib';
|
||||
import {
|
||||
getPdfiumModule,
|
||||
saveRawDocument,
|
||||
} from '@app/services/pdfiumService';
|
||||
import { copyRgbaToBgraHeap } from '@app/utils/pdfiumBitmapUtils';
|
||||
|
||||
export interface ImageToPdfOptions {
|
||||
imageResolution?: 'full' | 'reduced';
|
||||
@@ -6,11 +10,14 @@ export interface ImageToPdfOptions {
|
||||
stretchToFit?: boolean;
|
||||
}
|
||||
|
||||
// Standard page sizes in PDF points (72 dpi)
|
||||
const PAGE_SIZES = {
|
||||
A4: [595.276, 841.890] as [number, number],
|
||||
Letter: [612, 792] as [number, number],
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an image file to a PDF file
|
||||
* @param imageFile - The image file to convert (JPEG, PNG, etc.)
|
||||
* @param options - Conversion options
|
||||
* @returns A Promise that resolves to a PDF File object
|
||||
* Convert an image file to a PDF file using PDFium WASM.
|
||||
*/
|
||||
export async function convertImageToPdf(
|
||||
imageFile: File,
|
||||
@@ -21,121 +28,143 @@ export async function convertImageToPdf(
|
||||
pageFormat = 'A4',
|
||||
stretchToFit = false,
|
||||
} = options;
|
||||
try {
|
||||
// Create a new PDF document
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
// Read the image file as an array buffer
|
||||
let imageBytes = await imageFile.arrayBuffer();
|
||||
try {
|
||||
const m = await getPdfiumModule();
|
||||
|
||||
// Read the image file
|
||||
let imageBlob: Blob = imageFile;
|
||||
|
||||
// Apply image resolution reduction if requested
|
||||
if (imageResolution === 'reduced') {
|
||||
const reducedImage = await reduceImageResolution(imageFile, 1200); // Max 1200px on longest side
|
||||
imageBytes = await reducedImage.arrayBuffer();
|
||||
imageBlob = await reduceImageResolution(imageFile, 1200);
|
||||
}
|
||||
|
||||
// Embed the image based on its type
|
||||
let image;
|
||||
const imageType = imageFile.type.toLowerCase();
|
||||
|
||||
if (imageType === 'image/jpeg' || imageType === 'image/jpg') {
|
||||
image = await pdfDoc.embedJpg(imageBytes);
|
||||
} else if (imageType === 'image/png') {
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
} else {
|
||||
// For other image types, try to convert to PNG first using canvas
|
||||
const convertedImage = await convertImageToPng(imageFile);
|
||||
const convertedBytes = await convertedImage.arrayBuffer();
|
||||
image = await pdfDoc.embedPng(convertedBytes);
|
||||
// Decode image to RGBA pixels via canvas
|
||||
const decoded = await decodeImageToRgba(imageBlob);
|
||||
if (!decoded) {
|
||||
throw new Error('Failed to decode image');
|
||||
}
|
||||
|
||||
// Get image dimensions
|
||||
const { width: imageWidth, height: imageHeight } = image;
|
||||
const { rgba, width: imageWidth, height: imageHeight } = decoded;
|
||||
|
||||
// Determine page dimensions based on pageFormat option
|
||||
// Determine page dimensions
|
||||
let pageWidth: number;
|
||||
let pageHeight: number;
|
||||
|
||||
if (pageFormat === 'keep') {
|
||||
// Use original image dimensions
|
||||
pageWidth = imageWidth;
|
||||
pageHeight = imageHeight;
|
||||
} else if (pageFormat === 'letter') {
|
||||
// US Letter: 8.5" x 11" = 612 x 792 points
|
||||
pageWidth = PageSizes.Letter[0];
|
||||
pageHeight = PageSizes.Letter[1];
|
||||
[pageWidth, pageHeight] = PAGE_SIZES.Letter;
|
||||
} else {
|
||||
// A4: 210mm x 297mm = 595 x 842 points (default)
|
||||
pageWidth = PageSizes.A4[0];
|
||||
pageHeight = PageSizes.A4[1];
|
||||
[pageWidth, pageHeight] = PAGE_SIZES.A4;
|
||||
}
|
||||
|
||||
// Adjust page orientation based on image orientation if using standard page size
|
||||
// Adjust orientation to match image
|
||||
if (pageFormat !== 'keep') {
|
||||
const imageIsLandscape = imageWidth > imageHeight;
|
||||
const pageIsLandscape = pageWidth > pageHeight;
|
||||
|
||||
// Rotate page to match image orientation
|
||||
if (imageIsLandscape !== pageIsLandscape) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a page
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
|
||||
// Calculate image placement based on stretchToFit option
|
||||
// Calculate image placement
|
||||
let drawX: number;
|
||||
let drawY: number;
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
|
||||
if (stretchToFit || pageFormat === 'keep') {
|
||||
// Stretch/fill to page
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageHeight;
|
||||
} else {
|
||||
// Fit within page bounds while preserving aspect ratio
|
||||
const imageAspectRatio = imageWidth / imageHeight;
|
||||
const pageAspectRatio = pageWidth / pageHeight;
|
||||
|
||||
if (imageAspectRatio > pageAspectRatio) {
|
||||
// Image is wider than page - fit to width
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageWidth / imageAspectRatio;
|
||||
drawX = 0;
|
||||
drawY = (pageHeight - drawHeight) / 2; // Center vertically
|
||||
drawY = (pageHeight - drawHeight) / 2;
|
||||
} else {
|
||||
// Image is taller than page - fit to height
|
||||
drawHeight = pageHeight;
|
||||
drawWidth = pageHeight * imageAspectRatio;
|
||||
drawY = 0;
|
||||
drawX = (pageWidth - drawWidth) / 2; // Center horizontally
|
||||
drawX = (pageWidth - drawWidth) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the image on the page
|
||||
page.drawImage(image, {
|
||||
x: drawX,
|
||||
y: drawY,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
});
|
||||
// Create new PDF document
|
||||
const docPtr = m.FPDF_CreateNewDocument();
|
||||
if (!docPtr) throw new Error('PDFium: failed to create document');
|
||||
|
||||
// Save the PDF to bytes
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
try {
|
||||
// Create a page
|
||||
const pagePtr = m.FPDFPage_New(docPtr, 0, pageWidth, pageHeight);
|
||||
if (!pagePtr) throw new Error('PDFium: failed to create page');
|
||||
|
||||
// Create a filename by replacing the image extension with .pdf
|
||||
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
|
||||
// Create bitmap from RGBA data (PDFium uses BGRA)
|
||||
const bitmapPtr = m.FPDFBitmap_Create(imageWidth, imageHeight, 1);
|
||||
if (!bitmapPtr) throw new Error('PDFium: failed to create bitmap');
|
||||
|
||||
// Create a File object from the PDF bytes
|
||||
const pdfFile = new File([new Uint8Array(pdfBytes)], pdfFilename, {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||
|
||||
return pdfFile;
|
||||
// Bulk RGBA → BGRA copy via shared utility
|
||||
copyRgbaToBgraHeap(m, rgba, bufferPtr, imageWidth, imageHeight, stride);
|
||||
|
||||
// Create image page object
|
||||
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
|
||||
if (!imageObjPtr) {
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
throw new Error('PDFium: failed to create image object');
|
||||
}
|
||||
|
||||
const setBitmapOk = m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr);
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
|
||||
if (!setBitmapOk) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
throw new Error('PDFium: failed to set bitmap on image object');
|
||||
}
|
||||
|
||||
// Set transformation matrix: scale + translate
|
||||
// FS_MATRIX: {a, b, c, d, e, f} — 6 floats
|
||||
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
|
||||
m.pdfium.setValue(matrixPtr, drawWidth, 'float'); // a = scaleX
|
||||
m.pdfium.setValue(matrixPtr + 4, 0, 'float'); // b
|
||||
m.pdfium.setValue(matrixPtr + 8, 0, 'float'); // c
|
||||
m.pdfium.setValue(matrixPtr + 12, drawHeight, 'float'); // d = scaleY
|
||||
m.pdfium.setValue(matrixPtr + 16, drawX, 'float'); // e = translateX
|
||||
m.pdfium.setValue(matrixPtr + 20, drawY, 'float'); // f = translateY
|
||||
|
||||
const setMatrixOk = m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr);
|
||||
m.pdfium.wasmExports.free(matrixPtr);
|
||||
|
||||
if (!setMatrixOk) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
throw new Error('PDFium: failed to set image matrix');
|
||||
}
|
||||
|
||||
// Insert image into page
|
||||
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
|
||||
|
||||
// Generate page content stream
|
||||
m.FPDFPage_GenerateContent(pagePtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
|
||||
// Save document
|
||||
const pdfBytes = await saveRawDocument(docPtr);
|
||||
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
|
||||
|
||||
return new File([pdfBytes], pdfFilename, { type: 'application/pdf' });
|
||||
} finally {
|
||||
m.FPDF_CloseDocument(docPtr);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting image to PDF:', error);
|
||||
throw new Error(
|
||||
@@ -146,57 +175,41 @@ export async function convertImageToPdf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file to PNG using canvas
|
||||
* This is used for image types that pdf-lib doesn't directly support
|
||||
* Decode an image Blob to RGBA pixel data via canvas.
|
||||
*/
|
||||
async function convertImageToPng(imageFile: File): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
function decodeImageToRgba(imageBlob: Blob): Promise<{ rgba: Uint8Array; width: number; height: number } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
const url = URL.createObjectURL(imageBlob);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// Create a canvas with the image dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// Draw the image on the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Convert canvas to blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a File object from the blob
|
||||
const pngFilename = imageFile.name.replace(/\.[^.]+$/, '.png');
|
||||
const pngFile = new File([blob], pngFilename, {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(pngFile);
|
||||
},
|
||||
'image/png',
|
||||
1.0
|
||||
);
|
||||
} catch (error) {
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
resolve({
|
||||
rgba: new Uint8Array(imageData.data.buffer),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
@@ -205,9 +218,6 @@ async function convertImageToPng(imageFile: File): Promise<File> {
|
||||
|
||||
/**
|
||||
* Reduce image resolution to a maximum dimension
|
||||
* @param imageFile - The image file to reduce
|
||||
* @param maxDimension - Maximum width or height in pixels
|
||||
* @returns A Promise that resolves to a reduced resolution image file
|
||||
*/
|
||||
async function reduceImageResolution(
|
||||
imageFile: File,
|
||||
@@ -221,14 +231,12 @@ async function reduceImageResolution(
|
||||
try {
|
||||
const { width, height } = img;
|
||||
|
||||
// Check if reduction is needed
|
||||
if (width <= maxDimension && height <= maxDimension) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(imageFile); // No reduction needed
|
||||
resolve(imageFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new dimensions while preserving aspect ratio
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
@@ -240,19 +248,14 @@ async function reduceImageResolution(
|
||||
newWidth = (width / height) * maxDimension;
|
||||
}
|
||||
|
||||
// Create a canvas with the new dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
// Draw the resized image on the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
if (!ctx) throw new Error('Failed to get canvas context');
|
||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
// Convert canvas to blob (preserve original format if possible)
|
||||
const outputType = imageFile.type.startsWith('image/')
|
||||
? imageFile.type
|
||||
: 'image/jpeg';
|
||||
@@ -263,17 +266,12 @@ async function reduceImageResolution(
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a File object from the blob
|
||||
const reducedFile = new File([blob], imageFile.name, {
|
||||
type: outputType,
|
||||
});
|
||||
|
||||
const reducedFile = new File([blob], imageFile.name, { type: outputType });
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(reducedFile);
|
||||
},
|
||||
outputType,
|
||||
0.9 // Quality (only applies to JPEG)
|
||||
0.9,
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
/**
|
||||
* pdfLinkUtils — Create, modify, and extract link annotations in PDF documents.
|
||||
*
|
||||
* Migrated from @cantoo/pdf-lib to @embedpdf/pdfium WASM.
|
||||
* All operations are performed via PDFium C API wrappers.
|
||||
*/
|
||||
import {
|
||||
PDFDocument,
|
||||
PDFPage,
|
||||
PDFName,
|
||||
PDFString,
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFRef,
|
||||
PDFContext,
|
||||
PDFNumber,
|
||||
PDFHexString,
|
||||
} from '@cantoo/pdf-lib';
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
closeDocAndFreeBuffer,
|
||||
saveRawDocument,
|
||||
readUtf16,
|
||||
writeUtf16,
|
||||
readAnnotRectAdjusted,
|
||||
parseRectToCss,
|
||||
} from '@app/services/pdfiumService';
|
||||
import {
|
||||
FPDF_ANNOT_LINK,
|
||||
PDFACTION_GOTO,
|
||||
PDFACTION_URI,
|
||||
} from '@app/utils/pdfiumBitmapUtils';
|
||||
|
||||
export type LinkType = 'internal' | 'external' | 'unknown';
|
||||
export type LinkBorderStyle = 'solid' | 'dashed' | 'beveled' | 'inset' | 'underline';
|
||||
@@ -40,9 +46,9 @@ export interface PdfLibLink {
|
||||
}
|
||||
|
||||
export interface CreateLinkOptions {
|
||||
/** Page to place the link on. */
|
||||
page: PDFPage;
|
||||
/** Link rectangle in PDF user-space coordinates (lower-left origin). */
|
||||
/** 0-based page index. */
|
||||
pageIndex: number;
|
||||
/** Link rectangle in CSS (top-left origin) coordinate space. */
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
/** External URL (mutually exclusive with destinationPage). */
|
||||
url?: string;
|
||||
@@ -62,22 +68,21 @@ export interface CreateLinkOptions {
|
||||
|
||||
/**
|
||||
* Create a link annotation on a PDF page.
|
||||
* Supports both external URIs and internal GoTo page destinations.
|
||||
* Mutates the document in-place and returns the updated PDF bytes.
|
||||
*/
|
||||
export function createLinkAnnotation(
|
||||
pdfDoc: PDFDocument,
|
||||
export async function createLinkAnnotation(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
options: CreateLinkOptions,
|
||||
): void {
|
||||
password?: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const {
|
||||
page,
|
||||
pageIndex,
|
||||
rect,
|
||||
url,
|
||||
destinationPage,
|
||||
title,
|
||||
color = [0, 0, 1],
|
||||
borderWidth = 0,
|
||||
borderStyle = 'solid',
|
||||
highlightMode = 'invert',
|
||||
} = options;
|
||||
|
||||
if (!url && destinationPage === undefined) {
|
||||
@@ -86,468 +91,244 @@ export function createLinkAnnotation(
|
||||
if (url && destinationPage !== undefined) {
|
||||
throw new Error('createLinkAnnotation: url and destinationPage are mutually exclusive');
|
||||
}
|
||||
if (destinationPage !== undefined) {
|
||||
const pageCount = pdfDoc.getPageCount();
|
||||
if (destinationPage < 0 || destinationPage >= pageCount) {
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
throw new Error('createLinkAnnotation: rect dimensions must be positive');
|
||||
}
|
||||
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
try {
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
if (destinationPage !== undefined && (destinationPage < 0 || destinationPage >= pageCount)) {
|
||||
throw new RangeError(
|
||||
`createLinkAnnotation: destinationPage ${destinationPage} out of range [0, ${pageCount})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
throw new Error('createLinkAnnotation: rect dimensions must be positive');
|
||||
}
|
||||
if (color.some((c) => c < 0 || c > 1)) {
|
||||
throw new RangeError('createLinkAnnotation: color components must be between 0 and 1');
|
||||
}
|
||||
if (borderWidth < 0) {
|
||||
throw new RangeError('createLinkAnnotation: borderWidth must be non-negative');
|
||||
}
|
||||
|
||||
const ctx = pdfDoc.context;
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
|
||||
|
||||
const entries: Record<string, any> = {
|
||||
Type: 'Annot',
|
||||
Subtype: 'Link',
|
||||
Rect: [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height],
|
||||
Border: [0, 0, borderWidth],
|
||||
C: color,
|
||||
H: PDFName.of(highlightModeCode(highlightMode)),
|
||||
};
|
||||
try {
|
||||
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||
|
||||
if (title) {
|
||||
entries.Contents = PDFString.of(title);
|
||||
}
|
||||
const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_LINK);
|
||||
if (!annotPtr) {
|
||||
throw new Error('Failed to create link annotation');
|
||||
}
|
||||
|
||||
const annotDict = ctx.obj(entries);
|
||||
try {
|
||||
// Set rect (convert from CSS top-left to PDF bottom-left origin)
|
||||
// FS_RECTF layout: { left, top, right, bottom } where top > bottom in PDF coords
|
||||
const pdfLeft = rect.x;
|
||||
const pdfTop = pageHeight - rect.y; // CSS y=0 → PDF top
|
||||
const pdfRight = rect.x + rect.width;
|
||||
const pdfBottom = pageHeight - rect.y - rect.height; // CSS bottom → PDF bottom
|
||||
|
||||
if (borderStyle !== 'solid' && borderWidth > 0) {
|
||||
const bsDict = ctx.obj({
|
||||
W: borderWidth,
|
||||
S: PDFName.of(borderStyleCode(borderStyle)),
|
||||
});
|
||||
(annotDict as PDFDict).set(PDFName.of('BS'), bsDict);
|
||||
}
|
||||
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||
m.pdfium.setValue(rectBuf, pdfLeft, 'float'); // offset 0: left
|
||||
m.pdfium.setValue(rectBuf + 4, pdfTop, 'float'); // offset 4: top (larger y)
|
||||
m.pdfium.setValue(rectBuf + 8, pdfRight, 'float'); // offset 8: right
|
||||
m.pdfium.setValue(rectBuf + 12, pdfBottom, 'float'); // offset 12: bottom (smaller y)
|
||||
m.FPDFAnnot_SetRect(annotPtr, rectBuf);
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
|
||||
if (url) {
|
||||
const actionDict = ctx.obj({
|
||||
S: 'URI',
|
||||
URI: PDFString.of(url),
|
||||
});
|
||||
(annotDict as PDFDict).set(PDFName.of('A'), actionDict);
|
||||
} else if (destinationPage !== undefined) {
|
||||
const destPage = pdfDoc.getPage(destinationPage);
|
||||
const destArray = ctx.obj([destPage.ref, 'XYZ', null, null, null]);
|
||||
(annotDict as PDFDict).set(PDFName.of('Dest'), destArray);
|
||||
}
|
||||
// Set color
|
||||
// FPDFANNOT_COLORTYPE_Color = 0
|
||||
m.FPDFAnnot_SetColor(
|
||||
annotPtr,
|
||||
0,
|
||||
Math.round(color[0] * 255),
|
||||
Math.round(color[1] * 255),
|
||||
Math.round(color[2] * 255),
|
||||
255,
|
||||
);
|
||||
|
||||
const annotRef = ctx.register(annotDict);
|
||||
// Set border
|
||||
m.FPDFAnnot_SetBorder(annotPtr, 0, 0, borderWidth);
|
||||
|
||||
const existingAnnots = page.node.get(PDFName.of('Annots'));
|
||||
if (existingAnnots) {
|
||||
const resolvedAnnots =
|
||||
existingAnnots instanceof PDFRef ? ctx.lookup(existingAnnots) : existingAnnots;
|
||||
if (resolvedAnnots instanceof PDFArray) {
|
||||
resolvedAnnots.push(annotRef);
|
||||
} else {
|
||||
page.node.set(PDFName.of('Annots'), ctx.obj([annotRef]));
|
||||
// Set URI for external links
|
||||
if (url) {
|
||||
const uriPtr = writeUtf16(m, url);
|
||||
m.FPDFAnnot_SetURI(annotPtr, uriPtr);
|
||||
m.pdfium.wasmExports.free(uriPtr);
|
||||
}
|
||||
|
||||
// Set title / contents
|
||||
if (title) {
|
||||
const titlePtr = writeUtf16(m, title);
|
||||
m.FPDFAnnot_SetStringValue(annotPtr, 'Contents', titlePtr);
|
||||
m.pdfium.wasmExports.free(titlePtr);
|
||||
}
|
||||
} finally {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
} finally {
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
} else {
|
||||
page.node.set(PDFName.of('Annots'), ctx.obj([annotRef]));
|
||||
|
||||
return await saveRawDocument(docPtr);
|
||||
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link annotation from a page by its index in the /Annots array.
|
||||
* Returns true if the annotation was found and removed.
|
||||
* Remove a link annotation from a page by its index.
|
||||
* Returns the updated PDF bytes.
|
||||
*/
|
||||
export function removeLinkAnnotation(
|
||||
pdfDoc: PDFDocument,
|
||||
page: PDFPage,
|
||||
export async function removeLinkAnnotation(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
pageIndex: number,
|
||||
annotIndex: number,
|
||||
): boolean {
|
||||
const ctx = pdfDoc.context;
|
||||
const annotsRaw = page.node.get(PDFName.of('Annots'));
|
||||
if (!annotsRaw) return false;
|
||||
password?: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
const annots =
|
||||
annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
|
||||
if (!(annots instanceof PDFArray)) return false;
|
||||
try {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
|
||||
|
||||
if (annotIndex < 0 || annotIndex >= annots.size()) return false;
|
||||
m.FPDFPage_RemoveAnnot(pagePtr, annotIndex);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
|
||||
const entry = annots.get(annotIndex);
|
||||
if (entry instanceof PDFRef) {
|
||||
ctx.delete(entry);
|
||||
return await saveRawDocument(docPtr);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
|
||||
annots.remove(annotIndex);
|
||||
|
||||
if (annots.size() === 0) {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all link annotations from a given PDF page.
|
||||
*/
|
||||
export function extractLinksFromPage(
|
||||
doc: PDFDocument,
|
||||
page: PDFPage,
|
||||
export async function extractLinksFromPage(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
pageIndex: number,
|
||||
): PdfLibLink[] {
|
||||
const links: PdfLibLink[] = [];
|
||||
const ctx = doc.context;
|
||||
const { height: pageHeight } = page.getSize();
|
||||
password?: string,
|
||||
): Promise<{ links: PdfLibLink[]; pdfPageWidth: number; pdfPageHeight: number }> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
const annotsRaw = page.node.get(PDFName.of('Annots'));
|
||||
if (!annotsRaw) return links;
|
||||
try {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) return { links: [], pdfPageWidth: 0, pdfPageHeight: 0 };
|
||||
|
||||
const annots = annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
|
||||
if (!(annots instanceof PDFArray)) return links;
|
||||
const pageWidth = m.FPDF_GetPageWidthF(pagePtr);
|
||||
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||
const links: PdfLibLink[] = [];
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
|
||||
for (let i = 0; i < annots.size(); i++) {
|
||||
try {
|
||||
const annotRaw = annots.get(i);
|
||||
const annot = annotRaw instanceof PDFRef ? ctx.lookup(annotRaw) : annotRaw;
|
||||
if (!(annot instanceof PDFDict)) continue;
|
||||
for (let i = 0; i < annotCount; i++) {
|
||||
try {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, i);
|
||||
if (!annotPtr) continue;
|
||||
|
||||
const subtype = annot.get(PDFName.of('Subtype'));
|
||||
if (subtype?.toString() !== '/Link') continue;
|
||||
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
|
||||
if (subtype !== FPDF_ANNOT_LINK) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rectRaw = annot.get(PDFName.of('Rect'));
|
||||
const rect = rectRaw instanceof PDFRef ? ctx.lookup(rectRaw) : rectRaw;
|
||||
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
|
||||
// Get rect (CropBox-adjusted for correct overlay positioning)
|
||||
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||
const hasRect = readAnnotRectAdjusted(m, annotPtr, rectBuf);
|
||||
if (!hasRect) {
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const x1 = num(ctx, rect.get(0));
|
||||
const y1 = num(ctx, rect.get(1));
|
||||
const x2 = num(ctx, rect.get(2));
|
||||
const y2 = num(ctx, rect.get(3));
|
||||
const rect = parseRectToCss(m, rectBuf, pageHeight);
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
|
||||
const left = Math.min(x1, x2);
|
||||
const bottom = Math.min(y1, y2);
|
||||
const width = Math.abs(x2 - x1);
|
||||
const height = Math.abs(y2 - y1);
|
||||
// Try to get link object
|
||||
const linkPtr = m.FPDFAnnot_GetLink(annotPtr);
|
||||
let linkType: LinkType = 'unknown';
|
||||
let targetPage: number | undefined;
|
||||
let uri: string | undefined;
|
||||
|
||||
const top = pageHeight - bottom - height;
|
||||
|
||||
let linkType: LinkType = 'unknown';
|
||||
let targetPage: number | undefined;
|
||||
let uri: string | undefined;
|
||||
|
||||
const actionRaw = annot.get(PDFName.of('A'));
|
||||
const action = actionRaw instanceof PDFRef ? ctx.lookup(actionRaw) : actionRaw;
|
||||
|
||||
if (action instanceof PDFDict) {
|
||||
const actionType = action.get(PDFName.of('S'))?.toString();
|
||||
|
||||
if (actionType === '/URI') {
|
||||
linkType = 'external';
|
||||
uri = str(ctx, action.get(PDFName.of('URI')));
|
||||
} else if (actionType === '/GoTo') {
|
||||
linkType = 'internal';
|
||||
const dest = action.get(PDFName.of('D'));
|
||||
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
|
||||
if (destResolved instanceof PDFArray) {
|
||||
targetPage = resolveDestArray(doc, ctx, destResolved);
|
||||
} else {
|
||||
const destName = str(ctx, destResolved);
|
||||
if (destName) {
|
||||
targetPage = resolveNamedDest(doc, ctx, destName);
|
||||
if (linkPtr) {
|
||||
// Check for action
|
||||
const actionPtr = m.FPDFLink_GetAction(linkPtr);
|
||||
if (actionPtr) {
|
||||
const actionType = m.FPDFAction_GetType(actionPtr);
|
||||
if (actionType === PDFACTION_URI) {
|
||||
const uriLen = m.FPDFAction_GetURIPath(docPtr, actionPtr, 0, 0);
|
||||
if (uriLen > 0) {
|
||||
const uriBuf = m.pdfium.wasmExports.malloc(uriLen);
|
||||
m.FPDFAction_GetURIPath(docPtr, actionPtr, uriBuf, uriLen);
|
||||
uri = m.pdfium.UTF8ToString(uriBuf);
|
||||
m.pdfium.wasmExports.free(uriBuf);
|
||||
linkType = 'external';
|
||||
}
|
||||
} else if (actionType === PDFACTION_GOTO) {
|
||||
const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr);
|
||||
if (destPtr) {
|
||||
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||
linkType = 'internal';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (actionType === '/GoToR' || actionType === '/Launch') {
|
||||
linkType = 'external';
|
||||
uri = str(ctx, action.get(PDFName.of('F')));
|
||||
}
|
||||
}
|
||||
|
||||
if (linkType === 'unknown') {
|
||||
const destRaw = annot.get(PDFName.of('Dest'));
|
||||
const dest = destRaw instanceof PDFRef ? ctx.lookup(destRaw) : destRaw;
|
||||
|
||||
if (dest instanceof PDFArray) {
|
||||
linkType = 'internal';
|
||||
targetPage = resolveDestArray(doc, ctx, dest);
|
||||
} else {
|
||||
const destName = str(ctx, dest);
|
||||
if (destName) {
|
||||
linkType = 'internal';
|
||||
targetPage = resolveNamedDest(doc, ctx, destName);
|
||||
// Check for direct destination
|
||||
if (linkType === 'unknown') {
|
||||
const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr);
|
||||
if (destPtr) {
|
||||
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||
linkType = 'internal';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = extractTitle(ctx, annot);
|
||||
const color = extractColor(ctx, annot);
|
||||
const borderStyle = extractBorderStyle(ctx, annot);
|
||||
const highlightMode = parseHighlightMode(ctx, annot.get(PDFName.of('H')));
|
||||
|
||||
links.push({
|
||||
id: `pdflib-link-${pageIndex}-${i}`,
|
||||
annotIndex: i,
|
||||
rect: { x: left, y: top, width, height },
|
||||
type: linkType,
|
||||
targetPage,
|
||||
uri,
|
||||
title,
|
||||
color,
|
||||
borderStyle,
|
||||
highlightMode,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[pdfLinkUtils] Failed to parse annotation:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private Helpers (Internal to extraction logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function num(ctx: PDFContext, value: unknown): number {
|
||||
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
|
||||
if (resolved instanceof PDFNumber) return resolved.asNumber();
|
||||
if (typeof resolved === 'number') return resolved;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function str(ctx: PDFContext, value: unknown): string | undefined {
|
||||
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
|
||||
if (resolved instanceof PDFString) return resolved.decodeText();
|
||||
if (resolved instanceof PDFHexString) return resolved.decodeText();
|
||||
if (typeof resolved === 'string') return resolved;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePageIndex(doc: PDFDocument, pageRef: PDFRef): number | undefined {
|
||||
const pages = doc.getPages();
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const ref = pages[i].ref;
|
||||
if (
|
||||
ref === pageRef ||
|
||||
(ref.objectNumber === pageRef.objectNumber &&
|
||||
ref.generationNumber === pageRef.generationNumber)
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveDestArray(
|
||||
doc: PDFDocument,
|
||||
ctx: PDFContext,
|
||||
destArr: PDFArray,
|
||||
): number | undefined {
|
||||
if (destArr.size() < 1) return undefined;
|
||||
const first = destArr.get(0);
|
||||
if (first instanceof PDFRef) {
|
||||
return resolvePageIndex(doc, first);
|
||||
}
|
||||
const n = num(ctx, first);
|
||||
if (typeof n === 'number' && n >= 0) return n;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveNamedDest(
|
||||
doc: PDFDocument,
|
||||
ctx: PDFContext,
|
||||
name: string,
|
||||
): number | undefined {
|
||||
try {
|
||||
const catalog = doc.catalog;
|
||||
|
||||
const namesRaw = catalog.get(PDFName.of('Names'));
|
||||
const namesDict = namesRaw instanceof PDFRef ? ctx.lookup(namesRaw) : namesRaw;
|
||||
if (namesDict instanceof PDFDict) {
|
||||
const destsRaw = namesDict.get(PDFName.of('Dests'));
|
||||
const destsTree = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw;
|
||||
if (destsTree instanceof PDFDict) {
|
||||
const result = searchNameTree(doc, ctx, destsTree, name);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
}
|
||||
|
||||
const destsRaw = catalog.get(PDFName.of('Dests'));
|
||||
const destsDict = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw;
|
||||
if (destsDict instanceof PDFDict) {
|
||||
const dest = destsDict.get(PDFName.of(name));
|
||||
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
|
||||
if (destResolved instanceof PDFArray) {
|
||||
return resolveDestArray(doc, ctx, destResolved);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function searchNameTree(
|
||||
doc: PDFDocument,
|
||||
ctx: PDFContext,
|
||||
node: PDFDict,
|
||||
name: string,
|
||||
): number | undefined {
|
||||
const namesArr = node.get(PDFName.of('Names'));
|
||||
const resolved = namesArr instanceof PDFRef ? ctx.lookup(namesArr) : namesArr;
|
||||
if (resolved instanceof PDFArray) {
|
||||
for (let i = 0; i < resolved.size(); i += 2) {
|
||||
const key = str(ctx, resolved.get(i));
|
||||
if (key === name) {
|
||||
const val = resolved.get(i + 1);
|
||||
const valResolved = val instanceof PDFRef ? ctx.lookup(val) : val;
|
||||
if (valResolved instanceof PDFArray) {
|
||||
return resolveDestArray(doc, ctx, valResolved);
|
||||
// Get title from /Contents
|
||||
let title: string | undefined;
|
||||
const contentsLen = m.FPDFAnnot_GetStringValue(annotPtr, 'Contents', 0, 0);
|
||||
if (contentsLen > 2) {
|
||||
const contentsBuf = m.pdfium.wasmExports.malloc(contentsLen);
|
||||
m.FPDFAnnot_GetStringValue(annotPtr, 'Contents', contentsBuf, contentsLen);
|
||||
title = readUtf16(m, contentsBuf, contentsLen) || undefined;
|
||||
m.pdfium.wasmExports.free(contentsBuf);
|
||||
}
|
||||
if (valResolved instanceof PDFDict) {
|
||||
const d = valResolved.get(PDFName.of('D'));
|
||||
const dResolved = d instanceof PDFRef ? ctx.lookup(d) : d;
|
||||
if (dResolved instanceof PDFArray) {
|
||||
return resolveDestArray(doc, ctx, dResolved);
|
||||
}
|
||||
|
||||
// Get color
|
||||
let color: [number, number, number] | undefined;
|
||||
// allocate 4 uint for RGBA
|
||||
const rPtr = m.pdfium.wasmExports.malloc(16);
|
||||
const gPtr = rPtr + 4;
|
||||
const bPtr = rPtr + 8;
|
||||
const aPtr = rPtr + 12;
|
||||
const hasColor = m.FPDFAnnot_GetColor(annotPtr, 0, rPtr, gPtr, bPtr, aPtr);
|
||||
if (hasColor) {
|
||||
color = [
|
||||
m.pdfium.getValue(rPtr, 'i32') / 255,
|
||||
m.pdfium.getValue(gPtr, 'i32') / 255,
|
||||
m.pdfium.getValue(bPtr, 'i32') / 255,
|
||||
];
|
||||
}
|
||||
m.pdfium.wasmExports.free(rPtr);
|
||||
|
||||
links.push({
|
||||
id: `link-${pageIndex}-${i}`,
|
||||
annotIndex: i,
|
||||
rect,
|
||||
type: linkType,
|
||||
targetPage,
|
||||
uri,
|
||||
title,
|
||||
color,
|
||||
});
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
} catch (e) {
|
||||
console.warn('[pdfLinkUtils] Failed to parse annotation:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const kidsArr = node.get(PDFName.of('Kids'));
|
||||
const kidsResolved = kidsArr instanceof PDFRef ? ctx.lookup(kidsArr) : kidsArr;
|
||||
if (kidsResolved instanceof PDFArray) {
|
||||
for (let i = 0; i < kidsResolved.size(); i++) {
|
||||
const kidRef = kidsResolved.get(i);
|
||||
const kid = kidRef instanceof PDFRef ? ctx.lookup(kidRef) : kidRef;
|
||||
if (kid instanceof PDFDict) {
|
||||
const limits = kid.get(PDFName.of('Limits'));
|
||||
const limitsResolved = limits instanceof PDFRef ? ctx.lookup(limits) : limits;
|
||||
if (limitsResolved instanceof PDFArray && limitsResolved.size() >= 2) {
|
||||
const lo = str(ctx, limitsResolved.get(0)) ?? '';
|
||||
const hi = str(ctx, limitsResolved.get(1)) ?? '';
|
||||
if (name < lo || name > hi) continue;
|
||||
}
|
||||
const result = searchNameTree(doc, ctx, kid, name);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function borderStyleCode(style: LinkBorderStyle): string {
|
||||
switch (style) {
|
||||
case 'dashed': return 'D';
|
||||
case 'beveled': return 'B';
|
||||
case 'inset': return 'I';
|
||||
case 'underline': return 'U';
|
||||
default: return 'S';
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
return { links, pdfPageWidth: pageWidth, pdfPageHeight: pageHeight };
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightModeCode(mode: LinkHighlightMode): string {
|
||||
switch (mode) {
|
||||
case 'none': return 'N';
|
||||
case 'outline': return 'O';
|
||||
case 'push': return 'P';
|
||||
default: return 'I';
|
||||
}
|
||||
}
|
||||
|
||||
function parseBorderStyleName(ctx: PDFContext, value: unknown): LinkBorderStyle {
|
||||
if (!value) return 'solid';
|
||||
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
|
||||
const s = resolved instanceof PDFName ? resolved.decodeText() : String(resolved);
|
||||
switch (s) {
|
||||
case 'D': return 'dashed';
|
||||
case 'B': return 'beveled';
|
||||
case 'I': return 'inset';
|
||||
case 'U': return 'underline';
|
||||
default: return 'solid';
|
||||
}
|
||||
}
|
||||
|
||||
function parseHighlightMode(ctx: PDFContext, value: unknown): LinkHighlightMode {
|
||||
if (!value) return 'invert';
|
||||
const resolved = value instanceof PDFRef ? ctx.lookup(value) : value;
|
||||
const s = resolved instanceof PDFName ? resolved.decodeText() : String(resolved);
|
||||
switch (s) {
|
||||
case 'N': return 'none';
|
||||
case 'I': return 'invert';
|
||||
case 'O': return 'outline';
|
||||
case 'P': return 'push';
|
||||
default: return 'invert';
|
||||
}
|
||||
}
|
||||
|
||||
function extractBorderStyle(
|
||||
ctx: PDFContext,
|
||||
annot: PDFDict,
|
||||
): PdfLibLink['borderStyle'] | undefined {
|
||||
const bsRaw = annot.get(PDFName.of('BS'));
|
||||
const bs = bsRaw instanceof PDFRef ? ctx.lookup(bsRaw) : bsRaw;
|
||||
if (bs instanceof PDFDict) {
|
||||
const w = bs.get(PDFName.of('W'));
|
||||
const s = bs.get(PDFName.of('S'));
|
||||
return {
|
||||
width: num(ctx, w) || 1,
|
||||
style: parseBorderStyleName(ctx, s),
|
||||
};
|
||||
}
|
||||
|
||||
const borderRaw = annot.get(PDFName.of('Border'));
|
||||
const border = borderRaw instanceof PDFRef ? ctx.lookup(borderRaw) : borderRaw;
|
||||
if (border instanceof PDFArray && border.size() >= 3) {
|
||||
const width = num(ctx, border.get(2));
|
||||
const style: LinkBorderStyle = border.size() >= 4 ? 'dashed' : 'solid';
|
||||
return { width, style };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractColor(
|
||||
ctx: PDFContext,
|
||||
annot: PDFDict,
|
||||
): [number, number, number] | undefined {
|
||||
const cRaw = annot.get(PDFName.of('C'));
|
||||
const c = cRaw instanceof PDFRef ? ctx.lookup(cRaw) : cRaw;
|
||||
if (!(c instanceof PDFArray)) return undefined;
|
||||
|
||||
const len = c.size();
|
||||
if (len === 3) {
|
||||
return [num(ctx, c.get(0)), num(ctx, c.get(1)), num(ctx, c.get(2))];
|
||||
}
|
||||
if (len === 1) {
|
||||
const g = num(ctx, c.get(0));
|
||||
return [g, g, g];
|
||||
}
|
||||
if (len === 4) {
|
||||
const cVal = num(ctx, c.get(0));
|
||||
const m = num(ctx, c.get(1));
|
||||
const y = num(ctx, c.get(2));
|
||||
const k = num(ctx, c.get(3));
|
||||
return [
|
||||
(1 - cVal) * (1 - k),
|
||||
(1 - m) * (1 - k),
|
||||
(1 - y) * (1 - k),
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractTitle(
|
||||
ctx: PDFContext,
|
||||
annot: PDFDict,
|
||||
): string | undefined {
|
||||
const raw = annot.get(PDFName.of('Contents'));
|
||||
const resolved = raw instanceof PDFRef ? ctx.lookup(raw) : raw;
|
||||
if (resolved instanceof PDFString || resolved instanceof PDFHexString) {
|
||||
return resolved.decodeText();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
208
frontend/src/core/utils/pdfiumBitmapUtils.ts
Normal file
208
frontend/src/core/utils/pdfiumBitmapUtils.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* pdfiumBitmapUtils — Shared utilities for working with PDFium WASM bitmaps.
|
||||
*
|
||||
* Centralises the RGBA→BGRA pixel conversion and image-to-page-object
|
||||
* embedding that previously appeared (copy-pasted) in at least 5 files.
|
||||
*
|
||||
* Performance note: instead of calling `m.pdfium.setValue()` per pixel
|
||||
* (which crosses the JS↔WASM FFI boundary on every call), we perform
|
||||
* the colour-channel swizzle in a plain JS TypedArray and then bulk-
|
||||
* copy the result into the WASM heap with a single `HEAPU8.set()`.
|
||||
*/
|
||||
import type { WrappedPdfiumModule } from '@embedpdf/pdfium';
|
||||
|
||||
/** FPDF_ANNOT_LINK */
|
||||
export const FPDF_ANNOT_LINK = 4;
|
||||
/** FPDF_ANNOT_WIDGET (form field) */
|
||||
export const FPDF_ANNOT_WIDGET = 20;
|
||||
/** FPDF_ANNOT_INK */
|
||||
export const FPDF_ANNOT_INK = 15;
|
||||
/** FPDF_ANNOT_LINE */
|
||||
export const FPDF_ANNOT_LINE = 3;
|
||||
/** PDFACTION_GOTO */
|
||||
export const PDFACTION_GOTO = 1;
|
||||
/** PDFACTION_URI */
|
||||
export const PDFACTION_URI = 3;
|
||||
/** FLAT_PRINT (for FPDFPage_Flatten) */
|
||||
export const FLAT_PRINT = 2;
|
||||
|
||||
/**
|
||||
* Convert an RGBA pixel buffer to BGRA (PDFium's expected format) **in place
|
||||
* inside the WASM heap** with a single bulk memcpy.
|
||||
*
|
||||
* When `stride === width * 4` the copy is a single `HEAPU8.set()`.
|
||||
* When the bitmap has padding (stride > width * 4), rows are copied
|
||||
* individually to skip the padding bytes.
|
||||
*
|
||||
* This is ~100× faster than per-pixel `m.pdfium.setValue()` calls for
|
||||
* large images.
|
||||
*/
|
||||
export function copyRgbaToBgraHeap(
|
||||
m: WrappedPdfiumModule,
|
||||
rgba: Uint8Array | Uint8ClampedArray,
|
||||
bufferPtr: number,
|
||||
width: number,
|
||||
height: number,
|
||||
stride: number,
|
||||
): void {
|
||||
const rowBytes = width * 4;
|
||||
|
||||
if (stride === rowBytes) {
|
||||
// Fast path: no padding — single bulk copy after swizzle
|
||||
const bgra = new Uint8Array(rgba.length);
|
||||
for (let i = 0; i < rgba.length; i += 4) {
|
||||
bgra[i] = rgba[i + 2]; // B
|
||||
bgra[i + 1] = rgba[i + 1]; // G
|
||||
bgra[i + 2] = rgba[i]; // R
|
||||
bgra[i + 3] = rgba[i + 3]; // A
|
||||
}
|
||||
new Uint8Array(
|
||||
(m.pdfium.wasmExports as any).memory.buffer,
|
||||
).set(bgra, bufferPtr);
|
||||
} else {
|
||||
// Stride has padding — swizzle + copy row by row
|
||||
const rowBuf = new Uint8Array(rowBytes);
|
||||
const heap = new Uint8Array(
|
||||
(m.pdfium.wasmExports as any).memory.buffer,
|
||||
);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const srcRowStart = y * rowBytes;
|
||||
for (let x = 0; x < rowBytes; x += 4) {
|
||||
rowBuf[x] = rgba[srcRowStart + x + 2]; // B
|
||||
rowBuf[x + 1] = rgba[srcRowStart + x + 1]; // G
|
||||
rowBuf[x + 2] = rgba[srcRowStart + x]; // R
|
||||
rowBuf[x + 3] = rgba[srcRowStart + x + 3]; // A
|
||||
}
|
||||
heap.set(rowBuf, bufferPtr + y * stride);
|
||||
}
|
||||
}
|
||||
}
|
||||
export interface DecodedImage {
|
||||
rgba: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image
|
||||
* page object, position it via an affine matrix, and insert it into the page.
|
||||
*
|
||||
* Returns `true` if the image was successfully inserted, `false` otherwise.
|
||||
* All intermediate WASM resources are cleaned up on failure.
|
||||
*/
|
||||
export function embedBitmapImageOnPage(
|
||||
m: WrappedPdfiumModule,
|
||||
docPtr: number,
|
||||
pagePtr: number,
|
||||
image: DecodedImage,
|
||||
pdfX: number,
|
||||
pdfY: number,
|
||||
drawWidth: number,
|
||||
drawHeight: number,
|
||||
): boolean {
|
||||
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
|
||||
if (!bitmapPtr) return false;
|
||||
|
||||
try {
|
||||
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||
|
||||
copyRgbaToBgraHeap(m, image.rgba, bufferPtr, image.width, image.height, stride);
|
||||
|
||||
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
|
||||
if (!imageObjPtr) return false;
|
||||
|
||||
const setBitmapOk = m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr);
|
||||
if (!setBitmapOk) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- early-destroy the bitmap; PDFium has copied the pixel data internally
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
|
||||
// Set affine transform: [a b c d e f]
|
||||
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
|
||||
try {
|
||||
m.pdfium.setValue(matrixPtr, drawWidth, 'float'); // a — scaleX
|
||||
m.pdfium.setValue(matrixPtr + 4, 0, 'float'); // b
|
||||
m.pdfium.setValue(matrixPtr + 8, 0, 'float'); // c
|
||||
m.pdfium.setValue(matrixPtr + 12, drawHeight, 'float'); // d — scaleY
|
||||
m.pdfium.setValue(matrixPtr + 16, pdfX, 'float'); // e — translateX
|
||||
m.pdfium.setValue(matrixPtr + 20, pdfY, 'float'); // f — translateY
|
||||
|
||||
if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
m.pdfium.wasmExports.free(matrixPtr);
|
||||
}
|
||||
|
||||
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
|
||||
return true;
|
||||
} finally {
|
||||
// Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium
|
||||
// builds but guard anyway. If already destroyed above, the second call
|
||||
// is harmless because we allow it to be idempotent.
|
||||
// We use a try-catch to be safe across PDFium WASM builds.
|
||||
try { m.FPDFBitmap_Destroy(bitmapPtr); } catch { /* already freed */ }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Draw a simple light-grey rectangle as a placeholder for annotations
|
||||
* that could not be rendered.
|
||||
*/
|
||||
export function drawPlaceholderRect(
|
||||
m: WrappedPdfiumModule,
|
||||
pagePtr: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
|
||||
if (!pathPtr) return;
|
||||
|
||||
m.FPDFPath_LineTo(pathPtr, x + width, y);
|
||||
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
|
||||
m.FPDFPath_LineTo(pathPtr, x, y + height);
|
||||
m.FPDFPath_Close(pathPtr);
|
||||
|
||||
m.FPDFPageObj_SetFillColor(pathPtr, 230, 230, 230, 150);
|
||||
m.FPDFPageObj_SetStrokeColor(pathPtr, 128, 128, 128, 255);
|
||||
m.FPDFPageObj_SetStrokeWidth(pathPtr, 1);
|
||||
|
||||
// fillMode 1 = alternate fill, stroke = true
|
||||
m.FPDFPath_SetDrawMode(pathPtr, 1, true);
|
||||
|
||||
m.FPDFPage_InsertObject(pagePtr, pathPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an image data URL (e.g. `data:image/png;base64,...`) to raw RGBA
|
||||
* pixel data via an offscreen canvas.
|
||||
*/
|
||||
export function decodeImageDataUrl(dataUrl: string): Promise<DecodedImage | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve(null); return; }
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
resolve({
|
||||
rgba: new Uint8Array(imageData.data.buffer),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
} catch { resolve(null); }
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import {PDFDocument, rgb} from '@cantoo/pdf-lib';
|
||||
import {PdfAnnotationSubtype} from '@embedpdf/models';
|
||||
import {generateThumbnailWithMetadata} from '@app/utils/thumbnailUtils';
|
||||
import {createChildStub, createProcessedFile} from '@app/contexts/file/fileActions';
|
||||
import {createStirlingFile, FileId, StirlingFile, StirlingFileStub} from '@app/types/fileContext';
|
||||
import type {SignatureAPI} from '@app/components/viewer/viewerTypes';
|
||||
// PDFium annotation subtype constants
|
||||
import {
|
||||
FPDF_ANNOT_INK,
|
||||
FPDF_ANNOT_LINE,
|
||||
embedBitmapImageOnPage,
|
||||
drawPlaceholderRect,
|
||||
decodeImageDataUrl,
|
||||
} from '@app/utils/pdfiumBitmapUtils';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createChildStub, createProcessedFile } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, FileId, StirlingFile, StirlingFileStub } from '@app/types/fileContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import {
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
closeDocAndFreeBuffer,
|
||||
saveRawDocument,
|
||||
} from '@app/services/pdfiumService';
|
||||
|
||||
interface MinimalFileContextSelectors {
|
||||
getAllFileIds: () => FileId[];
|
||||
@@ -37,7 +49,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
|
||||
|
||||
if (signatureApiRef?.current) {
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const totalPages = scrollState.totalPages;
|
||||
|
||||
@@ -47,7 +58,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
if (pageAnnotations && pageAnnotations.length > 0) {
|
||||
const sessionAnnotations = pageAnnotations.filter(annotation => {
|
||||
const hasStoredImageData = annotation.id && getImageData(annotation.id);
|
||||
|
||||
const hasDirectImageData = annotation.imageData || annotation.appearance ||
|
||||
annotation.stampData || annotation.imageSrc ||
|
||||
annotation.contents || annotation.data;
|
||||
@@ -64,7 +74,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually)
|
||||
// Step 2: Delete ONLY session annotations from EmbedPDF before export
|
||||
if (allAnnotations.length > 0 && signatureApiRef?.current) {
|
||||
for (const pageData of allAnnotations) {
|
||||
for (const annotation of pageData.annotations) {
|
||||
@@ -77,7 +87,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations)
|
||||
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF
|
||||
if (!exportActions) {
|
||||
console.error('No export actions available');
|
||||
return null;
|
||||
@@ -85,7 +95,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
const pdfArrayBuffer = await exportActions.saveAsCopy();
|
||||
|
||||
if (pdfArrayBuffer) {
|
||||
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
let currentFile = originalFile;
|
||||
@@ -108,182 +117,85 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
|
||||
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
|
||||
|
||||
// Step 4: Manually render extracted annotations onto the PDF using PDF-lib
|
||||
// Step 4: Manually render extracted annotations onto the PDF using PDFium WASM
|
||||
if (allAnnotations.length > 0) {
|
||||
try {
|
||||
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening);
|
||||
|
||||
let pdfDoc: PDFDocument;
|
||||
try {
|
||||
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
|
||||
ignoreEncryption: true,
|
||||
capNumbers: false,
|
||||
throwOnInvalidObject: false
|
||||
});
|
||||
} catch {
|
||||
console.warn('Failed to load with standard options, trying createProxy...');
|
||||
try {
|
||||
pdfDoc = await PDFDocument.create();
|
||||
const sourcePdf = await PDFDocument.load(pdfArrayBufferForFlattening, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false
|
||||
});
|
||||
const pageIndices = sourcePdf.getPages().map((_, i) => i);
|
||||
const copiedPages = await pdfDoc.copyPages(sourcePdf, pageIndices);
|
||||
copiedPages.forEach(page => pdfDoc.addPage(page));
|
||||
} catch (copyError) {
|
||||
console.error('Failed to load PDF with any method:', copyError);
|
||||
throw copyError;
|
||||
}
|
||||
}
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const pageData of allAnnotations) {
|
||||
const { pageIndex, annotations } = pageData;
|
||||
|
||||
for (const pageData of allAnnotations) {
|
||||
const { pageIndex, annotations } = pageData;
|
||||
if (pageIndex < pageCount) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) continue;
|
||||
|
||||
if (pageIndex < pages.length) {
|
||||
const page = pages[pageIndex];
|
||||
const { height: pageHeight } = page.getSize();
|
||||
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||
|
||||
for (const annotation of annotations) {
|
||||
try {
|
||||
for (const annotation of annotations) {
|
||||
try {
|
||||
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||
|
||||
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||
if (rect) {
|
||||
const originalX = rect.origin?.x || rect.x || rect.left || 0;
|
||||
const originalY = rect.origin?.y || rect.y || rect.top || 0;
|
||||
const width = rect.size?.width || rect.width || 100;
|
||||
const height = rect.size?.height || rect.height || 50;
|
||||
|
||||
if (rect) {
|
||||
const originalX = rect.origin?.x || rect.x || rect.left || 0;
|
||||
const originalY = rect.origin?.y || rect.y || rect.top || 0;
|
||||
const width = rect.size?.width || rect.width || 100;
|
||||
const height = rect.size?.height || rect.height || 50;
|
||||
// Convert from CSS top-left to PDF bottom-left
|
||||
const pdfX = originalX;
|
||||
const pdfY = pageHeight - originalY - height;
|
||||
|
||||
const pdfX = originalX;
|
||||
const pdfY = pageHeight - originalY - height;
|
||||
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
|
||||
annotation.imageSrc || annotation.contents || annotation.data;
|
||||
|
||||
|
||||
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
|
||||
annotation.imageSrc || annotation.contents || annotation.data;
|
||||
|
||||
if (!imageDataUrl && annotation.id) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (storedImageData) {
|
||||
imageDataUrl = storedImageData;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image/svg+xml')) {
|
||||
let svgRendered = false;
|
||||
try {
|
||||
const svgContent = decodeSvgDataUrl(imageDataUrl);
|
||||
if (svgContent && typeof (page as any).drawSvg === 'function') {
|
||||
// drawSvg from @cantoo/pdf-lib renders SVG natively as
|
||||
(page as any).drawSvg(svgContent, {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: width,
|
||||
height: height,
|
||||
});
|
||||
svgRendered = true;
|
||||
}
|
||||
} catch (svgError) {
|
||||
console.warn('Native SVG embed failed, falling back to raster:', svgError);
|
||||
}
|
||||
|
||||
if (!svgRendered) {
|
||||
try {
|
||||
const pngBytes = await rasteriseSvgToPng(imageDataUrl, width * 2, height * 2);
|
||||
if (pngBytes) {
|
||||
const image = await pdfDoc.embedPng(pngBytes);
|
||||
page.drawImage(image, { x: pdfX, y: pdfY, width, height });
|
||||
svgRendered = true;
|
||||
}
|
||||
} catch (rasterError) {
|
||||
console.error('SVG raster fallback also failed:', rasterError);
|
||||
if (!imageDataUrl && annotation.id) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (storedImageData) {
|
||||
imageDataUrl = storedImageData;
|
||||
}
|
||||
}
|
||||
|
||||
if (!svgRendered) {
|
||||
page.drawRectangle({
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: width,
|
||||
height: height,
|
||||
borderColor: rgb(0.8, 0, 0),
|
||||
borderWidth: 1,
|
||||
color: rgb(1, 0.95, 0.95),
|
||||
opacity: 0.7,
|
||||
});
|
||||
}
|
||||
} else if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
|
||||
try {
|
||||
const base64Data = imageDataUrl.split(',')[1];
|
||||
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
||||
|
||||
let image;
|
||||
if (imageDataUrl.includes('data:image/jpeg') || imageDataUrl.includes('data:image/jpg')) {
|
||||
image = await pdfDoc.embedJpg(imageBytes);
|
||||
} else if (imageDataUrl.includes('data:image/png')) {
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
// Convert SVG to PNG first if needed
|
||||
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image/svg+xml')) {
|
||||
const pngBytes = await rasteriseSvgToPng(imageDataUrl, width * 2, height * 2);
|
||||
if (pngBytes) {
|
||||
imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes);
|
||||
} else {
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
drawPlaceholderRect(m, pagePtr, pdfX, pdfY, width, height);
|
||||
continue;
|
||||
}
|
||||
|
||||
page.drawImage(image, {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: width,
|
||||
height: height,
|
||||
});
|
||||
|
||||
} catch (imageError) {
|
||||
console.error('Failed to render image annotation:', imageError);
|
||||
}
|
||||
} else if (annotation.content || annotation.text) {
|
||||
page.drawText(annotation.content || annotation.text, {
|
||||
x: pdfX,
|
||||
y: pdfY + height - 12, // Adjust for text baseline
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0)
|
||||
});
|
||||
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
|
||||
page.drawRectangle({
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: width,
|
||||
height: height,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: 1,
|
||||
color: rgb(0.95, 0.95, 0.95),
|
||||
opacity: 0.6
|
||||
});
|
||||
} else {
|
||||
page.drawRectangle({
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: width,
|
||||
height: height,
|
||||
borderColor: rgb(1, 0, 0),
|
||||
borderWidth: 2,
|
||||
color: rgb(1, 1, 0),
|
||||
opacity: 0.5
|
||||
});
|
||||
|
||||
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
|
||||
// Decode the image data URL to raw pixels via canvas
|
||||
const imageResult = await decodeImageDataUrl(imageDataUrl);
|
||||
if (imageResult) {
|
||||
embedBitmapImageOnPage(m, docPtr, pagePtr, imageResult, pdfX, pdfY, width, height);
|
||||
}
|
||||
} else if (annotation.type === FPDF_ANNOT_INK || annotation.type === FPDF_ANNOT_LINE) {
|
||||
drawPlaceholderRect(m, pagePtr, pdfX, pdfY, width, height);
|
||||
}
|
||||
}
|
||||
} catch (annotationError) {
|
||||
console.warn('Failed to render annotation:', annotationError);
|
||||
}
|
||||
} catch (annotationError) {
|
||||
console.warn('Failed to render annotation:', annotationError);
|
||||
}
|
||||
|
||||
m.FPDFPage_GenerateContent(pagePtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
}
|
||||
|
||||
const resultBuf = await saveRawDocument(docPtr);
|
||||
signedFile = new File([resultBuf], currentFile.name, { type: 'application/pdf' });
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
|
||||
|
||||
const flattenedPdfBytes = await pdfDoc.save({ useObjectStreams: false, addDefaultPage: false });
|
||||
|
||||
const arrayBuffer = new ArrayBuffer(flattenedPdfBytes.length);
|
||||
const uint8View = new Uint8Array(arrayBuffer);
|
||||
uint8View.set(flattenedPdfBytes);
|
||||
signedFile = new File([arrayBuffer], currentFile.name, { type: 'application/pdf' });
|
||||
|
||||
} catch (renderError) {
|
||||
console.error('Failed to manually render annotations:', renderError);
|
||||
console.warn('Signatures may only show as annotations');
|
||||
@@ -325,26 +237,19 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an SVG data URL to its raw XML string.
|
||||
* Handles both base64-encoded and URI-encoded SVG data URLs.
|
||||
* Convert Uint8Array PNG bytes to a data URL for canvas decoding.
|
||||
*/
|
||||
function decodeSvgDataUrl(dataUrl: string): string | null {
|
||||
try {
|
||||
if (dataUrl.includes(';base64,')) {
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
return atob(base64);
|
||||
}
|
||||
// URI-encoded SVG
|
||||
const encoded = dataUrl.split(',')[1];
|
||||
return decodeURIComponent(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const blob = new Blob([pngBytes as BlobPart], { type: 'image/png' });
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterise an SVG data URL to PNG bytes via an offscreen canvas.
|
||||
* Used as a fallback when native SVG embedding is unavailable.
|
||||
*/
|
||||
function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): Promise<Uint8Array | null> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -355,17 +260,11 @@ function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): P
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (!ctx) { resolve(null); return; }
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (!blob) { resolve(null); return; }
|
||||
blob.arrayBuffer().then(
|
||||
(buf) => resolve(new Uint8Array(buf)),
|
||||
() => resolve(null),
|
||||
@@ -373,9 +272,7 @@ function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): P
|
||||
},
|
||||
'image/png',
|
||||
);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
} catch { resolve(null); }
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = svgDataUrl;
|
||||
|
||||
Reference in New Issue
Block a user