mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
feat: multiple pages
This commit is contained in:
@ -14,7 +14,9 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e
|
||||
import {
|
||||
compositePageToBlob,
|
||||
getPageCanvasRefs,
|
||||
getRegisteredPageNumbers,
|
||||
} from '@documenso/lib/client-only/utils/page-canvas-registry';
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/ai';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
@ -138,6 +140,66 @@ const enforceMinimumFieldDimensions = (params: {
|
||||
};
|
||||
};
|
||||
|
||||
const processAllPagesWithAI = async (params: {
|
||||
pageNumbers: number[];
|
||||
onProgress: (current: number, total: number) => void;
|
||||
}): Promise<{
|
||||
fieldsPerPage: Map<number, TDetectedFormField[]>;
|
||||
errors: Map<number, Error>;
|
||||
}> => {
|
||||
const { pageNumbers, onProgress } = params;
|
||||
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||
const errors = new Map<number, Error>();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
pageNumbers.map(async (pageNumber) => {
|
||||
try {
|
||||
const blob = await compositePageToBlob(pageNumber);
|
||||
|
||||
if (!blob) {
|
||||
throw new Error(`Failed to capture page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, `page-${pageNumber}.png`);
|
||||
|
||||
const response = await fetch('/api/ai/detect-form-fields', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI detection failed for page ${pageNumber}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const detectedFields: TDetectedFormField[] = await response.json();
|
||||
|
||||
return { pageNumber, detectedFields };
|
||||
} catch (error) {
|
||||
throw { pageNumber, error };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let completedCount = 0;
|
||||
|
||||
results.forEach((result) => {
|
||||
completedCount++;
|
||||
onProgress(completedCount, pageNumbers.length);
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
const { pageNumber, detectedFields } = result.value;
|
||||
fieldsPerPage.set(pageNumber, detectedFields);
|
||||
} else {
|
||||
const { pageNumber, error } = result.reason;
|
||||
errors.set(pageNumber, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
|
||||
return { fieldsPerPage, errors };
|
||||
};
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
@ -161,6 +223,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
||||
const [processingProgress, setProcessingProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@ -286,34 +352,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
disabled={isAutoAddingFields}
|
||||
onClick={async () => {
|
||||
setIsAutoAddingFields(true);
|
||||
setProcessingProgress(null);
|
||||
|
||||
try {
|
||||
const blob = await compositePageToBlob(1);
|
||||
|
||||
if (!blob) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to capture page. Please ensure the document is fully loaded.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, 'page-1.png');
|
||||
|
||||
const response = await fetch('/api/ai/detect-form-fields', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI detection failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const detectedFields = await response.json();
|
||||
|
||||
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
|
||||
toast({
|
||||
title: t`Warning`,
|
||||
@ -323,80 +364,123 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageCanvasRefs = getPageCanvasRefs(1);
|
||||
if (!pageCanvasRefs) {
|
||||
const pageNumbers = getRegisteredPageNumbers();
|
||||
|
||||
if (pageNumbers.length === 0) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to capture page. Please ensure the document is fully loaded.`,
|
||||
description: t`No pages found. Please ensure the document is fully loaded.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
for (const detected of detectedFields) {
|
||||
const [ymin, xmin, ymax, xmax] = detected.box_2d;
|
||||
let positionX = (xmin / 1000) * 100;
|
||||
let positionY = (ymin / 1000) * 100;
|
||||
let width = ((xmax - xmin) / 1000) * 100;
|
||||
let height = ((ymax - ymin) / 1000) * 100;
|
||||
const { fieldsPerPage, errors } = await processAllPagesWithAI({
|
||||
pageNumbers,
|
||||
onProgress: (current, total) => {
|
||||
setProcessingProgress({ current, total });
|
||||
},
|
||||
});
|
||||
|
||||
if (pageCanvasRefs) {
|
||||
const adjusted = enforceMinimumFieldDimensions({
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
pageWidth: pageCanvasRefs.pdfCanvas.width,
|
||||
pageHeight: pageCanvasRefs.pdfCanvas.height,
|
||||
});
|
||||
let totalAdded = 0;
|
||||
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
|
||||
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
|
||||
|
||||
positionX = adjusted.positionX;
|
||||
positionY = adjusted.positionY;
|
||||
width = adjusted.width;
|
||||
height = adjusted.height;
|
||||
}
|
||||
for (const detected of detectedFields) {
|
||||
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
|
||||
let positionX = (xmin / 1000) * 100;
|
||||
let positionY = (ymin / 1000) * 100;
|
||||
let width = ((xmax - xmin) / 1000) * 100;
|
||||
let height = ((ymax - ymin) / 1000) * 100;
|
||||
|
||||
const fieldType = detected.label as FieldType;
|
||||
if (pageCanvasRefs) {
|
||||
const adjusted = enforceMinimumFieldDimensions({
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
pageWidth: pageCanvasRefs.pdfCanvas.width,
|
||||
pageHeight: pageCanvasRefs.pdfCanvas.height,
|
||||
});
|
||||
|
||||
try {
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: 1,
|
||||
type: fieldType,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
recipientId: editorFields.selectedRecipient.id,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||
});
|
||||
addedCount++;
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to add field. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
positionX = adjusted.positionX;
|
||||
positionY = adjusted.positionY;
|
||||
width = adjusted.width;
|
||||
height = adjusted.height;
|
||||
}
|
||||
|
||||
const fieldType = detected.label as FieldType;
|
||||
|
||||
try {
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageNumber,
|
||||
type: fieldType,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
recipientId: editorFields.selectedRecipient.id,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||
});
|
||||
totalAdded++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Added ${addedCount} fields to the document`,
|
||||
});
|
||||
const successfulPages = fieldsPerPage.size;
|
||||
const failedPages = errors.size;
|
||||
|
||||
if (totalAdded > 0) {
|
||||
let description = t`Added ${totalAdded} fields`;
|
||||
if (pageNumbers.length > 1) {
|
||||
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
|
||||
}
|
||||
if (failedPages > 0) {
|
||||
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description,
|
||||
});
|
||||
} else if (failedPages > 0) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Info`,
|
||||
description: t`No fields were detected in the document`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An unexpected error occurred while capturing the page.`,
|
||||
description: t`An unexpected error occurred while processing pages.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsAutoAddingFields(false);
|
||||
setProcessingProgress(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAutoAddingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
|
||||
{isAutoAddingFields ? (
|
||||
processingProgress ? (
|
||||
<Trans>
|
||||
Processing page {processingProgress.current} of {processingProgress.total}...
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Processing...</Trans>
|
||||
)
|
||||
) : (
|
||||
<Trans>Auto add fields</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ IMPORTANT RULES:
|
||||
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
|
||||
2. Analyze nearby text labels to determine the field type
|
||||
3. Return bounding boxes for the fillable area only, NOT the label text
|
||||
4. Each bounding box must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
|
||||
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
|
||||
|
||||
FIELD TYPES TO DETECT:
|
||||
• SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
|
||||
@ -182,7 +182,7 @@ export const aiRoute = new Hono<HonoEnv>().post('/detect-form-fields', async (c)
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
|
||||
detectedFields.forEach((field, index) => {
|
||||
const [ymin, xmin, ymax, xmax] = field.box_2d.map((coord) => coord / 1000);
|
||||
const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000);
|
||||
|
||||
const x = xmin * imageWidth + padding.left;
|
||||
const y = ymin * imageHeight + padding.top;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/ai';
|
||||
|
||||
export const ZGenerateTextRequestSchema = z.object({
|
||||
prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'),
|
||||
});
|
||||
@ -12,7 +14,7 @@ export type TGenerateTextRequest = z.infer<typeof ZGenerateTextRequestSchema>;
|
||||
export type TGenerateTextResponse = z.infer<typeof ZGenerateTextResponseSchema>;
|
||||
|
||||
export const ZDetectedFormFieldSchema = z.object({
|
||||
box_2d: z
|
||||
boundingBox: z
|
||||
.array(z.number())
|
||||
.length(4)
|
||||
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
|
||||
@ -38,6 +40,6 @@ export const ZDetectFormFieldsRequestSchema = z.object({
|
||||
|
||||
export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema);
|
||||
|
||||
export type TDetectedFormField = z.infer<typeof ZDetectedFormFieldSchema>;
|
||||
export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSchema>;
|
||||
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
|
||||
export type { TDetectedFormField };
|
||||
|
||||
14
packages/lib/types/ai.ts
Normal file
14
packages/lib/types/ai.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export type TDetectedFormField = {
|
||||
boundingBox: number[];
|
||||
label:
|
||||
| 'SIGNATURE'
|
||||
| 'INITIALS'
|
||||
| 'NAME'
|
||||
| 'EMAIL'
|
||||
| 'DATE'
|
||||
| 'TEXT'
|
||||
| 'NUMBER'
|
||||
| 'RADIO'
|
||||
| 'CHECKBOX'
|
||||
| 'DROPDOWN';
|
||||
};
|
||||
Reference in New Issue
Block a user