From 0d25c3ee84bcd31e48f1082e2399cfb72defb718 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 31 Dec 2025 19:48:26 +0100 Subject: [PATCH 1/3] feat: enhance signature handling and API integration - Updated the `normalizeFields` function to accept a `signatureMode` parameter for better handling of signature types. - Introduced a new utility function `textToImageDataUrl` to convert typed signatures into PNG data URLs. - Refactored the App component to utilize the new signature handling logic, including mapping signer fields based on type. - Added unit tests for the `textToImageDataUrl` function to ensure correct functionality. - Improved the demo to include a configuration for signer fields and updated API calls for signing and downloading documents. --- demo/server/server.js | 23 +++++---- demo/src/App.tsx | 85 +++++++++++++++++++++++---------- src/__tests__/signature.test.ts | 11 +++++ src/index.tsx | 29 +---------- src/utils/signature.ts | 27 +++++++++++ 5 files changed, 115 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/signature.test.ts create mode 100644 src/utils/signature.ts diff --git a/demo/server/server.js b/demo/server/server.js index 36d5b3c..f639d64 100644 --- a/demo/server/server.js +++ b/demo/server/server.js @@ -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 : []; @@ -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') { @@ -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' }); @@ -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, @@ -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) { @@ -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, diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 4940b1f..5cb325d 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -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, @@ -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(); @@ -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 }, @@ -108,6 +158,7 @@ export function App() { plan: documentFields['456789'], }, fileName: `signed_agreement_${data.eventId}.pdf`, + signatureMode: 'sign', }), }); @@ -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', }), }); @@ -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} diff --git a/src/__tests__/signature.test.ts b/src/__tests__/signature.test.ts new file mode 100644 index 0000000..28d7282 --- /dev/null +++ b/src/__tests__/signature.test.ts @@ -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'); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index bcd4a86..d7313f9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import { useRef, useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; import type { SuperDoc } from 'superdoc'; import type * as Types from './types'; +import { textToImageDataUrl } from './utils/signature'; import { SignatureInput, CheckboxInput, @@ -9,6 +10,7 @@ import { } from './defaults'; export * from './types'; +export { textToImageDataUrl }; export { SignatureInput, CheckboxInput }; type Editor = NonNullable; @@ -82,33 +84,6 @@ const SuperDocESign = forwardRef { if (!editor) return; diff --git a/src/utils/signature.ts b/src/utils/signature.ts new file mode 100644 index 0000000..70ea8c7 --- /dev/null +++ b/src/utils/signature.ts @@ -0,0 +1,27 @@ +// Convert typed signature text into a PNG data URL for consistent rendering. +export const textToImageDataUrl = (text: string): string => { + const canvas = globalThis.document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + const fontSize = 30; + ctx.font = `italic ${fontSize}px cursive`; + + const metrics = ctx.measureText(text); + const textWidth = metrics.width; + + const estimatedHeight = fontSize * 1.3; + const paddingX = 4; + const paddingY = 6; + + canvas.width = Math.ceil(textWidth + paddingX * 2) + 20; + canvas.height = Math.ceil(estimatedHeight + paddingY * 2); + + ctx.font = `italic ${fontSize}px cursive`; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + return canvas.toDataURL('image/png'); +}; From ac8a6d227060251b5469c237102d3673bc00b8fa Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 31 Dec 2025 20:15:19 +0100 Subject: [PATCH 2/3] feat: resize signature pad and trim signature whitespaces --- demo/src/CustomSignature.tsx | 119 ++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/demo/src/CustomSignature.tsx b/demo/src/CustomSignature.tsx index 584a155..507b055 100644 --- a/demo/src/CustomSignature.tsx +++ b/demo/src/CustomSignature.tsx @@ -2,12 +2,101 @@ import React, { useEffect, useRef, useState } from 'react'; import SignaturePad from 'signature_pad'; import type { FieldComponentProps } from '@superdoc-dev/esign'; +// Trim whitespace around strokes by tightening the SVG viewBox. +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 => + 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 = ({ value, onChange, isDisabled, label }) => { const [mode, setMode] = useState<'type' | 'draw'>('type'); const canvasRef = useRef(null); const signaturePadRef = useRef(null); + const commitTimerRef = useRef | null>(null); + const latestDataUrlRef = useRef(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) { @@ -18,6 +107,9 @@ const CustomSignature: React.FC = ({ value, onChange, isDis const clearCanvas = () => { if (signaturePadRef.current) { signaturePadRef.current.clear(); + clearCommitTimer(); + latestDataUrlRef.current = null; + conversionIdRef.current += 1; onChange(''); } }; @@ -25,6 +117,22 @@ const CustomSignature: React.FC = ({ value, onChange, isDis 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(); + }; + + resizeCanvas(); + signaturePadRef.current = new SignaturePad(canvasRef.current, { backgroundColor: 'rgb(255, 255, 255)', penColor: 'rgb(0, 0, 0)', @@ -36,14 +144,21 @@ const CustomSignature: React.FC = ({ value, onChange, isDis signaturePadRef.current.addEventListener('endStroke', () => { if (signaturePadRef.current) { - onChange(signaturePadRef.current.toDataURL()); + clearCommitTimer(); + commitTimerRef.current = setTimeout(() => { + commitSignature(); + }, 1000); } }); + window.addEventListener('resize', resizeCanvas); + return () => { if (signaturePadRef.current) { signaturePadRef.current.off(); } + clearCommitTimer(); + window.removeEventListener('resize', resizeCanvas); }; }, [mode, isDisabled, onChange]); @@ -109,8 +224,6 @@ const CustomSignature: React.FC = ({ value, onChange, isDis
Date: Mon, 5 Jan 2026 23:58:32 +0100 Subject: [PATCH 3/3] feat: refactor layout with CSS classes for main content and sidebar --- demo/src/App.css | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ demo/src/App.tsx | 24 +++++---------------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/demo/src/App.css b/demo/src/App.css index e7f97ae..6f47d6c 100644 --- a/demo/src/App.css +++ b/demo/src/App.css @@ -484,4 +484,60 @@ header { .superdoc-esign-actions { gap: 12px; +} + +/* Main layout container */ +.main-layout-container { + display: flex; + gap: 24px; +} + +/* Main content area */ +.main-content-area { + flex: 1; + min-width: 0; +} + +/* Right sidebar */ +.document-fields-sidebar { + width: 280px; + flex-shrink: 0; + padding: 16px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + align-self: flex-start; +} + +.document-fields-sidebar h3 { + margin: 0 0 16px; + font-size: 14px; + font-weight: 600; + color: #374151; +} + +.document-fields-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.document-field { + /* Individual field styles handled inline for now */ +} + +/* Responsive layout - stack vertically on small screens */ +@media (max-width: 768px) { + .main-layout-container { + flex-direction: column; + } + + .document-fields-sidebar { + width: 100%; + order: 2; /* Move sidebar below the main content */ + } + + .main-content-area { + order: 1; + } } \ No newline at end of file diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 5cb325d..b78c8e7 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -308,9 +308,9 @@ export function App() { Use the document toolbar to download the current agreement at any time.

-
+
{/* Main content */} -
+
{/* Right Sidebar - Document Fields */} -
-

- Document Fields -

-
+
+

Document Fields

+
{documentFieldsConfig.map((field) => (