mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(pdf): replace PdfLib with Pdfium for form handling and general rendering tasks (#5899)
# Description of Changes Improves PDF rendering in the viewer by adding digital signature field support, cleaning up overlay rendering, and migrating the contrast tool off pdf-lib to PDFium WASM. ### Signature Field Overlay - Added `SignatureFieldOverlay` component that renders digital signature form fields - Renders appearance streams when present; shows a fallback badge for unsigned fields - Uses PDFium WASM for bitmap extraction ### Overlay Rendering - Integrated `SignatureFieldOverlay` and `ButtonAppearanceOverlay` into `LocalEmbedPDF` - Overlays are now clipped to page boundaries - Clarified in `EmbedPdfViewer` that frontend overlays use PDFium WASM, backend overlays use PDFBox ### Contrast Tool Migration - Replaced pdf-lib with PDFium WASM in `useAdjustContrastOperation` - PDF page creation and image embedding now go through PDFium APIs directly - Updated bitmap handling and memory management accordingly ### Cleanup - Fixed import ordering in viewer components - Removed stale comments in the contrast operation hook <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs <[email protected]> Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
@@ -20,12 +20,11 @@ import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
|
|||||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||||
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
|
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 { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||||
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
||||||
|
|
||||||
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
|
|
||||||
|
|
||||||
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
||||||
|
|
||||||
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
|
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;
|
if (!(measureObj instanceof PDFDict)) return null;
|
||||||
const rObj = measureObj.lookup(PDFName.of('R'));
|
const rObj = measureObj.lookup(PDFName.of('R'));
|
||||||
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
|
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
|
||||||
? rObj.decodeText() : '';
|
? rObj.decodeText() : '';
|
||||||
// D = distance array, X = x-axis fallback
|
// D = distance array, X = x-axis fallback
|
||||||
let fmtArray = measureObj.lookup(PDFName.of('D'));
|
let fmtArray = measureObj.lookup(PDFName.of('D'));
|
||||||
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
|
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
|
||||||
@@ -213,7 +212,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
const isFormFillToolActive = (selectedTool as string) === 'formFill';
|
const isFormFillToolActive = (selectedTool as string) === 'formFill';
|
||||||
|
|
||||||
// Form overlays are shown in BOTH modes:
|
// 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)
|
// - formFill tool: form overlays visible (PDFBox, backend)
|
||||||
const shouldEnableFormFill = true;
|
const shouldEnableFormFill = true;
|
||||||
|
|
||||||
@@ -881,7 +880,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
||||||
|
|
||||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
// 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).
|
// In formFill tool mode, this uses PDFBox (backend).
|
||||||
const formFillFileIdRef = useRef<string | null>(null);
|
const formFillFileIdRef = useRef<string | null>(null);
|
||||||
const formFillProviderRef = useRef(isFormFillToolActive);
|
const formFillProviderRef = useRef(isFormFillToolActive);
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
|
|||||||
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
|
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
|
||||||
import { absoluteWithBasePath } from '@app/constants/app';
|
import { absoluteWithBasePath } from '@app/constants/app';
|
||||||
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
|
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 { CommentsSidebar } from '@app/components/viewer/CommentsSidebar';
|
||||||
import { CommentAuthorProvider } from '@app/contexts/CommentAuthorContext';
|
import { CommentAuthorProvider } from '@app/contexts/CommentAuthorContext';
|
||||||
import { accountService } from '@app/services/accountService';
|
import { accountService } from '@app/services/accountService';
|
||||||
@@ -770,6 +772,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
overflow: 'hidden', // clip overlays (buttons, fields) that extend beyond the page rect
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
MozUserSelect: 'none',
|
MozUserSelect: 'none',
|
||||||
@@ -790,6 +793,16 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
</div>
|
</div>
|
||||||
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
|
<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 */}
|
{/* FormFieldOverlay for interactive form filling */}
|
||||||
{enableFormFill && (
|
{enableFormFill && (
|
||||||
<FormFieldOverlay
|
<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 */}
|
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
||||||
{(enableAnnotations || enableRedaction) && (
|
{(enableAnnotations || enableRedaction) && (
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* SignatureFieldOverlay — Renders digital-signature form fields on top of a
|
||||||
|
* PDF page.
|
||||||
|
*
|
||||||
|
* When a signature widget has an appearance stream (i.e. a visible graphic
|
||||||
|
* embedded by the signing tool), we render it via `EPDF_RenderAnnotBitmap`
|
||||||
|
* (an @embedpdf PDFium WASM extension) and paint the result into a `<canvas>`
|
||||||
|
* positioned at the correct overlay location. This is the same rendering
|
||||||
|
* path the engine itself uses for individual annotation bitmaps.
|
||||||
|
*
|
||||||
|
* For widgets without an appearance stream (unsigned fields, or fields whose
|
||||||
|
* PDF writer didn't embed one), we fall back to a translucent badge overlay.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||||
|
import {
|
||||||
|
renderSignatureFieldAppearances,
|
||||||
|
extractSignatures,
|
||||||
|
type SignatureFieldAppearance,
|
||||||
|
} from '@app/services/pdfiumService';
|
||||||
|
|
||||||
|
interface SignatureFieldOverlayProps {
|
||||||
|
pageIndex: number;
|
||||||
|
/** URL or File for the current PDF — used to extract signature data. */
|
||||||
|
pdfSource: File | Blob | null;
|
||||||
|
/** Document ID from EmbedPDF (kept for caller compatibility). */
|
||||||
|
documentId: string;
|
||||||
|
/** Rendered page width from Scroller (pixel space). */
|
||||||
|
pageWidth: number;
|
||||||
|
/** Rendered page height from Scroller (pixel space). */
|
||||||
|
pageHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedSignatureField extends SignatureFieldAppearance {
|
||||||
|
/** Whether a cryptographic signature was found for this field. */
|
||||||
|
isSigned: boolean;
|
||||||
|
/** Signer reason string (if available). */
|
||||||
|
reason?: string;
|
||||||
|
/** Signing time string (if available). */
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
let _cachedSource: File | Blob | null = null;
|
||||||
|
let _cachedFields: ResolvedSignatureField[] = [];
|
||||||
|
let _cachePromise: Promise<ResolvedSignatureField[]> | null = null;
|
||||||
|
|
||||||
|
async function resolveFields(
|
||||||
|
source: File | Blob,
|
||||||
|
): Promise<ResolvedSignatureField[]> {
|
||||||
|
if (source === _cachedSource && _cachePromise) return _cachePromise;
|
||||||
|
_cachedSource = source;
|
||||||
|
|
||||||
|
_cachePromise = (async () => {
|
||||||
|
const buf = await source.arrayBuffer();
|
||||||
|
const [appearances, signatures] = await Promise.all([
|
||||||
|
renderSignatureFieldAppearances(buf),
|
||||||
|
extractSignatures(buf),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return appearances.map((f, i) => {
|
||||||
|
// Positional correlation is only reliable when both arrays have the same
|
||||||
|
// length — i.e. one signature object per signature field in document order.
|
||||||
|
// When the counts differ we cannot safely attribute reason/time per-field,
|
||||||
|
// so we fall back to a whole-document "is signed" indicator.
|
||||||
|
const exactMatch = appearances.length === signatures.length;
|
||||||
|
const matchedSig = exactMatch ? signatures[i] : undefined;
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
isSigned: exactMatch ? i < signatures.length : signatures.length > 0,
|
||||||
|
reason: matchedSig?.reason,
|
||||||
|
time: matchedSig?.time,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
_cachedFields = await _cachePromise;
|
||||||
|
return _cachedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureBitmapCanvas({
|
||||||
|
imageData,
|
||||||
|
cssWidth,
|
||||||
|
cssHeight,
|
||||||
|
}: {
|
||||||
|
imageData: ImageData;
|
||||||
|
cssWidth: number;
|
||||||
|
cssHeight: number;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
canvas.width = imageData.width;
|
||||||
|
canvas.height = imageData.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) ctx.putImageData(imageData, 0, 0);
|
||||||
|
}, [imageData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
width: cssWidth,
|
||||||
|
height: cssHeight,
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureFieldOverlayInner({
|
||||||
|
pageIndex,
|
||||||
|
pdfSource,
|
||||||
|
documentId: _documentId,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
}: SignatureFieldOverlayProps) {
|
||||||
|
const [fields, setFields] = useState<ResolvedSignatureField[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pdfSource) {
|
||||||
|
setFields([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
resolveFields(pdfSource).then((res) => {
|
||||||
|
if (!cancelled) setFields(res);
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setFields([]);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [pdfSource]);
|
||||||
|
|
||||||
|
const pageFields = useMemo(
|
||||||
|
() => fields.filter((f) => f.pageIndex === pageIndex),
|
||||||
|
[fields, pageIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pageFields.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 6,
|
||||||
|
}}
|
||||||
|
data-signature-overlay-page={pageIndex}
|
||||||
|
>
|
||||||
|
{pageFields.map((field, idx) => {
|
||||||
|
// Use the source PDF page dimensions that the extraction used for
|
||||||
|
// coordinate computation. This avoids mismatches with pdfPage.size
|
||||||
|
// from EmbedPDF which may report different dimensions.
|
||||||
|
const sx = field.sourcePageWidth > 0 ? pageWidth / field.sourcePageWidth : 1;
|
||||||
|
const sy = field.sourcePageHeight > 0 ? pageHeight / field.sourcePageHeight : 1;
|
||||||
|
const left = field.x * sx;
|
||||||
|
const top = field.y * sy;
|
||||||
|
const width = field.width * sx;
|
||||||
|
const height = field.height * sy;
|
||||||
|
|
||||||
|
// If we have a rendered appearance bitmap, paint it via <canvas>.
|
||||||
|
if (field.imageData) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`sig-${field.fieldName}-${idx}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
field.isSigned
|
||||||
|
? `Signed${field.reason ? `: ${field.reason}` : ''}${field.time ? ` (${field.time})` : ''}`
|
||||||
|
: `Signature field: ${field.fieldName}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SignatureBitmapCanvas
|
||||||
|
imageData={field.imageData}
|
||||||
|
cssWidth={width}
|
||||||
|
cssHeight={height}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: translucent badge for fields without an appearance.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`sig-${field.fieldName}-${idx}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
border: field.isSigned
|
||||||
|
? '2px solid rgba(34, 139, 34, 0.7)'
|
||||||
|
: '2px dashed rgba(180, 180, 180, 0.7)',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: field.isSigned
|
||||||
|
? 'rgba(34, 139, 34, 0.08)'
|
||||||
|
: 'rgba(200, 200, 200, 0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
field.isSigned
|
||||||
|
? `Signed${field.reason ? `: ${field.reason}` : ''}${field.time ? ` (${field.time})` : ''}`
|
||||||
|
: `Unsigned signature field: ${field.fieldName}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: Math.min(height * 0.35, 14),
|
||||||
|
color: field.isSigned ? 'rgba(34, 139, 34, 0.85)' : 'rgba(120, 120, 120, 0.85)',
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
padding: '2px 4px',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{field.isSigned ? '🔒 Signed' : '✎ Signature'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignatureFieldOverlay = memo(SignatureFieldOverlayInner);
|
||||||
|
export default SignatureFieldOverlay;
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
|
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters';
|
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 { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
|
||||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||||
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
|
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> {
|
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
|
||||||
const page = await pdf.getPage(pageNumber);
|
const page = await pdf.getPage(pageNumber);
|
||||||
@@ -18,37 +22,94 @@ async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjustment logic moved to shared util
|
// Render, adjust, and assemble all pages of a single PDF into a new PDF using PDFium
|
||||||
|
|
||||||
// Render, adjust, and assemble all pages of a single PDF into a new PDF
|
|
||||||
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
|
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
|
||||||
|
const m = await getPdfiumModule();
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
|
||||||
const pageCount = pdf.numPages;
|
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++) {
|
try {
|
||||||
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
|
for (let p = 1; p <= pageCount; p++) {
|
||||||
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
|
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
|
||||||
const pngUrl = adjusted.toDataURL('image/png');
|
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
|
||||||
const res = await fetch(pngUrl);
|
const ctx = adjusted.getContext('2d');
|
||||||
const pngBytes = new Uint8Array(await res.arrayBuffer());
|
if (!ctx) {
|
||||||
const embedded = await newDoc.embedPng(pngBytes);
|
console.warn(`[adjustContrast] Skipping page ${p}: failed to get canvas context`);
|
||||||
const { width, height } = embedded.scale(1);
|
continue;
|
||||||
const page = newDoc.addPage([width, height]);
|
}
|
||||||
page.drawImage(embedded, { x: 0, y: 0, width, height });
|
|
||||||
|
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> {
|
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;
|
let CONCURRENCY_LIMIT = 2;
|
||||||
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
|
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
|
||||||
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
|
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
|
||||||
@@ -85,7 +146,6 @@ export const adjustContrastOperationConfig = {
|
|||||||
customProcessor: processPdfClientSide,
|
customProcessor: processPdfClientSide,
|
||||||
operationType: 'adjustContrast',
|
operationType: 'adjustContrast',
|
||||||
defaultParameters,
|
defaultParameters,
|
||||||
// Single-step settings component for Automate
|
|
||||||
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
|
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -96,4 +156,3 @@ export const useAdjustContrastOperation = () => {
|
|||||||
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
|
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 { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
||||||
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
|
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
|
||||||
import { PDFDocument, PDFName, PDFRef, PDFDict } from '@cantoo/pdf-lib';
|
import {
|
||||||
// Client-side PDF processing using PDF-lib
|
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 removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<CustomProcessorResult> => {
|
||||||
const processedFiles: File[] = [];
|
const processedFiles: File[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
// Load the PDF
|
const m = await getPdfiumModule();
|
||||||
const fileArrayBuffer = await file.arrayBuffer();
|
const fileArrayBuffer = await file.arrayBuffer();
|
||||||
const pdfBytesIn = new Uint8Array(fileArrayBuffer);
|
const docPtr = await openRawDocumentSafe(fileArrayBuffer);
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytesIn, { ignoreEncryption: true });
|
|
||||||
const ctx = pdfDoc.context;
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const catalog = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Root);
|
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||||
if (catalog && 'has' in catalog && 'delete' in catalog) {
|
|
||||||
const catalogDict = catalog as any;
|
for (let i = 0; i < pageCount; i++) {
|
||||||
if (catalogDict.has(PDFName.of('AcroForm'))) {
|
const pagePtr = m.FPDF_LoadPage(docPtr, i);
|
||||||
catalogDict.delete(PDFName.of('AcroForm'));
|
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) {
|
} catch (error) {
|
||||||
console.error('Error processing file:', file.name, error);
|
console.error('Error processing file:', file.name, error);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
+4
-4
@@ -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 { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
|
||||||
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
||||||
|
|
||||||
interface DrawCenteredMessageOptions {
|
interface DrawCenteredMessageOptions {
|
||||||
page: PDFPage;
|
page: PdfiumPage;
|
||||||
font: PDFFont;
|
font: PdfiumFont;
|
||||||
fontBold: PDFFont;
|
fontBold: PdfiumFont;
|
||||||
text: string;
|
text: string;
|
||||||
description: string;
|
description: string;
|
||||||
marginX: number;
|
marginX: number;
|
||||||
|
|||||||
+4
-4
@@ -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 { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
|
||||||
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
||||||
|
|
||||||
interface FieldBoxOptions {
|
interface FieldBoxOptions {
|
||||||
page: PDFPage;
|
page: PdfiumPage;
|
||||||
font: PDFFont;
|
font: PdfiumFont;
|
||||||
fontBold: PDFFont;
|
fontBold: PdfiumFont;
|
||||||
x: number;
|
x: number;
|
||||||
top: number;
|
top: number;
|
||||||
width: number;
|
width: number;
|
||||||
|
|||||||
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
import type { TFunction } from 'i18next';
|
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 { SignatureValidationSignature } from '@app/types/validateSignature';
|
||||||
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
|
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
|
||||||
import { drawStatusBadge } from '@app/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection';
|
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';
|
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
||||||
|
|
||||||
interface DrawSignatureSectionOptions {
|
interface DrawSignatureSectionOptions {
|
||||||
page: PDFPage;
|
page: PdfiumPage;
|
||||||
cursorY: number;
|
cursorY: number;
|
||||||
signature: SignatureValidationSignature;
|
signature: SignatureValidationSignature;
|
||||||
index: number;
|
index: number;
|
||||||
marginX: number;
|
marginX: number;
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
columnGap: number;
|
columnGap: number;
|
||||||
font: PDFFont;
|
font: PdfiumFont;
|
||||||
fontBold: PDFFont;
|
fontBold: PdfiumFont;
|
||||||
t: TFunction<'translation'>;
|
t: TFunction<'translation'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export const drawSignatureSection = ({
|
|||||||
thickness: 1,
|
thickness: 1,
|
||||||
color: colorPalette.boxBorder,
|
color: colorPalette.boxBorder,
|
||||||
});
|
});
|
||||||
nextY -= 20;
|
nextY -= 20;
|
||||||
|
|
||||||
const certificateFields = [
|
const certificateFields = [
|
||||||
{ label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },
|
{ label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },
|
||||||
|
|||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
|
import { PdfiumFont, PdfiumPage, rgb } from '@app/services/pdfiumDocBuilder';
|
||||||
|
|
||||||
interface StatusBadgeOptions {
|
interface StatusBadgeOptions {
|
||||||
page: PDFPage;
|
page: PdfiumPage;
|
||||||
font: PDFFont;
|
font: PdfiumFont;
|
||||||
fontBold: PDFFont;
|
fontBold: PdfiumFont;
|
||||||
text: string;
|
text: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
import type { TFunction } from 'i18next';
|
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 { SignatureValidationReportEntry } from '@app/types/validateSignature';
|
||||||
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
|
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
|
||||||
import { drawThumbnailImage, drawThumbnailPlaceholder } from '@app/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection';
|
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';
|
import { formatFileSize } from '@app/hooks/tools/validateSignature/utils/pdfText';
|
||||||
|
|
||||||
interface DrawSummarySectionOptions {
|
interface DrawSummarySectionOptions {
|
||||||
page: PDFPage;
|
page: PdfiumPage;
|
||||||
cursorY: number;
|
cursorY: number;
|
||||||
entry: SignatureValidationReportEntry;
|
entry: SignatureValidationReportEntry;
|
||||||
font: PDFFont;
|
font: PdfiumFont;
|
||||||
fontBold: PDFFont;
|
fontBold: PdfiumFont;
|
||||||
marginX: number;
|
marginX: number;
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
columnGap: number;
|
columnGap: number;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
statusColor: (typeof colorPalette)['success'];
|
statusColor: (typeof colorPalette)['success'];
|
||||||
loadThumbnail: (url: string) => Promise<{ image: PDFImage } | null>;
|
loadThumbnail: (url: string) => Promise<{ image: PdfiumImage } | null>;
|
||||||
t: TFunction<'translation'>;
|
t: TFunction<'translation'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -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';
|
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
||||||
|
|
||||||
export const drawThumbnailPlaceholder = (
|
export const drawThumbnailPlaceholder = (
|
||||||
page: PDFPage,
|
page: PdfiumPage,
|
||||||
fontBold: PDFFont,
|
fontBold: PdfiumFont,
|
||||||
x: number,
|
x: number,
|
||||||
top: number,
|
top: number,
|
||||||
width: number,
|
width: number,
|
||||||
@@ -35,8 +35,8 @@ export const drawThumbnailPlaceholder = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const drawThumbnailImage = (
|
export const drawThumbnailImage = (
|
||||||
page: PDFPage,
|
page: PdfiumPage,
|
||||||
image: PDFImage,
|
image: PdfiumImage,
|
||||||
x: number,
|
x: number,
|
||||||
top: number,
|
top: number,
|
||||||
width: 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 type { TFunction } from 'i18next';
|
||||||
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
|
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
|
||||||
import { REPORT_PDF_FILENAME } from '@app/hooks/tools/validateSignature/utils/signatureUtils';
|
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 CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
|
||||||
const COLUMN_GAP = 18;
|
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({
|
page.drawLine({
|
||||||
start: { x: marginX, y },
|
start: { x: marginX, y },
|
||||||
end: { x: marginX + contentWidth, y },
|
end: { x: marginX + contentWidth, y },
|
||||||
@@ -29,7 +29,7 @@ export const createReportPdf = async (
|
|||||||
entries: SignatureValidationReportEntry[],
|
entries: SignatureValidationReportEntry[],
|
||||||
t: TFunction<'translation'>
|
t: TFunction<'translation'>
|
||||||
): Promise<File> => {
|
): Promise<File> => {
|
||||||
const doc = await PDFDocument.create();
|
const doc = await PdfiumDocument.create();
|
||||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||||
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
|
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
|
||||||
const loadThumbnail = createThumbnailLoader(doc);
|
const loadThumbnail = createThumbnailLoader(doc);
|
||||||
@@ -101,7 +101,6 @@ export const createReportPdf = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < entry.signatures.length; i += 1) {
|
for (let i = 0; i < entry.signatures.length; i += 1) {
|
||||||
// After the first signature, start a new page per signature
|
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
({ page, cursorY } = startReportPage({
|
({ page, cursorY } = startReportPage({
|
||||||
doc,
|
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 type { TFunction } from 'i18next';
|
||||||
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
|
||||||
|
|
||||||
interface StartPageParams {
|
interface StartPageParams {
|
||||||
doc: PDFDocument;
|
doc: PdfiumDocument;
|
||||||
font: PDFFont;
|
font: PdfiumFont;
|
||||||
fontBold: PDFFont;
|
fontBold: PdfiumFont;
|
||||||
marginX: number;
|
marginX: number;
|
||||||
marginY: number;
|
marginY: number;
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
@@ -63,8 +63,8 @@ export const startReportPage = ({
|
|||||||
return { page, cursorY };
|
return { page, cursorY };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createThumbnailLoader = (doc: PDFDocument) => {
|
export const createThumbnailLoader = (doc: PdfiumDocument) => {
|
||||||
const cache = new Map<string, { image: PDFImage } | null>();
|
const cache = new Map<string, { image: PdfiumImage } | null>();
|
||||||
|
|
||||||
return async (url: string) => {
|
return async (url: string) => {
|
||||||
if (cache.has(url)) {
|
if (cache.has(url)) {
|
||||||
@@ -75,7 +75,7 @@ export const createThumbnailLoader = (doc: PDFDocument) => {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
let image: PDFImage;
|
let image: PdfiumImage;
|
||||||
|
|
||||||
if (contentType.includes('png')) {
|
if (contentType.includes('png')) {
|
||||||
image = await doc.embedPng(bytes);
|
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];
|
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);
|
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.
|
* Falls back to sensible defaults when the CSS variable cannot be resolved.
|
||||||
*/
|
*/
|
||||||
function getCssVariableAsRgb(variableName: string, fallback: RgbTuple) {
|
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 lines: string[] = [];
|
||||||
const paragraphs = text.split(/\r?\n/);
|
const paragraphs = text.split(/\r?\n/);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
|
||||||
PDFDocument,
|
|
||||||
} from '@cantoo/pdf-lib';
|
|
||||||
import {
|
import {
|
||||||
PdfLibLink,
|
PdfLibLink,
|
||||||
extractLinksFromPage,
|
extractLinksFromPage,
|
||||||
@@ -20,14 +17,14 @@ export interface PdfLibLinksResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CachedDoc {
|
interface CachedDoc {
|
||||||
doc: PDFDocument;
|
data: ArrayBuffer;
|
||||||
/** Number of active consumers (hook instances) holding this entry. */
|
/** Number of active consumers (hook instances) holding this entry. */
|
||||||
refCount: number;
|
refCount: number;
|
||||||
/** Per-page extracted links (lazy, filled on first request). */
|
/** Per-page extracted links (lazy, filled on first request). */
|
||||||
pageLinks: Map<number, { links: PdfLibLink[]; width: number; height: number }>;
|
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. */
|
* skip link extraction on all subsequent calls without retrying. */
|
||||||
invalidCatalog?: boolean;
|
invalidDocument?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docCache = new Map<string, Promise<CachedDoc>>();
|
const docCache = new Map<string, Promise<CachedDoc>>();
|
||||||
@@ -37,13 +34,8 @@ async function acquireDocument(url: string): Promise<CachedDoc> {
|
|||||||
const promise = (async (): Promise<CachedDoc> => {
|
const promise = (async (): Promise<CachedDoc> => {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const buffer = await response.arrayBuffer();
|
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);
|
docCache.set(url, promise);
|
||||||
|
|
||||||
@@ -107,7 +99,7 @@ export function usePdfLibLinks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cached.invalidCatalog) {
|
if (cached.invalidDocument) {
|
||||||
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
|
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
|
||||||
releaseDocument(url);
|
releaseDocument(url);
|
||||||
return;
|
return;
|
||||||
@@ -115,27 +107,12 @@ export function usePdfLibLinks(
|
|||||||
|
|
||||||
let pageData = cached.pageLinks.get(pageIndex);
|
let pageData = cached.pageLinks.get(pageIndex);
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
let pageCount: number;
|
|
||||||
try {
|
try {
|
||||||
pageCount = cached.doc.getPageCount();
|
const { links, pdfPageWidth, pdfPageHeight } = await extractLinksFromPage(
|
||||||
} catch {
|
cached.data,
|
||||||
cached.invalidCatalog = true;
|
pageIndex,
|
||||||
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
|
);
|
||||||
releaseDocument(url);
|
pageData = { links, width: pdfPageWidth, height: pdfPageHeight };
|
||||||
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 };
|
|
||||||
cached.pageLinks.set(pageIndex, pageData);
|
cached.pageLinks.set(pageIndex, pageData);
|
||||||
} catch (pageError) {
|
} catch (pageError) {
|
||||||
console.warn(`[usePdfLibLinks] Failed to read page ${pageIndex}:`, 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 { downloadFile } from '@app/services/downloadService';
|
||||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
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 {
|
export interface ExportOptions {
|
||||||
selectedOnly?: boolean;
|
selectedOnly?: boolean;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
@@ -19,7 +31,6 @@ export class PDFExportService {
|
|||||||
const { selectedOnly = false, filename } = options;
|
const { selectedOnly = false, filename } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine which pages to export
|
|
||||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||||
: pdfDocument.pages;
|
: pdfDocument.pages;
|
||||||
@@ -28,10 +39,8 @@ export class PDFExportService {
|
|||||||
throw new Error('No pages to export');
|
throw new Error('No pages to export');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load original PDF and create new document
|
|
||||||
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||||
const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
|
const blob = await this.createSingleDocument(originalPDFBytes, pagesToExport);
|
||||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
|
||||||
const exportFilename = this.generateFilename(filename || pdfDocument.name);
|
const exportFilename = this.generateFilename(filename || pdfDocument.name);
|
||||||
|
|
||||||
return { blob, filename: exportFilename };
|
return { blob, filename: exportFilename };
|
||||||
@@ -56,7 +65,6 @@ export class PDFExportService {
|
|||||||
const { selectedOnly = false, filename } = options;
|
const { selectedOnly = false, filename } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine which pages to export
|
|
||||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||||
: pdfDocument.pages;
|
: 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(
|
private async createMultiSourceDocument(
|
||||||
sourceFiles: Map<string, File>,
|
sourceFiles: Map<string, File>,
|
||||||
pages: PDFPage[]
|
pages: PDFPage[]
|
||||||
): Promise<Blob> {
|
): 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
|
// 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 {
|
||||||
try {
|
for (const [fileId, file] of sourceFiles) {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
try {
|
||||||
const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
loadedDocs.set(fileId, doc);
|
const docPtr = await openRawDocumentSafe(arrayBuffer);
|
||||||
} catch (error) {
|
loadedDocs.set(fileId, docPtr);
|
||||||
console.warn(`Failed to load source file ${fileId}:`, error);
|
} 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);
|
|
||||||
}
|
}
|
||||||
} 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(
|
private async createSingleDocument(
|
||||||
sourceDoc: PDFLibDocument,
|
sourceData: ArrayBuffer,
|
||||||
pages: PDFPage[]
|
pages: PDFPage[]
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const newDoc = await PDFLibDocument.create();
|
const m = await getPdfiumModule();
|
||||||
|
|
||||||
for (const page of pages) {
|
// Open source document
|
||||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
const srcDocPtr = await openRawDocumentSafe(sourceData);
|
||||||
// Create a blank page
|
const destDocPtr = m.FPDF_CreateNewDocument();
|
||||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
if (!destDocPtr) {
|
||||||
|
await closeRawDocument(srcDocPtr);
|
||||||
blankPage.setRotation(degrees(page.rotation));
|
throw new Error('PDFium: failed to create destination document');
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata
|
try {
|
||||||
newDoc.setCreator('Stirling PDF');
|
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
|
||||||
newDoc.setProducer('Stirling PDF');
|
let insertIdx = 0;
|
||||||
newDoc.setCreationDate(new Date());
|
|
||||||
newDoc.setModificationDate(new Date());
|
|
||||||
|
|
||||||
const pdfBytes = await newDoc.save();
|
for (const page of pages) {
|
||||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
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
|
* Generate appropriate filename for export
|
||||||
*/
|
*/
|
||||||
@@ -183,7 +229,6 @@ export class PDFExportService {
|
|||||||
return `${baseName}.pdf`;
|
return `${baseName}.pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a single file
|
* Download a single file
|
||||||
*/
|
*/
|
||||||
@@ -198,7 +243,7 @@ export class PDFExportService {
|
|||||||
blobs.forEach((blob, index) => {
|
blobs.forEach((blob, index) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.downloadFile(blob, filenames[index]);
|
this.downloadFile(blob, filenames[index]);
|
||||||
}, index * 500); // Stagger downloads
|
}, index * 500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +286,8 @@ export class PDFExportService {
|
|||||||
|
|
||||||
const splitCount = pagesToExport.reduce((count, page) => {
|
const splitCount = pagesToExport.reduce((count, page) => {
|
||||||
return count + (page.splitAfter ? 1 : 0);
|
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 avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
|
||||||
const estimatedBytes = avgPageSize * pagesToExport.length;
|
const estimatedBytes = avgPageSize * pagesToExport.length;
|
||||||
const estimatedSize = this.formatFileSize(estimatedBytes);
|
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 singleton instance
|
||||||
export const pdfExportService = new PDFExportService();
|
export const pdfExportService = new PDFExportService();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class PDFWorkerManager {
|
|||||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
).toString();
|
).toString();
|
||||||
|
(GlobalWorkerOptions as any).docBaseUrl = undefined;
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,13 +75,17 @@ class PDFWorkerManager {
|
|||||||
disableAutoFetch: options.disableAutoFetch ?? true,
|
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||||
disableStream: options.disableStream ?? true,
|
disableStream: options.disableStream ?? true,
|
||||||
stopAtErrors: options.stopAtErrors ?? false,
|
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,
|
...pdfData,
|
||||||
disableAutoFetch: options.disableAutoFetch ?? true,
|
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||||
disableStream: options.disableStream ?? true,
|
disableStream: options.disableStream ?? true,
|
||||||
stopAtErrors: options.stopAtErrors ?? false,
|
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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,19 @@ code {
|
|||||||
monospace;
|
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 */
|
/* CSS Variables */
|
||||||
:root {
|
:root {
|
||||||
--footer-height: 2rem;
|
--footer-height: 2rem;
|
||||||
|
|||||||
@@ -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': {
|
case 'radio': {
|
||||||
const radioOptions: { value: string; label: string }[] = [];
|
const radioOptions: { value: string; label: string }[] = [];
|
||||||
if (field.widgets && field.widgets.length > 0) {
|
if (field.widgets && field.widgets.length > 0) {
|
||||||
for (const w of field.widgets) {
|
for (let i = 0; i < field.widgets.length; i++) {
|
||||||
if (w.exportValue && !radioOptions.some((o) => o.value === w.exportValue)) {
|
const w = field.widgets[i];
|
||||||
radioOptions.push({ value: w.exportValue, label: w.exportValue });
|
// 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 (
|
return (
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -20,7 +20,110 @@
|
|||||||
import React, { useCallback, useMemo, memo } from 'react';
|
import React, { useCallback, useMemo, memo } from 'react';
|
||||||
import { useDocumentState } from '@embedpdf/core/react';
|
import { useDocumentState } from '@embedpdf/core/react';
|
||||||
import { useFormFill, useFieldValue } from '@app/tools/formFill/FormFillContext';
|
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 {
|
interface WidgetInputProps {
|
||||||
field: FormField;
|
field: FormField;
|
||||||
@@ -31,6 +134,7 @@ interface WidgetInputProps {
|
|||||||
scaleY: number;
|
scaleY: number;
|
||||||
onFocus: (fieldName: string) => void;
|
onFocus: (fieldName: string) => void;
|
||||||
onChange: (fieldName: string, value: string) => void;
|
onChange: (fieldName: string, value: string) => void;
|
||||||
|
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +151,7 @@ function WidgetInputInner({
|
|||||||
scaleY,
|
scaleY,
|
||||||
onFocus,
|
onFocus,
|
||||||
onChange,
|
onChange,
|
||||||
|
onButtonClick,
|
||||||
}: WidgetInputProps) {
|
}: WidgetInputProps) {
|
||||||
// Per-field value subscription — only this widget re-renders when its value changes
|
// Per-field value subscription — only this widget re-renders when its value changes
|
||||||
const value = useFieldValue(field.name);
|
const value = useFieldValue(field.name);
|
||||||
@@ -287,10 +392,12 @@ function WidgetInputInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'radio': {
|
case 'radio': {
|
||||||
// Each radio widget has an exportValue set by the backend
|
// Identify this widget by its index within the field's widgets array.
|
||||||
const optionValue = widget.exportValue || '';
|
// This avoids issues with duplicate exportValues (e.g., all "Yes").
|
||||||
if (!optionValue) return null; // no export value, skip
|
const widgetIndex = field.widgets?.indexOf(widget) ?? -1;
|
||||||
const isSelected = value === optionValue;
|
if (widgetIndex < 0) return null;
|
||||||
|
const widgetIndexStr = String(widgetIndex);
|
||||||
|
const isSelected = value === widgetIndexStr;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
@@ -304,11 +411,11 @@ function WidgetInputInner({
|
|||||||
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
|
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
|
||||||
cursor: field.readOnly ? 'default' : 'pointer',
|
cursor: field.readOnly ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
title={error || field.tooltip || `${field.label}: ${optionValue}`}
|
title={error || field.tooltip || `${field.label}: ${widget.exportValue || widgetIndexStr}`}
|
||||||
onClick={(e) => {
|
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();
|
handleFocus();
|
||||||
onChange(field.name, optionValue);
|
onChange(field.name, widgetIndexStr);
|
||||||
stopPropagation(e);
|
stopPropagation(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -317,8 +424,8 @@ function WidgetInputInner({
|
|||||||
width: Math.min(width, height) * 0.8,
|
width: Math.min(width, height) * 0.8,
|
||||||
height: Math.min(width, height) * 0.8,
|
height: Math.min(width, height) * 0.8,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
|
border: `1.5px solid ${isSelected ? '#2196F3' : isActive ? '#2196F3' : '#999'}`,
|
||||||
background: isSelected ? '#2196F3' : '#FFF',
|
background: isSelected ? '#2196F3' : 'transparent',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -331,44 +438,58 @@ function WidgetInputInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'signature':
|
case 'signature':
|
||||||
// Signed signatures have a pre-rendered appearance image; render it.
|
// Signature fields are handled entirely by SignatureFieldOverlay (bitmap canvas).
|
||||||
if (field.appearanceDataUrl) {
|
// Rendering a placeholder here creates a visible grey overlay on top of the
|
||||||
return (
|
// signature appearance, so we skip it entirely.
|
||||||
<img
|
return null;
|
||||||
src={field.appearanceDataUrl}
|
|
||||||
style={{
|
case 'button': {
|
||||||
position: 'absolute',
|
// Transparent hit-target only — visual appearance is rendered by ButtonAppearanceOverlay
|
||||||
left,
|
// (which paints the PDF's native /AP bitmap onto a canvas behind this div).
|
||||||
top,
|
const buttonLabel = field.buttonLabel || field.value || field.label || 'Button';
|
||||||
width,
|
const isClickable = !field.readOnly;
|
||||||
height,
|
|
||||||
zIndex: 10,
|
let actionHint = '';
|
||||||
pointerEvents: 'none',
|
if (field.buttonAction) {
|
||||||
userSelect: 'none',
|
switch (field.buttonAction.type) {
|
||||||
display: 'block',
|
case 'named': actionHint = field.buttonAction.namedAction ?? ''; break;
|
||||||
}}
|
case 'resetForm': actionHint = 'Reset Form'; break;
|
||||||
alt={field.label || 'Signature'}
|
case 'submitForm': actionHint = `Submit to: ${field.buttonAction.url ?? ''}`.trim(); break;
|
||||||
draggable={false}
|
case 'uri': actionHint = field.buttonAction.url ?? ''; break;
|
||||||
/>
|
case 'javascript': actionHint = 'Script'; break;
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
// Unsigned signature — fall through to placeholder
|
const titleText = field.tooltip || (actionHint ? `${buttonLabel} (${actionHint})` : buttonLabel);
|
||||||
// falls through
|
|
||||||
case 'button':
|
|
||||||
// Just render a highlighted area — not editable
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
style={{
|
style={{
|
||||||
...commonStyle,
|
...commonStyle,
|
||||||
background: 'rgba(200,200,200,0.3)',
|
background: 'transparent',
|
||||||
border: '1px dashed #999',
|
border: 'none',
|
||||||
cursor: 'default',
|
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:
|
default:
|
||||||
return (
|
return (
|
||||||
@@ -407,6 +528,7 @@ export function FormFieldOverlay({
|
|||||||
}: FormFieldOverlayProps) {
|
}: FormFieldOverlayProps) {
|
||||||
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
||||||
const { activeFieldName, validationErrors } = state;
|
const { activeFieldName, validationErrors } = state;
|
||||||
|
const { printActions, scrollActions, exportActions } = useViewer();
|
||||||
|
|
||||||
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||||
// NOTE: All hooks must be called unconditionally (before any early returns)
|
// NOTE: All hooks must be called unconditionally (before any early returns)
|
||||||
@@ -416,20 +538,28 @@ export function FormFieldOverlay({
|
|||||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||||
const s = documentState?.scale ?? 1;
|
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 };
|
return { scaleX: s, scaleY: s };
|
||||||
}
|
}
|
||||||
|
|
||||||
// pdfPage.size contains un-rotated (MediaBox) dimensions;
|
const sx = pageWidth / pdfPage.size.width;
|
||||||
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
|
const sy = pageHeight / pdfPage.size.height;
|
||||||
return {
|
if (pageIndex === 0) {
|
||||||
scaleX: pageWidth / pdfPage.size.width,
|
console.debug(
|
||||||
scaleY: pageHeight / pdfPage.size.height,
|
'[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]);
|
}, [documentState, pageIndex, pageWidth, pageHeight]);
|
||||||
|
|
||||||
const pageFields = useMemo(
|
const pageFields = useMemo(
|
||||||
() => fieldsByPage.get(pageIndex) || [],
|
() => fieldsByPage.get(pageIndex) || [],
|
||||||
[fieldsByPage, pageIndex],
|
[fieldsByPage, pageIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
@@ -442,6 +572,64 @@ export function FormFieldOverlay({
|
|||||||
[setValue]
|
[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.
|
// 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 is provided and doesn't match what the context fetched for, render nothing.
|
||||||
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
||||||
@@ -485,6 +673,7 @@ export function FormFieldOverlay({
|
|||||||
scaleY={scaleY}
|
scaleY={scaleY}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onButtonClick={handleButtonClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ import React, {
|
|||||||
import { useDebouncedCallback } from '@mantine/hooks';
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/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 { 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)
|
// FormValuesStore — external store for field values (outside React state)
|
||||||
@@ -268,7 +269,7 @@ export function useAllFormValues(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton provider instances */
|
/** Singleton provider instances */
|
||||||
const pdfLibProvider = new PdfLibFormProvider();
|
const pdfiumProvider = new PdfiumFormProvider();
|
||||||
const pdfBoxProvider = new PdfBoxFormProvider();
|
const pdfBoxProvider = new PdfBoxFormProvider();
|
||||||
|
|
||||||
export function FormFillProvider({
|
export function FormFillProvider({
|
||||||
@@ -283,7 +284,7 @@ export function FormFillProvider({
|
|||||||
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
|
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
|
||||||
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
|
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
|
||||||
providerModeRef.current = providerMode;
|
providerModeRef.current = providerMode;
|
||||||
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
|
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider);
|
||||||
const providerRef = useRef(provider);
|
const providerRef = useRef(provider);
|
||||||
providerRef.current = provider;
|
providerRef.current = provider;
|
||||||
|
|
||||||
@@ -322,7 +323,7 @@ export function FormFillProvider({
|
|||||||
let fields = await providerRef.current.fetchFields(file);
|
let fields = await providerRef.current.fetchFields(file);
|
||||||
// If another fetch or reset happened while we were waiting, discard this result
|
// If another fetch or reset happened while we were waiting, discard this result
|
||||||
if (fetchVersionRef.current !== version) {
|
if (fetchVersionRef.current !== version) {
|
||||||
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
|
console.debug('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +331,9 @@ export function FormFillProvider({
|
|||||||
// (they're not fillable). Fetch them via pdflib so their appearances still render.
|
// (they're not fillable). Fetch them via pdflib so their appearances still render.
|
||||||
if (providerModeRef.current === 'pdfbox') {
|
if (providerModeRef.current === 'pdfbox') {
|
||||||
try {
|
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 (fetchVersionRef.current !== version) return; // stale check after async
|
||||||
if (sigFields.length > 0) {
|
if (sigFields.length > 0) {
|
||||||
fields = [...fields, ...sigFields];
|
fields = [...fields, ...sigFields];
|
||||||
@@ -420,7 +423,7 @@ export function FormFillProvider({
|
|||||||
if (providerModeRef.current === mode) return;
|
if (providerModeRef.current === mode) return;
|
||||||
|
|
||||||
// provider (pdfbox vs pdflib).
|
// provider (pdfbox vs pdflib).
|
||||||
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
|
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfiumProvider;
|
||||||
providerRef.current = newProvider;
|
providerRef.current = newProvider;
|
||||||
providerModeRef.current = mode;
|
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 { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||||
export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||||
export type { IFormDataProvider } from '@app/tools/formFill/providers/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';
|
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 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';
|
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface FormField {
|
|||||||
multiline: boolean;
|
multiline: boolean;
|
||||||
tooltip: string | null;
|
tooltip: string | null;
|
||||||
widgets: WidgetCoordinates[] | 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). */
|
/** Pre-rendered appearance image for signed signature fields (data URL). */
|
||||||
appearanceDataUrl?: string;
|
appearanceDataUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,26 @@ export type FormFieldType =
|
|||||||
| 'button'
|
| 'button'
|
||||||
| 'signature';
|
| '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 {
|
export interface FormFillState {
|
||||||
/** Fields fetched from backend with coordinates */
|
/** Fields fetched from backend with coordinates */
|
||||||
fields: FormField[];
|
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 {
|
export interface ImageToPdfOptions {
|
||||||
imageResolution?: 'full' | 'reduced';
|
imageResolution?: 'full' | 'reduced';
|
||||||
@@ -6,11 +10,14 @@ export interface ImageToPdfOptions {
|
|||||||
stretchToFit?: boolean;
|
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
|
* Convert an image file to a PDF file using PDFium WASM.
|
||||||
* @param imageFile - The image file to convert (JPEG, PNG, etc.)
|
|
||||||
* @param options - Conversion options
|
|
||||||
* @returns A Promise that resolves to a PDF File object
|
|
||||||
*/
|
*/
|
||||||
export async function convertImageToPdf(
|
export async function convertImageToPdf(
|
||||||
imageFile: File,
|
imageFile: File,
|
||||||
@@ -21,121 +28,143 @@ export async function convertImageToPdf(
|
|||||||
pageFormat = 'A4',
|
pageFormat = 'A4',
|
||||||
stretchToFit = false,
|
stretchToFit = false,
|
||||||
} = options;
|
} = options;
|
||||||
try {
|
|
||||||
// Create a new PDF document
|
|
||||||
const pdfDoc = await PDFDocument.create();
|
|
||||||
|
|
||||||
// Read the image file as an array buffer
|
try {
|
||||||
let imageBytes = await imageFile.arrayBuffer();
|
const m = await getPdfiumModule();
|
||||||
|
|
||||||
|
// Read the image file
|
||||||
|
let imageBlob: Blob = imageFile;
|
||||||
|
|
||||||
// Apply image resolution reduction if requested
|
// Apply image resolution reduction if requested
|
||||||
if (imageResolution === 'reduced') {
|
if (imageResolution === 'reduced') {
|
||||||
const reducedImage = await reduceImageResolution(imageFile, 1200); // Max 1200px on longest side
|
imageBlob = await reduceImageResolution(imageFile, 1200);
|
||||||
imageBytes = await reducedImage.arrayBuffer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed the image based on its type
|
// Decode image to RGBA pixels via canvas
|
||||||
let image;
|
const decoded = await decodeImageToRgba(imageBlob);
|
||||||
const imageType = imageFile.type.toLowerCase();
|
if (!decoded) {
|
||||||
|
throw new Error('Failed to decode image');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get image dimensions
|
const { rgba, width: imageWidth, height: imageHeight } = decoded;
|
||||||
const { width: imageWidth, height: imageHeight } = image;
|
|
||||||
|
|
||||||
// Determine page dimensions based on pageFormat option
|
// Determine page dimensions
|
||||||
let pageWidth: number;
|
let pageWidth: number;
|
||||||
let pageHeight: number;
|
let pageHeight: number;
|
||||||
|
|
||||||
if (pageFormat === 'keep') {
|
if (pageFormat === 'keep') {
|
||||||
// Use original image dimensions
|
|
||||||
pageWidth = imageWidth;
|
pageWidth = imageWidth;
|
||||||
pageHeight = imageHeight;
|
pageHeight = imageHeight;
|
||||||
} else if (pageFormat === 'letter') {
|
} else if (pageFormat === 'letter') {
|
||||||
// US Letter: 8.5" x 11" = 612 x 792 points
|
[pageWidth, pageHeight] = PAGE_SIZES.Letter;
|
||||||
pageWidth = PageSizes.Letter[0];
|
|
||||||
pageHeight = PageSizes.Letter[1];
|
|
||||||
} else {
|
} else {
|
||||||
// A4: 210mm x 297mm = 595 x 842 points (default)
|
[pageWidth, pageHeight] = PAGE_SIZES.A4;
|
||||||
pageWidth = PageSizes.A4[0];
|
|
||||||
pageHeight = PageSizes.A4[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust page orientation based on image orientation if using standard page size
|
// Adjust orientation to match image
|
||||||
if (pageFormat !== 'keep') {
|
if (pageFormat !== 'keep') {
|
||||||
const imageIsLandscape = imageWidth > imageHeight;
|
const imageIsLandscape = imageWidth > imageHeight;
|
||||||
const pageIsLandscape = pageWidth > pageHeight;
|
const pageIsLandscape = pageWidth > pageHeight;
|
||||||
|
|
||||||
// Rotate page to match image orientation
|
|
||||||
if (imageIsLandscape !== pageIsLandscape) {
|
if (imageIsLandscape !== pageIsLandscape) {
|
||||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a page
|
// Calculate image placement
|
||||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
|
||||||
|
|
||||||
// Calculate image placement based on stretchToFit option
|
|
||||||
let drawX: number;
|
let drawX: number;
|
||||||
let drawY: number;
|
let drawY: number;
|
||||||
let drawWidth: number;
|
let drawWidth: number;
|
||||||
let drawHeight: number;
|
let drawHeight: number;
|
||||||
|
|
||||||
if (stretchToFit || pageFormat === 'keep') {
|
if (stretchToFit || pageFormat === 'keep') {
|
||||||
// Stretch/fill to page
|
|
||||||
drawX = 0;
|
drawX = 0;
|
||||||
drawY = 0;
|
drawY = 0;
|
||||||
drawWidth = pageWidth;
|
drawWidth = pageWidth;
|
||||||
drawHeight = pageHeight;
|
drawHeight = pageHeight;
|
||||||
} else {
|
} else {
|
||||||
// Fit within page bounds while preserving aspect ratio
|
|
||||||
const imageAspectRatio = imageWidth / imageHeight;
|
const imageAspectRatio = imageWidth / imageHeight;
|
||||||
const pageAspectRatio = pageWidth / pageHeight;
|
const pageAspectRatio = pageWidth / pageHeight;
|
||||||
|
|
||||||
if (imageAspectRatio > pageAspectRatio) {
|
if (imageAspectRatio > pageAspectRatio) {
|
||||||
// Image is wider than page - fit to width
|
|
||||||
drawWidth = pageWidth;
|
drawWidth = pageWidth;
|
||||||
drawHeight = pageWidth / imageAspectRatio;
|
drawHeight = pageWidth / imageAspectRatio;
|
||||||
drawX = 0;
|
drawX = 0;
|
||||||
drawY = (pageHeight - drawHeight) / 2; // Center vertically
|
drawY = (pageHeight - drawHeight) / 2;
|
||||||
} else {
|
} else {
|
||||||
// Image is taller than page - fit to height
|
|
||||||
drawHeight = pageHeight;
|
drawHeight = pageHeight;
|
||||||
drawWidth = pageHeight * imageAspectRatio;
|
drawWidth = pageHeight * imageAspectRatio;
|
||||||
drawY = 0;
|
drawY = 0;
|
||||||
drawX = (pageWidth - drawWidth) / 2; // Center horizontally
|
drawX = (pageWidth - drawWidth) / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the image on the page
|
// Create new PDF document
|
||||||
page.drawImage(image, {
|
const docPtr = m.FPDF_CreateNewDocument();
|
||||||
x: drawX,
|
if (!docPtr) throw new Error('PDFium: failed to create document');
|
||||||
y: drawY,
|
|
||||||
width: drawWidth,
|
|
||||||
height: drawHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save the PDF to bytes
|
try {
|
||||||
const pdfBytes = await pdfDoc.save();
|
// 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
|
// Create bitmap from RGBA data (PDFium uses BGRA)
|
||||||
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf');
|
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 bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||||
const pdfFile = new File([new Uint8Array(pdfBytes)], pdfFilename, {
|
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||||
type: 'application/pdf',
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error converting image to PDF:', error);
|
console.error('Error converting image to PDF:', error);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -146,57 +175,41 @@ export async function convertImageToPdf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an image file to PNG using canvas
|
* Decode an image Blob to RGBA pixel data via canvas.
|
||||||
* This is used for image types that pdf-lib doesn't directly support
|
|
||||||
*/
|
*/
|
||||||
async function convertImageToPng(imageFile: File): Promise<File> {
|
function decodeImageToRgba(imageBlob: Blob): Promise<{ rgba: Uint8Array; width: number; height: number } | null> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const url = URL.createObjectURL(imageFile);
|
const url = URL.createObjectURL(imageBlob);
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
try {
|
try {
|
||||||
// Create a canvas with the image dimensions
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
|
|
||||||
// Draw the image on the canvas
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('Failed to get canvas context');
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
// 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) {
|
|
||||||
URL.revokeObjectURL(url);
|
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 = () => {
|
img.onerror = () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
reject(new Error('Failed to load image'));
|
resolve(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = url;
|
img.src = url;
|
||||||
@@ -205,9 +218,6 @@ async function convertImageToPng(imageFile: File): Promise<File> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reduce image resolution to a maximum dimension
|
* 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(
|
async function reduceImageResolution(
|
||||||
imageFile: File,
|
imageFile: File,
|
||||||
@@ -221,14 +231,12 @@ async function reduceImageResolution(
|
|||||||
try {
|
try {
|
||||||
const { width, height } = img;
|
const { width, height } = img;
|
||||||
|
|
||||||
// Check if reduction is needed
|
|
||||||
if (width <= maxDimension && height <= maxDimension) {
|
if (width <= maxDimension && height <= maxDimension) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
resolve(imageFile); // No reduction needed
|
resolve(imageFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate new dimensions while preserving aspect ratio
|
|
||||||
let newWidth: number;
|
let newWidth: number;
|
||||||
let newHeight: number;
|
let newHeight: number;
|
||||||
|
|
||||||
@@ -240,19 +248,14 @@ async function reduceImageResolution(
|
|||||||
newWidth = (width / height) * maxDimension;
|
newWidth = (width / height) * maxDimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a canvas with the new dimensions
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = newWidth;
|
canvas.width = newWidth;
|
||||||
canvas.height = newHeight;
|
canvas.height = newHeight;
|
||||||
|
|
||||||
// Draw the resized image on the canvas
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) throw new Error('Failed to get canvas context');
|
||||||
throw new Error('Failed to get canvas context');
|
|
||||||
}
|
|
||||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
// Convert canvas to blob (preserve original format if possible)
|
|
||||||
const outputType = imageFile.type.startsWith('image/')
|
const outputType = imageFile.type.startsWith('image/')
|
||||||
? imageFile.type
|
? imageFile.type
|
||||||
: 'image/jpeg';
|
: 'image/jpeg';
|
||||||
@@ -263,17 +266,12 @@ async function reduceImageResolution(
|
|||||||
reject(new Error('Failed to convert canvas to blob'));
|
reject(new Error('Failed to convert canvas to blob'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const reducedFile = new File([blob], imageFile.name, { type: outputType });
|
||||||
// Create a File object from the blob
|
|
||||||
const reducedFile = new File([blob], imageFile.name, {
|
|
||||||
type: outputType,
|
|
||||||
});
|
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
resolve(reducedFile);
|
resolve(reducedFile);
|
||||||
},
|
},
|
||||||
outputType,
|
outputType,
|
||||||
0.9 // Quality (only applies to JPEG)
|
0.9,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* pdfLinkUtils — Create, modify, and extract link annotations in PDF documents.
|
* 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 {
|
import {
|
||||||
PDFDocument,
|
getPdfiumModule,
|
||||||
PDFPage,
|
openRawDocumentSafe,
|
||||||
PDFName,
|
closeDocAndFreeBuffer,
|
||||||
PDFString,
|
saveRawDocument,
|
||||||
PDFArray,
|
readUtf16,
|
||||||
PDFDict,
|
writeUtf16,
|
||||||
PDFRef,
|
readAnnotRectAdjusted,
|
||||||
PDFContext,
|
parseRectToCss,
|
||||||
PDFNumber,
|
} from '@app/services/pdfiumService';
|
||||||
PDFHexString,
|
import {
|
||||||
} from '@cantoo/pdf-lib';
|
FPDF_ANNOT_LINK,
|
||||||
|
PDFACTION_GOTO,
|
||||||
|
PDFACTION_URI,
|
||||||
|
} from '@app/utils/pdfiumBitmapUtils';
|
||||||
|
|
||||||
export type LinkType = 'internal' | 'external' | 'unknown';
|
export type LinkType = 'internal' | 'external' | 'unknown';
|
||||||
export type LinkBorderStyle = 'solid' | 'dashed' | 'beveled' | 'inset' | 'underline';
|
export type LinkBorderStyle = 'solid' | 'dashed' | 'beveled' | 'inset' | 'underline';
|
||||||
@@ -40,9 +46,9 @@ export interface PdfLibLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLinkOptions {
|
export interface CreateLinkOptions {
|
||||||
/** Page to place the link on. */
|
/** 0-based page index. */
|
||||||
page: PDFPage;
|
pageIndex: number;
|
||||||
/** Link rectangle in PDF user-space coordinates (lower-left origin). */
|
/** Link rectangle in CSS (top-left origin) coordinate space. */
|
||||||
rect: { x: number; y: number; width: number; height: number };
|
rect: { x: number; y: number; width: number; height: number };
|
||||||
/** External URL (mutually exclusive with destinationPage). */
|
/** External URL (mutually exclusive with destinationPage). */
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -62,22 +68,21 @@ export interface CreateLinkOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a link annotation on a PDF page.
|
* 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(
|
export async function createLinkAnnotation(
|
||||||
pdfDoc: PDFDocument,
|
data: ArrayBuffer | Uint8Array,
|
||||||
options: CreateLinkOptions,
|
options: CreateLinkOptions,
|
||||||
): void {
|
password?: string,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
const {
|
const {
|
||||||
page,
|
pageIndex,
|
||||||
rect,
|
rect,
|
||||||
url,
|
url,
|
||||||
destinationPage,
|
destinationPage,
|
||||||
title,
|
title,
|
||||||
color = [0, 0, 1],
|
color = [0, 0, 1],
|
||||||
borderWidth = 0,
|
borderWidth = 0,
|
||||||
borderStyle = 'solid',
|
|
||||||
highlightMode = 'invert',
|
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!url && destinationPage === undefined) {
|
if (!url && destinationPage === undefined) {
|
||||||
@@ -86,468 +91,244 @@ export function createLinkAnnotation(
|
|||||||
if (url && destinationPage !== undefined) {
|
if (url && destinationPage !== undefined) {
|
||||||
throw new Error('createLinkAnnotation: url and destinationPage are mutually exclusive');
|
throw new Error('createLinkAnnotation: url and destinationPage are mutually exclusive');
|
||||||
}
|
}
|
||||||
if (destinationPage !== undefined) {
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
const pageCount = pdfDoc.getPageCount();
|
throw new Error('createLinkAnnotation: rect dimensions must be positive');
|
||||||
if (destinationPage < 0 || destinationPage >= pageCount) {
|
}
|
||||||
|
|
||||||
|
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(
|
throw new RangeError(
|
||||||
`createLinkAnnotation: destinationPage ${destinationPage} out of range [0, ${pageCount})`,
|
`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> = {
|
try {
|
||||||
Type: 'Annot',
|
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||||
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)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (title) {
|
const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_LINK);
|
||||||
entries.Contents = PDFString.of(title);
|
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 rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||||
const bsDict = ctx.obj({
|
m.pdfium.setValue(rectBuf, pdfLeft, 'float'); // offset 0: left
|
||||||
W: borderWidth,
|
m.pdfium.setValue(rectBuf + 4, pdfTop, 'float'); // offset 4: top (larger y)
|
||||||
S: PDFName.of(borderStyleCode(borderStyle)),
|
m.pdfium.setValue(rectBuf + 8, pdfRight, 'float'); // offset 8: right
|
||||||
});
|
m.pdfium.setValue(rectBuf + 12, pdfBottom, 'float'); // offset 12: bottom (smaller y)
|
||||||
(annotDict as PDFDict).set(PDFName.of('BS'), bsDict);
|
m.FPDFAnnot_SetRect(annotPtr, rectBuf);
|
||||||
}
|
m.pdfium.wasmExports.free(rectBuf);
|
||||||
|
|
||||||
if (url) {
|
// Set color
|
||||||
const actionDict = ctx.obj({
|
// FPDFANNOT_COLORTYPE_Color = 0
|
||||||
S: 'URI',
|
m.FPDFAnnot_SetColor(
|
||||||
URI: PDFString.of(url),
|
annotPtr,
|
||||||
});
|
0,
|
||||||
(annotDict as PDFDict).set(PDFName.of('A'), actionDict);
|
Math.round(color[0] * 255),
|
||||||
} else if (destinationPage !== undefined) {
|
Math.round(color[1] * 255),
|
||||||
const destPage = pdfDoc.getPage(destinationPage);
|
Math.round(color[2] * 255),
|
||||||
const destArray = ctx.obj([destPage.ref, 'XYZ', null, null, null]);
|
255,
|
||||||
(annotDict as PDFDict).set(PDFName.of('Dest'), destArray);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const annotRef = ctx.register(annotDict);
|
// Set border
|
||||||
|
m.FPDFAnnot_SetBorder(annotPtr, 0, 0, borderWidth);
|
||||||
|
|
||||||
const existingAnnots = page.node.get(PDFName.of('Annots'));
|
// Set URI for external links
|
||||||
if (existingAnnots) {
|
if (url) {
|
||||||
const resolvedAnnots =
|
const uriPtr = writeUtf16(m, url);
|
||||||
existingAnnots instanceof PDFRef ? ctx.lookup(existingAnnots) : existingAnnots;
|
m.FPDFAnnot_SetURI(annotPtr, uriPtr);
|
||||||
if (resolvedAnnots instanceof PDFArray) {
|
m.pdfium.wasmExports.free(uriPtr);
|
||||||
resolvedAnnots.push(annotRef);
|
}
|
||||||
} else {
|
|
||||||
page.node.set(PDFName.of('Annots'), ctx.obj([annotRef]));
|
// 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.
|
* Remove a link annotation from a page by its index.
|
||||||
* Returns true if the annotation was found and removed.
|
* Returns the updated PDF bytes.
|
||||||
*/
|
*/
|
||||||
export function removeLinkAnnotation(
|
export async function removeLinkAnnotation(
|
||||||
pdfDoc: PDFDocument,
|
data: ArrayBuffer | Uint8Array,
|
||||||
page: PDFPage,
|
pageIndex: number,
|
||||||
annotIndex: number,
|
annotIndex: number,
|
||||||
): boolean {
|
password?: string,
|
||||||
const ctx = pdfDoc.context;
|
): Promise<ArrayBuffer> {
|
||||||
const annotsRaw = page.node.get(PDFName.of('Annots'));
|
const m = await getPdfiumModule();
|
||||||
if (!annotsRaw) return false;
|
const docPtr = await openRawDocumentSafe(data, password);
|
||||||
|
|
||||||
const annots =
|
try {
|
||||||
annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
|
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||||
if (!(annots instanceof PDFArray)) return false;
|
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);
|
return await saveRawDocument(docPtr);
|
||||||
if (entry instanceof PDFRef) {
|
} finally {
|
||||||
ctx.delete(entry);
|
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.
|
* Extract all link annotations from a given PDF page.
|
||||||
*/
|
*/
|
||||||
export function extractLinksFromPage(
|
export async function extractLinksFromPage(
|
||||||
doc: PDFDocument,
|
data: ArrayBuffer | Uint8Array,
|
||||||
page: PDFPage,
|
|
||||||
pageIndex: number,
|
pageIndex: number,
|
||||||
): PdfLibLink[] {
|
password?: string,
|
||||||
const links: PdfLibLink[] = [];
|
): Promise<{ links: PdfLibLink[]; pdfPageWidth: number; pdfPageHeight: number }> {
|
||||||
const ctx = doc.context;
|
const m = await getPdfiumModule();
|
||||||
const { height: pageHeight } = page.getSize();
|
const docPtr = await openRawDocumentSafe(data, password);
|
||||||
|
|
||||||
const annotsRaw = page.node.get(PDFName.of('Annots'));
|
try {
|
||||||
if (!annotsRaw) return links;
|
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||||
|
if (!pagePtr) return { links: [], pdfPageWidth: 0, pdfPageHeight: 0 };
|
||||||
|
|
||||||
const annots = annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
|
const pageWidth = m.FPDF_GetPageWidthF(pagePtr);
|
||||||
if (!(annots instanceof PDFArray)) return links;
|
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||||
|
const links: PdfLibLink[] = [];
|
||||||
|
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||||
|
|
||||||
for (let i = 0; i < annots.size(); i++) {
|
for (let i = 0; i < annotCount; i++) {
|
||||||
try {
|
try {
|
||||||
const annotRaw = annots.get(i);
|
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, i);
|
||||||
const annot = annotRaw instanceof PDFRef ? ctx.lookup(annotRaw) : annotRaw;
|
if (!annotPtr) continue;
|
||||||
if (!(annot instanceof PDFDict)) continue;
|
|
||||||
|
|
||||||
const subtype = annot.get(PDFName.of('Subtype'));
|
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
|
||||||
if (subtype?.toString() !== '/Link') continue;
|
if (subtype !== FPDF_ANNOT_LINK) {
|
||||||
|
m.FPDFPage_CloseAnnot(annotPtr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const rectRaw = annot.get(PDFName.of('Rect'));
|
// Get rect (CropBox-adjusted for correct overlay positioning)
|
||||||
const rect = rectRaw instanceof PDFRef ? ctx.lookup(rectRaw) : rectRaw;
|
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||||
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
|
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 rect = parseRectToCss(m, rectBuf, pageHeight);
|
||||||
const y1 = num(ctx, rect.get(1));
|
m.pdfium.wasmExports.free(rectBuf);
|
||||||
const x2 = num(ctx, rect.get(2));
|
|
||||||
const y2 = num(ctx, rect.get(3));
|
|
||||||
|
|
||||||
const left = Math.min(x1, x2);
|
// Try to get link object
|
||||||
const bottom = Math.min(y1, y2);
|
const linkPtr = m.FPDFAnnot_GetLink(annotPtr);
|
||||||
const width = Math.abs(x2 - x1);
|
let linkType: LinkType = 'unknown';
|
||||||
const height = Math.abs(y2 - y1);
|
let targetPage: number | undefined;
|
||||||
|
let uri: string | undefined;
|
||||||
|
|
||||||
const top = pageHeight - bottom - height;
|
if (linkPtr) {
|
||||||
|
// Check for action
|
||||||
let linkType: LinkType = 'unknown';
|
const actionPtr = m.FPDFLink_GetAction(linkPtr);
|
||||||
let targetPage: number | undefined;
|
if (actionPtr) {
|
||||||
let uri: string | undefined;
|
const actionType = m.FPDFAction_GetType(actionPtr);
|
||||||
|
if (actionType === PDFACTION_URI) {
|
||||||
const actionRaw = annot.get(PDFName.of('A'));
|
const uriLen = m.FPDFAction_GetURIPath(docPtr, actionPtr, 0, 0);
|
||||||
const action = actionRaw instanceof PDFRef ? ctx.lookup(actionRaw) : actionRaw;
|
if (uriLen > 0) {
|
||||||
|
const uriBuf = m.pdfium.wasmExports.malloc(uriLen);
|
||||||
if (action instanceof PDFDict) {
|
m.FPDFAction_GetURIPath(docPtr, actionPtr, uriBuf, uriLen);
|
||||||
const actionType = action.get(PDFName.of('S'))?.toString();
|
uri = m.pdfium.UTF8ToString(uriBuf);
|
||||||
|
m.pdfium.wasmExports.free(uriBuf);
|
||||||
if (actionType === '/URI') {
|
linkType = 'external';
|
||||||
linkType = 'external';
|
}
|
||||||
uri = str(ctx, action.get(PDFName.of('URI')));
|
} else if (actionType === PDFACTION_GOTO) {
|
||||||
} else if (actionType === '/GoTo') {
|
const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr);
|
||||||
linkType = 'internal';
|
if (destPtr) {
|
||||||
const dest = action.get(PDFName.of('D'));
|
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||||
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
|
linkType = 'internal';
|
||||||
if (destResolved instanceof PDFArray) {
|
}
|
||||||
targetPage = resolveDestArray(doc, ctx, destResolved);
|
|
||||||
} else {
|
|
||||||
const destName = str(ctx, destResolved);
|
|
||||||
if (destName) {
|
|
||||||
targetPage = resolveNamedDest(doc, ctx, destName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (actionType === '/GoToR' || actionType === '/Launch') {
|
|
||||||
linkType = 'external';
|
|
||||||
uri = str(ctx, action.get(PDFName.of('F')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linkType === 'unknown') {
|
// Check for direct destination
|
||||||
const destRaw = annot.get(PDFName.of('Dest'));
|
if (linkType === 'unknown') {
|
||||||
const dest = destRaw instanceof PDFRef ? ctx.lookup(destRaw) : destRaw;
|
const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr);
|
||||||
|
if (destPtr) {
|
||||||
if (dest instanceof PDFArray) {
|
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||||
linkType = 'internal';
|
linkType = 'internal';
|
||||||
targetPage = resolveDestArray(doc, ctx, dest);
|
}
|
||||||
} else {
|
|
||||||
const destName = str(ctx, dest);
|
|
||||||
if (destName) {
|
|
||||||
linkType = 'internal';
|
|
||||||
targetPage = resolveNamedDest(doc, ctx, destName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const title = extractTitle(ctx, annot);
|
// Get title from /Contents
|
||||||
const color = extractColor(ctx, annot);
|
let title: string | undefined;
|
||||||
const borderStyle = extractBorderStyle(ctx, annot);
|
const contentsLen = m.FPDFAnnot_GetStringValue(annotPtr, 'Contents', 0, 0);
|
||||||
const highlightMode = parseHighlightMode(ctx, annot.get(PDFName.of('H')));
|
if (contentsLen > 2) {
|
||||||
|
const contentsBuf = m.pdfium.wasmExports.malloc(contentsLen);
|
||||||
links.push({
|
m.FPDFAnnot_GetStringValue(annotPtr, 'Contents', contentsBuf, contentsLen);
|
||||||
id: `pdflib-link-${pageIndex}-${i}`,
|
title = readUtf16(m, contentsBuf, contentsLen) || undefined;
|
||||||
annotIndex: i,
|
m.pdfium.wasmExports.free(contentsBuf);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if (valResolved instanceof PDFDict) {
|
|
||||||
const d = valResolved.get(PDFName.of('D'));
|
// Get color
|
||||||
const dResolved = d instanceof PDFRef ? ctx.lookup(d) : d;
|
let color: [number, number, number] | undefined;
|
||||||
if (dResolved instanceof PDFArray) {
|
// allocate 4 uint for RGBA
|
||||||
return resolveDestArray(doc, ctx, dResolved);
|
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'));
|
m.FPDF_ClosePage(pagePtr);
|
||||||
const kidsResolved = kidsArr instanceof PDFRef ? ctx.lookup(kidsArr) : kidsArr;
|
return { links, pdfPageWidth: pageWidth, pdfPageHeight: pageHeight };
|
||||||
if (kidsResolved instanceof PDFArray) {
|
} finally {
|
||||||
for (let i = 0; i < kidsResolved.size(); i++) {
|
closeDocAndFreeBuffer(m, docPtr);
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
// PDFium annotation subtype constants
|
||||||
import {PdfAnnotationSubtype} from '@embedpdf/models';
|
import {
|
||||||
import {generateThumbnailWithMetadata} from '@app/utils/thumbnailUtils';
|
FPDF_ANNOT_INK,
|
||||||
import {createChildStub, createProcessedFile} from '@app/contexts/file/fileActions';
|
FPDF_ANNOT_LINE,
|
||||||
import {createStirlingFile, FileId, StirlingFile, StirlingFileStub} from '@app/types/fileContext';
|
embedBitmapImageOnPage,
|
||||||
import type {SignatureAPI} from '@app/components/viewer/viewerTypes';
|
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 {
|
interface MinimalFileContextSelectors {
|
||||||
getAllFileIds: () => FileId[];
|
getAllFileIds: () => FileId[];
|
||||||
@@ -37,7 +49,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
|||||||
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
|
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
|
||||||
|
|
||||||
if (signatureApiRef?.current) {
|
if (signatureApiRef?.current) {
|
||||||
|
|
||||||
const scrollState = getScrollState();
|
const scrollState = getScrollState();
|
||||||
const totalPages = scrollState.totalPages;
|
const totalPages = scrollState.totalPages;
|
||||||
|
|
||||||
@@ -47,7 +58,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
|||||||
if (pageAnnotations && pageAnnotations.length > 0) {
|
if (pageAnnotations && pageAnnotations.length > 0) {
|
||||||
const sessionAnnotations = pageAnnotations.filter(annotation => {
|
const sessionAnnotations = pageAnnotations.filter(annotation => {
|
||||||
const hasStoredImageData = annotation.id && getImageData(annotation.id);
|
const hasStoredImageData = annotation.id && getImageData(annotation.id);
|
||||||
|
|
||||||
const hasDirectImageData = annotation.imageData || annotation.appearance ||
|
const hasDirectImageData = annotation.imageData || annotation.appearance ||
|
||||||
annotation.stampData || annotation.imageSrc ||
|
annotation.stampData || annotation.imageSrc ||
|
||||||
annotation.contents || annotation.data;
|
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) {
|
if (allAnnotations.length > 0 && signatureApiRef?.current) {
|
||||||
for (const pageData of allAnnotations) {
|
for (const pageData of allAnnotations) {
|
||||||
for (const annotation of pageData.annotations) {
|
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) {
|
if (!exportActions) {
|
||||||
console.error('No export actions available');
|
console.error('No export actions available');
|
||||||
return null;
|
return null;
|
||||||
@@ -85,7 +95,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
|||||||
const pdfArrayBuffer = await exportActions.saveAsCopy();
|
const pdfArrayBuffer = await exportActions.saveAsCopy();
|
||||||
|
|
||||||
if (pdfArrayBuffer) {
|
if (pdfArrayBuffer) {
|
||||||
|
|
||||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||||
|
|
||||||
let currentFile = originalFile;
|
let currentFile = originalFile;
|
||||||
@@ -108,182 +117,85 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
|||||||
|
|
||||||
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
|
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) {
|
if (allAnnotations.length > 0) {
|
||||||
try {
|
try {
|
||||||
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
|
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
|
||||||
|
const m = await getPdfiumModule();
|
||||||
|
const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening);
|
||||||
|
|
||||||
let pdfDoc: PDFDocument;
|
|
||||||
try {
|
try {
|
||||||
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
|
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||||
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 pages = pdfDoc.getPages();
|
for (const pageData of allAnnotations) {
|
||||||
|
const { pageIndex, annotations } = pageData;
|
||||||
|
|
||||||
for (const pageData of allAnnotations) {
|
if (pageIndex < pageCount) {
|
||||||
const { pageIndex, annotations } = pageData;
|
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||||
|
if (!pagePtr) continue;
|
||||||
|
|
||||||
if (pageIndex < pages.length) {
|
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||||
const page = pages[pageIndex];
|
|
||||||
const { height: pageHeight } = page.getSize();
|
|
||||||
|
|
||||||
for (const annotation of annotations) {
|
for (const annotation of annotations) {
|
||||||
try {
|
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) {
|
// Convert from CSS top-left to PDF bottom-left
|
||||||
const originalX = rect.origin?.x || rect.x || rect.left || 0;
|
const pdfX = originalX;
|
||||||
const originalY = rect.origin?.y || rect.y || rect.top || 0;
|
const pdfY = pageHeight - originalY - height;
|
||||||
const width = rect.size?.width || rect.width || 100;
|
|
||||||
const height = rect.size?.height || rect.height || 50;
|
|
||||||
|
|
||||||
const pdfX = originalX;
|
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
|
||||||
const pdfY = pageHeight - originalY - height;
|
annotation.imageSrc || annotation.contents || annotation.data;
|
||||||
|
|
||||||
|
if (!imageDataUrl && annotation.id) {
|
||||||
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
|
const storedImageData = getImageData(annotation.id);
|
||||||
annotation.imageSrc || annotation.contents || annotation.data;
|
if (storedImageData) {
|
||||||
|
imageDataUrl = storedImageData;
|
||||||
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 (!svgRendered) {
|
// Convert SVG to PNG first if needed
|
||||||
page.drawRectangle({
|
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image/svg+xml')) {
|
||||||
x: pdfX,
|
const pngBytes = await rasteriseSvgToPng(imageDataUrl, width * 2, height * 2);
|
||||||
y: pdfY,
|
if (pngBytes) {
|
||||||
width: width,
|
imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes);
|
||||||
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);
|
|
||||||
} else {
|
} 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, {
|
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
|
||||||
x: pdfX,
|
// Decode the image data URL to raw pixels via canvas
|
||||||
y: pdfY + height - 12, // Adjust for text baseline
|
const imageResult = await decodeImageDataUrl(imageDataUrl);
|
||||||
size: 12,
|
if (imageResult) {
|
||||||
color: rgb(0, 0, 0)
|
embedBitmapImageOnPage(m, docPtr, pagePtr, imageResult, pdfX, pdfY, width, height);
|
||||||
});
|
}
|
||||||
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
|
} else if (annotation.type === FPDF_ANNOT_INK || annotation.type === FPDF_ANNOT_LINE) {
|
||||||
page.drawRectangle({
|
drawPlaceholderRect(m, pagePtr, pdfX, pdfY, width, height);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} 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) {
|
} catch (renderError) {
|
||||||
console.error('Failed to manually render annotations:', renderError);
|
console.error('Failed to manually render annotations:', renderError);
|
||||||
console.warn('Signatures may only show as annotations');
|
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.
|
* Convert Uint8Array PNG bytes to a data URL for canvas decoding.
|
||||||
* Handles both base64-encoded and URI-encoded SVG data URLs.
|
|
||||||
*/
|
*/
|
||||||
function decodeSvgDataUrl(dataUrl: string): string | null {
|
function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise<string> {
|
||||||
try {
|
return new Promise((resolve) => {
|
||||||
if (dataUrl.includes(';base64,')) {
|
const blob = new Blob([pngBytes as BlobPart], { type: 'image/png' });
|
||||||
const base64 = dataUrl.split(',')[1];
|
const reader = new FileReader();
|
||||||
return atob(base64);
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
}
|
reader.readAsDataURL(blob);
|
||||||
// URI-encoded SVG
|
});
|
||||||
const encoded = dataUrl.split(',')[1];
|
|
||||||
return decodeURIComponent(encoded);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rasterise an SVG data URL to PNG bytes via an offscreen canvas.
|
* 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> {
|
function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): Promise<Uint8Array | null> {
|
||||||
return new Promise((resolve) => {
|
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.width = Math.max(1, Math.round(width));
|
||||||
canvas.height = Math.max(1, Math.round(height));
|
canvas.height = Math.max(1, Math.round(height));
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) { resolve(null); return; }
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
if (!blob) {
|
if (!blob) { resolve(null); return; }
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
blob.arrayBuffer().then(
|
blob.arrayBuffer().then(
|
||||||
(buf) => resolve(new Uint8Array(buf)),
|
(buf) => resolve(new Uint8Array(buf)),
|
||||||
() => resolve(null),
|
() => resolve(null),
|
||||||
@@ -373,9 +272,7 @@ function rasteriseSvgToPng(svgDataUrl: string, width: number, height: number): P
|
|||||||
},
|
},
|
||||||
'image/png',
|
'image/png',
|
||||||
);
|
);
|
||||||
} catch {
|
} catch { resolve(null); }
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
img.onerror = () => resolve(null);
|
img.onerror = () => resolve(null);
|
||||||
img.src = svgDataUrl;
|
img.src = svgDataUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user