Render signature to pdf

This commit is contained in:
Reece 2025-09-26 15:10:47 +01:00
parent c94ee388fc
commit cfd00b2c71
4 changed files with 343 additions and 85 deletions

View File

@ -1623,6 +1623,7 @@
"add": "Add",
"saved": "Saved Signatures",
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
"sharedSigs": "Shared Signatures",
"noSavedSigs": "No saved signatures found",

View File

@ -91,11 +91,9 @@ const SignSettings = ({
}
};
// Handle drawing mode activation
// Handle signature mode deactivation when switching types
useEffect(() => {
if (parameters.signatureType === 'draw' && onActivateDrawMode) {
onActivateDrawMode();
} else if (parameters.signatureType !== 'draw' && onDeactivateSignature) {
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
onDeactivateSignature();
}
}, [parameters.signatureType]);
@ -140,19 +138,14 @@ const SignSettings = ({
}
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
// Update draw settings when color or pen size changes
useEffect(() => {
if (parameters.signatureType === 'draw' && onUpdateDrawSettings) {
onUpdateDrawSettings(selectedColor, penSize);
}
}, [selectedColor, penSize, parameters.signatureType]);
// Draw settings are no longer needed since draw mode is removed
return (
<Stack gap="md">
{/* Signature Type Selection */}
<Tabs
value={parameters.signatureType}
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'draw' | 'canvas')}
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
>
<Tabs.List grow>
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
@ -164,9 +157,6 @@ const SignSettings = ({
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
{t('sign.type.text', 'Text')}
</Tabs.Tab>
<Tabs.Tab value="draw" style={{ fontSize: '0.8rem' }}>
{t('sign.type.draw', 'Draw')}
</Tabs.Tab>
</Tabs.List>
</Tabs>
@ -232,66 +222,15 @@ const SignSettings = ({
/>
)}
{/* Direct PDF Drawing */}
{parameters.signatureType === 'draw' && (
<Paper withBorder p="md">
<Stack gap="md">
<Text fw={500}>Direct PDF Drawing</Text>
<Text size="sm" c="dimmed">
Draw signatures and annotations directly on the PDF document.
</Text>
{/* Drawing Controls */}
<Group gap="md" align="flex-end">
{/* Color Picker */}
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<div
style={{
width: 24,
height: 24,
backgroundColor: selectedColor,
border: '1px solid #ccc',
borderRadius: 0,
cursor: 'pointer'
}}
onClick={() => setIsColorPickerOpen(true)}
/>
</div>
{/* Pen Size */}
<div style={{ flexGrow: 1, maxWidth: '200px' }}>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<input
type="number"
value={penSize}
onChange={(e) => {
const size = parseInt(e.target.value);
if (!isNaN(size) && size >= 1 && size <= 200) {
setPenSize(size);
}
}}
min={1}
max={200}
disabled={disabled}
style={{ width: '100%', padding: '4px 8px' }}
/>
</div>
</Group>
</Stack>
</Paper>
)}
{/* Instructions for placing signature */}
{(parameters.signatureType === 'canvas' || parameters.signatureType === 'image' || parameters.signatureType === 'text') && (
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
</Text>
</Alert>
)}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
</Text>
</Alert>
{/* Color Picker Modal */}
<ColorPicker
@ -301,7 +240,7 @@ const SignSettings = ({
onColorChange={setSelectedColor}
/>
{/* Save Button */}
{/* Apply Signatures Button */}
{onSave && (
<Button
onClick={onSave}
@ -309,7 +248,7 @@ const SignSettings = ({
variant="filled"
fullWidth
>
{t('save', 'Save')}
{t('sign.applySignatures', 'Apply Signatures')}
</Button>
)}

View File

@ -14,6 +14,7 @@ export interface SignatureAPI {
updateDrawSettings: (color: string, size: number) => void;
deactivateTools: () => void;
applySignatureFromParameters: (params: SignParameters) => void;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
export interface SignatureAPIBridgeProps {}
@ -344,6 +345,26 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI, SignatureAPIBridgePro
break;
}
},
getPageAnnotations: async (pageIndex: number): Promise<any[]> => {
if (!annotationApi || !annotationApi.getPageAnnotations) {
console.warn('getPageAnnotations not available');
return [];
}
try {
const pageAnnotationsTask = annotationApi.getPageAnnotations({ pageIndex });
if (pageAnnotationsTask && pageAnnotationsTask.toPromise) {
const annotations = await pageAnnotationsTask.toPromise();
console.log(`Retrieved ${annotations?.length || 0} annotations from page ${pageIndex}`);
return annotations || [];
}
return [];
} catch (error) {
console.error(`Error getting annotations for page ${pageIndex}:`, error);
return [];
}
},
}), [annotationApi, signatureConfig]);

View File

@ -13,11 +13,12 @@ import { useViewer } from "../contexts/ViewerContext";
import { generateThumbnailWithMetadata } from "../utils/thumbnailUtils";
import { createNewStirlingFileStub, createStirlingFile, StirlingFileStub, StirlingFile, FileId, extractFiles } from "../types/fileContext";
import { createProcessedFile } from "../contexts/file/fileActions";
import { PDFDocument, PDFName, PDFDict, PDFArray, rgb } from 'pdf-lib';
const Sign = (props: BaseToolProps) => {
const { t } = useTranslation();
const { setWorkbench } = useNavigation();
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, isPlacementMode } = useSignature();
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, isPlacementMode, signatureApiRef, getImageData } = useSignature();
const { actions } = useFileActions();
const { consumeFiles, selectors } = useFileContext();
const { exportActions } = useViewer();
@ -53,12 +54,58 @@ const Sign = (props: BaseToolProps) => {
// Save signed files to the system - apply signatures using EmbedPDF and replace original
const handleSaveToSystem = useCallback(async () => {
try {
// Use EmbedPDF's saveAsCopy to apply signatures and get ArrayBuffer
// Step 1: Extract all annotations from EmbedPDF before export
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
if (signatureApiRef?.current) {
console.log('Extracting annotations from all pages...');
// We need to know how many pages to check - let's assume we check first few pages
// In a real implementation, we'd get the page count from somewhere
for (let pageIndex = 0; pageIndex < 10; pageIndex++) {
try {
const pageAnnotations = await signatureApiRef.current.getPageAnnotations(pageIndex);
if (pageAnnotations && pageAnnotations.length > 0) {
allAnnotations.push({pageIndex, annotations: pageAnnotations});
console.log(`Found ${pageAnnotations.length} annotations on page ${pageIndex + 1}`);
console.log('Annotation data:', pageAnnotations);
}
} catch (e) {
// Page doesn't exist or no annotations, continue
console.log(`No annotations on page ${pageIndex + 1}:`, e);
if (pageIndex > 2) break; // Stop after checking first few pages
}
}
}
console.log(`Total annotations found: ${allAnnotations.reduce((sum, page) => sum + page.annotations.length, 0)}`);
// Step 2: Delete annotations from EmbedPDF before export (they'll be rendered manually)
if (allAnnotations.length > 0 && signatureApiRef?.current) {
console.log('Deleting annotations from EmbedPDF before export...');
for (const pageData of allAnnotations) {
for (const annotation of pageData.annotations) {
try {
await signatureApiRef.current.deleteAnnotation(annotation.id, pageData.pageIndex);
console.log(`Deleted annotation ${annotation.id} from page ${pageData.pageIndex}`);
} catch (deleteError) {
console.warn(`Failed to delete annotation ${annotation.id}:`, deleteError);
}
}
}
}
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
const pdfArrayBuffer = await exportActions.saveAsCopy();
if (pdfArrayBuffer) {
console.log(`EmbedPDF exported PDF size: ${pdfArrayBuffer.byteLength} bytes`);
// Try loading with more permissive PDF-lib options
console.log('Attempting to load PDF with PDF-lib...');
// Convert ArrayBuffer to File
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
let blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the current file - try from base.selectedFiles first, then from all files
let originalFile = null;
@ -80,7 +127,248 @@ const Sign = (props: BaseToolProps) => {
return;
}
const signedFile = new File([blob], originalFile.name, { type: 'application/pdf' });
let signedFile = new File([blob], originalFile.name, { type: 'application/pdf' });
// Step 3: Manually render extracted annotations onto the PDF using PDF-lib
if (allAnnotations.length > 0) {
try {
console.log('Manually rendering annotations onto PDF...');
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
// Try different loading options to handle problematic PDFs
let pdfDoc: PDFDocument;
try {
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
ignoreEncryption: true,
capNumbers: false,
throwOnInvalidObject: false
});
console.log('✓ PDF loaded successfully with standard options');
} catch (loadError) {
console.warn('Failed to load with standard options, trying createProxy...');
try {
// Create a fresh PDF and copy pages instead of modifying
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));
console.log('✓ PDF loaded by creating new document and copying pages');
} catch (copyError) {
console.error('Failed to load PDF with any method:', copyError);
throw copyError;
}
}
const pages = pdfDoc.getPages();
console.log(`PDF has ${pages.length} pages`);
let totalRendered = 0;
const annotationsToDelete: Array<{pageIndex: number, id: string}> = [];
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
if (pageIndex < pages.length) {
const page = pages[pageIndex];
const { width: pageWidth, height: pageHeight } = page.getSize();
for (const annotation of annotations) {
try {
console.log('Processing annotation:', annotation);
console.log('Annotation keys:', Object.keys(annotation));
// EmbedPDF annotations might have different property names
// Let's check for various possible rectangle properties
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
console.log('Rect found:', rect);
if (rect) {
// Extract original annotation position and size
const originalX = rect.origin?.x || rect.x || rect.left || 0;
const originalY = rect.origin?.y || rect.y || rect.top || 0;
const width = rect.size?.width || rect.width || 100;
const height = rect.size?.height || rect.height || 50;
// Convert EmbedPDF coordinates to PDF-lib coordinates
// EmbedPDF uses top-left origin, PDF-lib uses bottom-left origin
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
console.log('Signature positioning:', {
originalX,
originalY,
width,
height,
pdfX,
pdfY,
pageWidth,
pageHeight
});
// Try to get annotation image data - check multiple possible properties
console.log('Looking for image data in:', {
imageData: !!annotation.imageData,
appearance: !!annotation.appearance,
stampData: !!annotation.stampData,
imageSrc: !!annotation.imageSrc,
contents: !!annotation.contents,
data: !!annotation.data
});
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData || annotation.imageSrc || annotation.contents || annotation.data;
// If no image data found directly, try to get it from our storage using annotation ID
if (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
console.log('Found stored image data for annotation:', annotation.id);
imageDataUrl = storedImageData;
}
}
if (imageDataUrl) {
console.log('Found image data:', typeof imageDataUrl, imageDataUrl?.substring?.(0, 100));
}
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
try {
// Convert data URL to bytes
const base64Data = imageDataUrl.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
console.log(`Image data size: ${imageBytes.length} bytes`);
// Embed image in PDF based on data URL type
let image;
if (imageDataUrl.includes('data:image/jpeg') || imageDataUrl.includes('data:image/jpg')) {
console.log('Embedding as JPEG');
image = await pdfDoc.embedJpg(imageBytes);
} else if (imageDataUrl.includes('data:image/png')) {
console.log('Embedding as PNG');
image = await pdfDoc.embedPng(imageBytes);
} else {
console.log('Unknown image type, trying PNG as fallback');
image = await pdfDoc.embedPng(imageBytes);
}
const imageDims = image.size();
console.log(`Image dimensions: ${imageDims.width}x${imageDims.height}`);
// Draw image on page at annotation position
page.drawImage(image, {
x: pdfX,
y: pdfY,
width: width,
height: height,
});
totalRendered++;
annotationsToDelete.push({pageIndex, id: annotation.id});
console.log(`✓ SUCCESS: Rendered image annotation at (${pdfX}, ${pdfY}) size (${width}x${height})`);
} catch (imageError) {
console.error('Failed to render image annotation:', imageError);
}
} else if (annotation.content || annotation.text) {
// Handle text annotations
page.drawText(annotation.content || annotation.text, {
x: pdfX,
y: pdfY + height - 12, // Adjust for text baseline
size: 12,
color: rgb(0, 0, 0)
});
totalRendered++;
annotationsToDelete.push({pageIndex, id: annotation.id});
console.log(`Rendered text annotation: "${annotation.content || annotation.text}"`);
} else if (annotation.type === 14 || annotation.type === 15) {
// Handle ink annotations (drawn signatures)
// Type 14 = INK, Type 15 = could be another drawing type
console.log('Processing ink annotation:', annotation);
// For ink annotations, we'll draw a placeholder rectangle since we can't easily reconstruct the ink paths
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(0, 0, 0),
borderWidth: 2,
color: rgb(0.9, 0.9, 0.9), // Light gray background
opacity: 0.8
});
// Add text indicating this was a drawn signature
page.drawText('Drawn Signature', {
x: pdfX + 5,
y: pdfY + height / 2,
size: 10,
color: rgb(0, 0, 0)
});
totalRendered++;
annotationsToDelete.push({pageIndex, id: annotation.id});
console.log(`Rendered ink annotation placeholder at (${pdfX}, ${pdfY}) size (${width}x${height})`);
} else {
// Handle other annotation types
console.log(`Unknown annotation type ${annotation.type}:`, annotation);
// Draw a placeholder for unknown types
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(1, 0, 0),
borderWidth: 2,
color: rgb(1, 1, 0), // Yellow background
opacity: 0.5
});
totalRendered++;
annotationsToDelete.push({pageIndex, id: annotation.id});
console.log(`Rendered unknown annotation type ${annotation.type} as placeholder`);
}
}
} catch (annotationError) {
console.warn('Failed to render annotation:', annotationError);
}
}
}
}
console.log(`Successfully rendered ${totalRendered} annotations`);
// Annotations were already deleted from EmbedPDF before export
// Save the PDF with rendered annotations
const flattenedPdfBytes = await pdfDoc.save({ useObjectStreams: false, addDefaultPage: false });
console.log(`Original PDF size: ${pdfArrayBufferForFlattening.byteLength} bytes`);
console.log(`Modified PDF size: ${flattenedPdfBytes.length} bytes`);
const arrayBuffer = new ArrayBuffer(flattenedPdfBytes.length);
const uint8View = new Uint8Array(arrayBuffer);
uint8View.set(flattenedPdfBytes);
signedFile = new File([arrayBuffer], originalFile.name, { type: 'application/pdf' });
console.log('Manual annotation rendering completed');
// Verify the modified PDF can be loaded
try {
const verifyDoc = await PDFDocument.load(flattenedPdfBytes);
console.log(`✓ Verification: Modified PDF has ${verifyDoc.getPages().length} pages and can be loaded`);
} catch (verifyError) {
console.error('❌ Verification: Modified PDF cannot be loaded:', verifyError);
}
} catch (renderError) {
console.error('Failed to manually render annotations:', renderError);
console.warn('Signatures may only show as annotations');
}
} else {
console.log('No annotations found to render');
}
// Generate thumbnail and metadata for the signed file
const thumbnailResult = await generateThumbnailWithMetadata(signedFile);
@ -105,19 +393,28 @@ const Sign = (props: BaseToolProps) => {
// Replace the original file with the signed version
await consumeFiles(inputFileIds, [outputStirlingFile], [outputStub]);
// Reactivate the signature mode that was active before save
console.log('✓ File replaced in context, new file ID:', outputStub.id);
// Force refresh the viewer to show the flattened PDF
setTimeout(() => {
if (activeModeRef.current === 'draw') {
activateDrawMode();
} else if (activeModeRef.current === 'placement') {
handleSignaturePlacement();
}
// Navigate away from viewer and back to force reload
setWorkbench('fileEditor');
setTimeout(() => {
setWorkbench('viewer');
// Reactivate the signature mode that was active before save
if (activeModeRef.current === 'draw') {
activateDrawMode();
} else if (activeModeRef.current === 'placement') {
handleSignaturePlacement();
}
}, 100);
}, 200);
}
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, selectors, consumeFiles]);
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData]);
const getSteps = () => {
const steps = [];