Skip to content
This repository was archived by the owner on Jan 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions demo/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});

const normalizeFields = (fieldsPayload = {}) => {
const normalizeFields = (fieldsPayload = {}, signatureMode = 'annotate') => {
const documentFields = Array.isArray(fieldsPayload.document) ? fieldsPayload.document : [];
const signerFields = Array.isArray(fieldsPayload.signer) ? fieldsPayload.signer : [];

Expand All @@ -38,8 +38,8 @@ const normalizeFields = (fieldsPayload = {}) => {
.map((field) => {
const isSignatureField = field.id === SIGNATURE_FIELD_ID;
const value = field.value ?? '';
const isDrawnSignature = typeof value === 'string' && value.startsWith('data:image/');
const type = isSignatureField && isDrawnSignature ? 'signature' : 'text';
const signatureType = signatureMode === 'sign' ? 'signature' : 'image';
const type = isSignatureField ? signatureType : 'text';

const normalized = { id: field.id, value, type };
if (type === 'signature') {
Expand Down Expand Up @@ -85,7 +85,8 @@ const sendPdfBuffer = (res, base64, fileName, contentType = 'application/pdf') =

app.post('/v1/download', async (req, res) => {
try {
const { document, fields = {}, fileName = 'document.pdf' } = req.body || {};
const { document, fields = {}, fileName = 'document.pdf', signatureMode = 'annotate' } =
req.body || {};

if (!SUPERDOC_SERVICES_API_KEY) {
return res.status(500).json({ error: 'Missing SUPERDOC_SERVICES_API_KEY on the server' });
Expand All @@ -95,7 +96,7 @@ app.post('/v1/download', async (req, res) => {
return res.status(400).json({ error: 'document.url is required' });
}

const annotatedFields = normalizeFields(fields);
const annotatedFields = normalizeFields(fields, signatureMode);

const { base64, contentType } = await annotateDocument({
documentUrl: document.url,
Expand Down Expand Up @@ -129,6 +130,7 @@ app.post('/v1/sign', async (req, res) => {
certificate,
metadata,
fileName = 'signed-document.pdf',
signatureMode = 'sign',
} = req.body || {};

if (!SUPERDOC_SERVICES_API_KEY) {
Expand All @@ -139,10 +141,13 @@ app.post('/v1/sign', async (req, res) => {
return res.status(400).json({ error: 'document.url is required' });
}

const annotatedFields = normalizeFields({
document: documentFields,
signer: signerFields,
});
const annotatedFields = normalizeFields(
{
document: documentFields,
signer: signerFields,
},
signatureMode,
);

const { base64: annotatedBase64 } = await annotateDocument({
documentUrl: document.url,
Expand Down
85 changes: 61 additions & 24 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react';
import SuperDocESign from '@superdoc-dev/esign';
import SuperDocESign, { textToImageDataUrl } from '@superdoc-dev/esign';
import type {
SubmitData,
SigningState,
Expand All @@ -16,6 +16,54 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const documentSource =
'https://storage.googleapis.com/public_static_hosting/public_demo_docs/service_agreement_updated.docx';

const signerFieldsConfig = [
{
id: '789012',
type: 'signature' as const,
label: 'Your Signature',
validation: { required: true },
component: CustomSignature,
},
{
id: 'terms',
type: 'checkbox' as const,
label: 'I accept the terms and conditions',
validation: { required: true },
},
{
id: 'email',
type: 'checkbox' as const,
label: 'Send me a copy of the agreement',
validation: { required: false },
},
];

const signatureFieldIds = new Set(
signerFieldsConfig.filter((field) => field.type === 'signature').map((field) => field.id),
);

const toSignatureImageValue = (value: SubmitData['signerFields'][number]['value']) => {
if (value === null || value === undefined) return null;
if (typeof value === 'string' && value.startsWith('data:image/')) return value;
return textToImageDataUrl(String(value));
};

const mapSignerFieldsWithType = (
fields: Array<{ id: string; value: SubmitData['signerFields'][number]['value'] }>,
signatureType: 'signature' | 'image',
) =>
fields.map((field) => {
if (!signatureFieldIds.has(field.id)) {
return field;
}

return {
...field,
type: signatureType,
value: toSignatureImageValue(field.value),
};
});

// Helper to download a response blob as a file
const downloadBlob = async (response: Response, fileName: string) => {
const blob = await response.blob();
Expand Down Expand Up @@ -93,13 +141,15 @@ export function App() {
console.log('Submit data:', data);

try {
const signerFields = mapSignerFieldsWithType(data.signerFields, 'signature');

const response = await fetch(`${API_BASE_URL}/v1/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document: { url: documentSource },
documentFields: data.documentFields,
signerFields: data.signerFields,
signerFields,
auditTrail: data.auditTrail,
eventId: data.eventId,
certificate: { enable: true },
Expand All @@ -108,6 +158,7 @@ export function App() {
plan: documentFields['456789'],
},
fileName: `signed_agreement_${data.eventId}.pdf`,
signatureMode: 'sign',
}),
});

Expand All @@ -134,13 +185,19 @@ export function App() {
return;
}

const signerFields = mapSignerFieldsWithType(data.fields.signer, 'image');

const response = await fetch(`${API_BASE_URL}/v1/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document: { url: data.documentSource },
fields: data.fields,
fields: {
...data.fields,
signer: signerFields,
},
fileName: data.fileName,
signatureMode: 'annotate',
}),
});

Expand Down Expand Up @@ -272,27 +329,7 @@ export function App() {
value: documentFields[f.id],
type: f.type,
})),
signer: [
{
id: '789012',
type: 'signature',
label: 'Your Signature',
validation: { required: true },
component: CustomSignature,
},
{
id: 'terms',
type: 'checkbox',
label: 'I accept the terms and conditions',
validation: { required: true },
},
{
id: 'email',
type: 'checkbox',
label: 'Send me a copy of the agreement',
validation: { required: false },
},
],
signer: signerFieldsConfig,
}}
download={{ label: 'Download PDF' }}
onSubmit={handleSubmit}
Expand Down
119 changes: 116 additions & 3 deletions demo/src/CustomSignature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,101 @@
import SignaturePad from 'signature_pad';
import type { FieldComponentProps } from '@superdoc-dev/esign';

// Trim whitespace around strokes by tightening the SVG viewBox.
Comment thread
andrii-harbour marked this conversation as resolved.
const cropSVG = (svgText: string): string => {
const container = document.createElement('div');
container.setAttribute('style', 'visibility: hidden; position: absolute; left: -9999px;');
document.body.appendChild(container);

try {
container.innerHTML = svgText;
const svgElement = container.getElementsByTagName('svg')[0];
if (!svgElement) return svgText;

const bbox = svgElement.getBBox();
if (bbox.width === 0 || bbox.height === 0) return svgText;

const padding = 5;
const viewBox = [
bbox.x - padding,
bbox.y - padding,
bbox.width + padding * 2,
bbox.height + padding * 2,
].join(' ');
svgElement.setAttribute('viewBox', viewBox);
svgElement.setAttribute('width', String(Math.ceil(bbox.width + padding * 2)));
svgElement.setAttribute('height', String(Math.ceil(bbox.height + padding * 2)));

return svgElement.outerHTML;
} finally {
container.remove();
}
};

// Rasterize a cropped SVG into a PNG data URL.
const svgToPngDataUrl = (svgText: string): Promise<string> =>
new Promise((resolve, reject) => {
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`;
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas context not available'));
return;
}
ctx.drawImage(img, 0, 0);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => reject(new Error('Failed to load SVG for rasterization'));
img.src = svgDataUrl;
});

const CustomSignature: React.FC<FieldComponentProps> = ({ value, onChange, isDisabled, label }) => {
const [mode, setMode] = useState<'type' | 'draw'>('type');
const canvasRef = useRef<HTMLCanvasElement>(null);
const signaturePadRef = useRef<SignaturePad | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestDataUrlRef = useRef<string | null>(null);
const conversionIdRef = useRef(0);

const clearCommitTimer = () => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
};

// Debounced export to avoid re-rendering during active drawing.
const commitSignature = () => {
if (!signaturePadRef.current) return;
if (signaturePadRef.current.isEmpty()) {
latestDataUrlRef.current = null;
onChange('');
return;
}

const svgText = signaturePadRef.current.toSVG();
const croppedSvg = cropSVG(svgText);
const conversionId = ++conversionIdRef.current;

svgToPngDataUrl(croppedSvg)
.then((dataUrl) => {
if (conversionIdRef.current !== conversionId) return;
latestDataUrlRef.current = dataUrl;
onChange(dataUrl);
})
.catch((error) => {
console.error('Failed to convert signature to PNG:', error);
});
};

const switchMode = (newMode: 'type' | 'draw') => {
clearCommitTimer();
latestDataUrlRef.current = null;
conversionIdRef.current += 1;
setMode(newMode);
onChange('');
if (newMode === 'draw' && signaturePadRef.current) {
Expand All @@ -18,13 +107,32 @@
const clearCanvas = () => {
if (signaturePadRef.current) {
signaturePadRef.current.clear();
clearCommitTimer();
latestDataUrlRef.current = null;
conversionIdRef.current += 1;
onChange('');
}
};

useEffect(() => {
if (!canvasRef.current || mode !== 'draw') return;

const canvas = canvasRef.current;
// Match canvas pixels to display size for correct pointer mapping.
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = Math.floor(rect.width * ratio);
canvas.height = Math.floor(rect.height * ratio);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(ratio, ratio);
}
signaturePadRef.current?.clear();
Comment thread
andrii-harbour marked this conversation as resolved.
};

resizeCanvas();

signaturePadRef.current = new SignaturePad(canvasRef.current, {
backgroundColor: 'rgb(255, 255, 255)',
penColor: 'rgb(0, 0, 0)',
Expand All @@ -36,16 +144,23 @@

signaturePadRef.current.addEventListener('endStroke', () => {
if (signaturePadRef.current) {
onChange(signaturePadRef.current.toDataURL());
clearCommitTimer();
commitTimerRef.current = setTimeout(() => {
commitSignature();
}, 1000);
Comment thread
caio-pizzol marked this conversation as resolved.
}
});

window.addEventListener('resize', resizeCanvas);

return () => {
if (signaturePadRef.current) {
signaturePadRef.current.off();
}
clearCommitTimer();
window.removeEventListener('resize', resizeCanvas);
};
}, [mode, isDisabled, onChange]);

Check warning on line 163 in demo/src/CustomSignature.tsx

View workflow job for this annotation

GitHub Actions / validate

React Hook useEffect has a missing dependency: 'commitSignature'. Either include it or remove the dependency array

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
Expand Down Expand Up @@ -109,8 +224,6 @@
<div>
<canvas
ref={canvasRef}
width={500}
height={150}
style={{
border: '1px solid #d1d5db',
borderRadius: '8px',
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/signature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';

import { textToImageDataUrl } from '../utils/signature';

describe('textToImageDataUrl', () => {
it('returns a data URL for a typed signature', () => {
const result = textToImageDataUrl('Jane Doe');
// the mock is generated by the test/setup.ts file
expect(result).toBe('data:image/png;base64,mock');
});
});
Loading
Loading