feat: multiple pages

This commit is contained in:
Ephraim Atta-Duncan
2025-10-31 03:27:37 +00:00
parent 134d5ac03e
commit 233e6e603c
4 changed files with 180 additions and 80 deletions

View File

@ -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>

View File

@ -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;

View File

@ -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
View File

@ -0,0 +1,14 @@
export type TDetectedFormField = {
boundingBox: number[];
label:
| 'SIGNATURE'
| 'INITIALS'
| 'NAME'
| 'EMAIL'
| 'DATE'
| 'TEXT'
| 'NUMBER'
| 'RADIO'
| 'CHECKBOX'
| 'DROPDOWN';
};