mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: autoplace fields from placeholders (#2111)
This PR introduces automatic detection and placement of fields and
recipients based on PDF placeholders.
The placeholders have the following structure:
- `{{fieldType,recipientPosition,fieldMeta}}`
- `{{text,r1,required=true,textAlign=right,fontSize=50}}`
When the user uploads a PDF document containing such placeholders, they
get converted automatically to Documenso fields and assigned to
recipients.
This commit is contained in:
parent
d77f81163b
commit
d18dcb4d60
22 changed files with 2045 additions and 50 deletions
|
|
@ -0,0 +1,161 @@
|
|||
---
|
||||
date: 2026-01-28
|
||||
title: Pdf Placeholder Field Positioning
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enables automatic field placement in PDFs using placeholder text, eliminating the need for manual coordinate-based positioning. It supports two complementary workflows:
|
||||
|
||||
1. **Automatic detection on upload** - PDFs containing structured placeholders like `{{signature, r1}}` have fields created automatically when uploaded
|
||||
2. **API placeholder positioning** - Developers can reference any text in a PDF to position fields instead of calculating coordinates
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow users to prepare documents in Word/Google Docs with placeholders that become signature fields
|
||||
- Reduce friction for document preparation workflows
|
||||
- Provide API developers with a simpler alternative to coordinate-based field positioning
|
||||
- Support documents with repeated placeholders (e.g., initials on every page)
|
||||
|
||||
## Placeholder Format (Automatic Detection)
|
||||
|
||||
```
|
||||
{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}}
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- **FIELD_TYPE** (required): One of `signature`, `initials`, `name`, `email`, `date`, `text`, `number`, `radio`, `checkbox`, `dropdown`
|
||||
- **RECIPIENT** (required): `r1`, `r2`, `r3`, etc. - identifies which recipient the field belongs to
|
||||
- **OPTIONS** (optional): Key-value pairs like `required=true`, `fontSize=14`, `readOnly=true`
|
||||
|
||||
### Examples
|
||||
|
||||
- `{{signature, r1}}` - Signature field for first recipient
|
||||
- `{{text, r1, required=true, label=Company Name}}` - Required text field with label
|
||||
- `{{number, r2, minValue=0, maxValue=100}}` - Number field with validation
|
||||
|
||||
### Behavior
|
||||
|
||||
- Placeholders without recipient identifiers (e.g., `{{signature}}`) are skipped during automatic detection - reserved for API use
|
||||
- Invalid field types are silently skipped
|
||||
- Placeholder text is covered with white rectangles after field creation
|
||||
|
||||
## API Placeholder Positioning
|
||||
|
||||
The `/api/v2/envelope/field/create-many` endpoint accepts `placeholder` as an alternative to coordinates:
|
||||
|
||||
```json
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "SIGNATURE",
|
||||
"placeholder": "{{signature}}"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------- | ------- | -------------------------------------------- |
|
||||
| `placeholder` | string | Text to search for in the PDF |
|
||||
| `width` | number | Optional override (percentage) |
|
||||
| `height` | number | Optional override (percentage) |
|
||||
| `matchAll` | boolean | When true, creates fields at ALL occurrences |
|
||||
|
||||
### matchAll Behavior
|
||||
|
||||
- Default (`false`): Only first occurrence gets a field
|
||||
- `true`: Creates a field at every occurrence of the placeholder text
|
||||
|
||||
This is useful for documents requiring initials on every page.
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### Core Functions
|
||||
|
||||
- `extractPlaceholdersFromPDF()` - Scans PDF for `{{...}}` patterns with recipient identifiers
|
||||
- `removePlaceholdersFromPDF()` - Covers placeholder text with white rectangles
|
||||
- `whiteoutRegions()` - Low-level helper for drawing white boxes on PDF pages
|
||||
- `parseFieldTypeFromPlaceholder()` - Converts placeholder field type to FieldType enum
|
||||
- `parseFieldMetaFromPlaceholder()` - Parses options into fieldMeta format
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **Upload flow** (`create-envelope.ts`, `create-envelope-items.ts`)
|
||||
- Extract placeholders at upload time (before saving to storage)
|
||||
- Pass placeholders in-memory to envelope creation
|
||||
- Create placeholder recipients if none provided
|
||||
- Create fields within the same transaction
|
||||
|
||||
2. **API field creation** (`create-envelope-fields.ts`)
|
||||
- Accept `placeholder` as alternative to coordinates
|
||||
- Search PDF for placeholder text
|
||||
- Resolve position from bounding box
|
||||
- Support `matchAll` for multiple occurrences
|
||||
|
||||
### Field Meta Parsing
|
||||
|
||||
The following properties are explicitly parsed:
|
||||
|
||||
- `required`, `readOnly` → boolean
|
||||
- `fontSize`, `minValue`, `maxValue`, `characterLimit` → number
|
||||
- Other properties pass through as strings
|
||||
|
||||
Note: Signature fields do not support fieldMeta options.
|
||||
|
||||
## Testing
|
||||
|
||||
### E2E Tests
|
||||
|
||||
**UI Tests** (`e2e/auto-placing-fields/`):
|
||||
|
||||
- Single recipient placeholder detection
|
||||
- Multiple recipient placeholder detection
|
||||
- Field configuration from placeholder options
|
||||
- Skipping placeholders without recipient identifiers
|
||||
- Skipping invalid field types
|
||||
|
||||
**API Tests** (`e2e/api/v2/placeholder-fields-api.spec.ts`):
|
||||
|
||||
- Placeholder-based field positioning
|
||||
- Width/height overrides
|
||||
- Error on placeholder not found
|
||||
- Mixed coordinate and placeholder positioning
|
||||
- First occurrence only (default)
|
||||
- All occurrences with `matchAll: true`
|
||||
|
||||
## Documentation
|
||||
|
||||
### User Documentation
|
||||
|
||||
`/users/documents/pdf-placeholders` - Explains:
|
||||
|
||||
- Placeholder format and syntax
|
||||
- Supported field types
|
||||
- Recipient identifiers
|
||||
- Available options per field type
|
||||
- Troubleshooting
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
`/developers/public-api/reference` - Documents:
|
||||
|
||||
- Coordinate-based positioning (existing)
|
||||
- Placeholder-based positioning (new)
|
||||
- matchAll parameter
|
||||
- Mixing both methods
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
1. **No placeholders found** - Original PDF returned unchanged
|
||||
2. **Placeholder not found (API)** - Returns error with placeholder text
|
||||
3. **Multiple occurrences** - First only by default, all with `matchAll: true`
|
||||
4. **No recipient identifier** - Skipped during auto-detection, works for API
|
||||
5. **Invalid field type** - Skipped during auto-detection
|
||||
6. **Signature field with options** - Options ignored (signature doesn't support fieldMeta)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Support for placeholder text styles (bold, underline) to indicate field properties
|
||||
- Template-level placeholder mapping for reusable configurations
|
||||
- Placeholder validation in document editor before sending
|
||||
|
|
@ -316,6 +316,8 @@ Before adding fields to an envelope, you will need the following details:
|
|||
|
||||
See the [Get Envelope](#get-envelope) section for more details on how to retrieve these details.
|
||||
|
||||
### Coordinate-Based Positioning
|
||||
|
||||
The following is an example of a request which creates 2 new fields on the first page of the envelope.
|
||||
|
||||
Note that width, height, positionX and positionY are percentage numbers between 0 and 100, which scale the field relative to the size of the PDF.
|
||||
|
|
@ -360,6 +362,95 @@ curl https://app.documenso.com/api/v2/envelope/field/create-many \
|
|||
}'
|
||||
```
|
||||
|
||||
### Placeholder-Based Positioning
|
||||
|
||||
Instead of specifying exact coordinates, you can position fields using placeholder text in the PDF. The API will search for the text and place the field at that location.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- You have PDFs with designated placeholder text (e.g., `{{signature}}`, `[SIGN HERE]`)
|
||||
- You want field positions to adapt to document content changes
|
||||
- You're working with templated documents generated from other systems
|
||||
|
||||
```sh
|
||||
curl https://app.documenso.com/api/v2/envelope/field/create-many \
|
||||
--request POST \
|
||||
--header 'Authorization: api_xxxxxxxxxxxxxx' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"envelopeId": "envelope_xxxxxxxxxx",
|
||||
"data": [
|
||||
{
|
||||
"recipientId": recipient_id_here,
|
||||
"type": "SIGNATURE",
|
||||
"placeholder": "{{signature}}"
|
||||
},
|
||||
{
|
||||
"recipientId": recipient_id_here,
|
||||
"type": "NAME",
|
||||
"placeholder": "{{name}}",
|
||||
"width": 30,
|
||||
"height": 5
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Placeholder Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `placeholder` | string | Yes | Text to search for in the PDF. The field is placed at the location of this text. |
|
||||
| `width` | number | No | Override the field width (percentage). If omitted, uses the placeholder text width. |
|
||||
| `height` | number | No | Override the field height (percentage). If omitted, uses the placeholder text height. |
|
||||
| `matchAll` | boolean | No | When `true`, creates a field at every occurrence of the placeholder. Default is `false` (first occurrence only). |
|
||||
|
||||
<Callout type="info">
|
||||
The placeholder text is automatically covered with a white rectangle after field creation, so it
|
||||
won't appear in the final signed document.
|
||||
</Callout>
|
||||
|
||||
#### Multiple Occurrences
|
||||
|
||||
If your PDF contains the same placeholder text multiple times (e.g., initials on every page), use `matchAll: true` to create fields at all occurrences:
|
||||
|
||||
```json
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "INITIALS",
|
||||
"placeholder": "{{initials}}",
|
||||
"matchAll": true
|
||||
}
|
||||
```
|
||||
|
||||
This will create one INITIALS field for each occurrence of `{{initials}}` in the PDF.
|
||||
|
||||
#### Mixing Positioning Methods
|
||||
|
||||
You can combine coordinate-based and placeholder-based positioning in the same request:
|
||||
|
||||
```json
|
||||
{
|
||||
"envelopeId": "envelope_xxxxxxxxxx",
|
||||
"data": [
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "SIGNATURE",
|
||||
"placeholder": "{{signature}}"
|
||||
},
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "DATE",
|
||||
"page": 1,
|
||||
"positionX": 70,
|
||||
"positionY": 85,
|
||||
"width": 20,
|
||||
"height": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field meta allows you to further configure fields, for example it will allow you to add multiple items for checkboxes or radios.
|
||||
|
||||
A successful request will return a JSON response with the newly added fields.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export default {
|
|||
'document-preferences': 'Document Preferences',
|
||||
'document-visibility': 'Document Visibility',
|
||||
fields: 'Document Fields',
|
||||
'pdf-placeholders': 'PDF Placeholders',
|
||||
'email-preferences': 'Email Preferences',
|
||||
'ai-detection': 'AI Recipient & Field Detection',
|
||||
'default-recipients': 'Default Recipients',
|
||||
|
|
|
|||
179
apps/documentation/pages/users/documents/pdf-placeholders.mdx
Normal file
179
apps/documentation/pages/users/documents/pdf-placeholders.mdx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
title: PDF Placeholders
|
||||
description: Learn how to use placeholder text in your PDFs for automatic field placement in Documenso.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# PDF Placeholders
|
||||
|
||||
Documenso can automatically detect placeholder text in your PDF documents and create fields at those locations. This allows you to prepare documents in your preferred editing tool (Word, Google Docs, etc.) with placeholders that become signature fields when uploaded.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you upload a PDF, Documenso scans for text matching the placeholder pattern `{{...}}`. Each placeholder can specify:
|
||||
|
||||
1. **Field type** - What kind of field to create (signature, name, email, etc.)
|
||||
2. **Recipient** - Which signer the field belongs to (r1, r2, etc.)
|
||||
3. **Options** - Additional settings like required, read-only, font size, etc.
|
||||
|
||||
The placeholder text is automatically hidden after fields are created, so your final document looks clean.
|
||||
|
||||
## Placeholder Format
|
||||
|
||||
The basic format is:
|
||||
|
||||
```
|
||||
{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
| Placeholder | Description |
|
||||
| ----------------------------- | ----------------------------------- |
|
||||
| `{{signature, r1}}` | Signature field for recipient 1 |
|
||||
| `{{name, r1}}` | Name field for recipient 1 |
|
||||
| `{{email, r2}}` | Email field for recipient 2 |
|
||||
| `{{date, r1}}` | Date field for recipient 1 |
|
||||
| `{{text, r1, required=true}}` | Required text field for recipient 1 |
|
||||
| `{{initials, r1}}` | Initials field for recipient 1 |
|
||||
|
||||
## Supported Field Types
|
||||
|
||||
The following field types are supported in placeholders:
|
||||
|
||||
| Field Type | Placeholder Value |
|
||||
| ---------- | ----------------- |
|
||||
| Signature | `signature` |
|
||||
| Initials | `initials` |
|
||||
| Name | `name` |
|
||||
| Email | `email` |
|
||||
| Date | `date` |
|
||||
| Text | `text` |
|
||||
| Number | `number` |
|
||||
| Radio | `radio` |
|
||||
| Checkbox | `checkbox` |
|
||||
| Dropdown | `dropdown` |
|
||||
|
||||
<Callout type="info">
|
||||
Field types are case-insensitive. `{{ SIGNATURE, r1 }}` and `{{ signature, r1 }}` are equivalent.
|
||||
</Callout>
|
||||
|
||||
## Recipient Identifiers
|
||||
|
||||
Recipients are identified using `r1`, `r2`, `r3`, etc. The number corresponds to the order in which recipients are created:
|
||||
|
||||
- `r1` - First recipient
|
||||
- `r2` - Second recipient
|
||||
- `r3` - Third recipient
|
||||
|
||||
When you upload a PDF with placeholders, Documenso will:
|
||||
|
||||
1. Create placeholder recipients for each unique identifier found (e.g., `r1`, `r2`)
|
||||
2. You can then update these with real email addresses before sending
|
||||
|
||||
<Callout type="warning">
|
||||
Placeholders without a recipient identifier (e.g., `{{ signature }}` without `r1`) are reserved
|
||||
for API use and will not create fields during upload.
|
||||
</Callout>
|
||||
|
||||
## Field Options
|
||||
|
||||
You can customize fields by adding options after the recipient identifier:
|
||||
|
||||
### Common Options
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------- | ------------------------- | ------------------------------------------ |
|
||||
| `required` | `true`, `false` | Whether the field must be filled |
|
||||
| `readOnly` | `true`, `false` | Whether the field is pre-filled and locked |
|
||||
| `fontSize` | Number (e.g., `12`) | Font size in points |
|
||||
| `textAlign` | `left`, `center`, `right` | Horizontal text alignment |
|
||||
|
||||
### Text Field Options
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | ------ | ------------------------------------- |
|
||||
| `label` | Text | Label shown in the field |
|
||||
| `placeholder` | Text | Placeholder text shown before signing |
|
||||
| `text` | Text | Pre-filled text value |
|
||||
| `characterLimit` | Number | Maximum characters allowed |
|
||||
|
||||
### Number Field Options
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------- | ------------- | --------------------- |
|
||||
| `value` | Number | Pre-filled value |
|
||||
| `minValue` | Number | Minimum allowed value |
|
||||
| `maxValue` | Number | Maximum allowed value |
|
||||
| `numberFormat` | Format string | Number display format |
|
||||
|
||||
### Examples with Options
|
||||
|
||||
```
|
||||
{{text, r1, required=true, label=Company Name}}
|
||||
{{number, r1, minValue=0, maxValue=100, value=50}}
|
||||
{{name, r1, fontSize=14}}
|
||||
{{text, r2, readOnly=true, text=Contract #12345}}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Signature and Free Signature fields do not support additional options beyond the field type and
|
||||
recipient.
|
||||
</Callout>
|
||||
|
||||
## Multiple Recipients Example
|
||||
|
||||
Here's how a document might look with placeholders for two signers:
|
||||
|
||||
```
|
||||
AGREEMENT
|
||||
|
||||
Party A Signature: {{signature, r1}}
|
||||
Party A Name: {{name, r1}}
|
||||
Party A Date: {{date, r1}}
|
||||
|
||||
Party B Signature: {{signature, r2}}
|
||||
Party B Name: {{name, r2}}
|
||||
Party B Date: {{date, r2}}
|
||||
```
|
||||
|
||||
When uploaded, this creates:
|
||||
|
||||
- 3 fields assigned to recipient 1 (Party A)
|
||||
- 3 fields assigned to recipient 2 (Party B)
|
||||
- 2 placeholder recipients that you can update with real email addresses
|
||||
|
||||
## Tips for Creating Documents
|
||||
|
||||
1. **Use a readable font** - Placeholders need to be readable by the PDF parser. Standard fonts like Arial, Helvetica, or Times New Roman work best.
|
||||
|
||||
2. **Don't split placeholders** - Ensure the entire placeholder text `{{...}}` is on a single line and not broken across text boxes.
|
||||
|
||||
3. **Size matters** - The field will be sized to match the placeholder text width. Use spaces or longer placeholder text if you need wider fields.
|
||||
|
||||
4. **Test with a draft** - Upload your document as a draft first to verify fields are detected correctly before sending.
|
||||
|
||||
<Callout type="info">
|
||||
Placeholder detection happens automatically when you upload a PDF. You can review and adjust the
|
||||
created fields in the document editor before sending.
|
||||
</Callout>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Placeholders Not Detected
|
||||
|
||||
- Ensure placeholders use double curly braces: `{{...}}`
|
||||
- Check that the placeholder includes a recipient identifier (e.g., `r1`)
|
||||
- Verify the field type is spelled correctly
|
||||
- Try using a standard font in your source document
|
||||
|
||||
### Wrong Field Position
|
||||
|
||||
- The field is placed at the exact location of the placeholder text
|
||||
- If the position seems off, check that your PDF wasn't scaled or reformatted when exported
|
||||
|
||||
### Placeholder Text Still Visible
|
||||
|
||||
- Placeholder text is covered with a white rectangle after field creation
|
||||
- If you see the text, try re-uploading the document
|
||||
453
packages/app-tests/e2e/api/v2/placeholder-fields-api.spec.ts
Normal file
453
packages/app-tests/e2e/api/v2/placeholder-fields-api.spec.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import { PDF, StandardFonts } from '@libpdf/core';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement');
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('Placeholder-based field creation', () => {
|
||||
let user: User, team: Team, token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
const createEnvelopeWithPdf = async (
|
||||
request: APIRequestContext,
|
||||
pdfFilename: string,
|
||||
): Promise<TCreateEnvelopeResponse> => {
|
||||
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
|
||||
const pdfData = fs.readFileSync(pdfPath);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Placeholder Fields Test',
|
||||
} satisfies TCreateEnvelopePayload),
|
||||
);
|
||||
|
||||
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const createEnvelopeItemsWithPdf = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
pdfFilename: string,
|
||||
) => {
|
||||
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
|
||||
const pdfData = fs.readFileSync(pdfPath);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify({ envelopeId }));
|
||||
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const addRecipient = async (request: APIRequestContext, envelopeId: string) => {
|
||||
const payload: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId,
|
||||
data: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
const addRecipients = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
recipients: TCreateEnvelopeRecipientsRequest['data'],
|
||||
) => {
|
||||
const payload: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId,
|
||||
data: recipients,
|
||||
};
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
const getEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
): Promise<TGetEnvelopeResponse> => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a PDF with the same placeholder appearing multiple times at different locations.
|
||||
*/
|
||||
const createPdfWithDuplicatePlaceholders = async (): Promise<Buffer> => {
|
||||
const pdf = PDF.create();
|
||||
const page = pdf.addPage({ size: 'letter' });
|
||||
|
||||
// Draw the same placeholder text at three different Y positions.
|
||||
page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 });
|
||||
page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 });
|
||||
page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 });
|
||||
|
||||
const bytes = await pdf.save();
|
||||
|
||||
return Buffer.from(bytes);
|
||||
};
|
||||
|
||||
const createEnvelopeWithPdfBuffer = async (
|
||||
request: APIRequestContext,
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<TCreateEnvelopeResponse> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Placeholder Fields Test',
|
||||
} satisfies TCreateEnvelopePayload),
|
||||
);
|
||||
|
||||
formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
test('should create a field at a placeholder location', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
placeholder: '{{signature}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
|
||||
// Verify the field has non-zero position/dimensions resolved from the placeholder.
|
||||
expect(fields[0].positionX.toNumber()).toBeGreaterThan(0);
|
||||
expect(fields[0].positionY.toNumber()).toBeGreaterThan(0);
|
||||
expect(fields[0].width.toNumber()).toBeGreaterThan(0);
|
||||
expect(fields[0].height.toNumber()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should override width and height when provided', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.NAME,
|
||||
placeholder: '{{name}}',
|
||||
width: 30,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].width.toNumber()).toBeCloseTo(30, 1);
|
||||
expect(fields[0].height.toNumber()).toBeCloseTo(5, 1);
|
||||
});
|
||||
|
||||
test('should fail when placeholder text is not found in the PDF', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.TEXT,
|
||||
placeholder: '{{nonexistent}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should create fields using a mix of coordinate and placeholder positioning', async ({
|
||||
request,
|
||||
}) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
placeholder: '{{signature}}',
|
||||
},
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.DATE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 20,
|
||||
width: 15,
|
||||
height: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
orderBy: { type: 'asc' },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
|
||||
const dateField = fields.find((f) => f.type === FieldType.DATE);
|
||||
const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
expect(dateField).toBeDefined();
|
||||
expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1);
|
||||
expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1);
|
||||
|
||||
expect(signatureField).toBeDefined();
|
||||
expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should create a field only at first occurrence by default', async ({ request }) => {
|
||||
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
|
||||
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.INITIALS,
|
||||
placeholder: '{{initials}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
// Should only create one field (first occurrence).
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].type).toBe(FieldType.INITIALS);
|
||||
});
|
||||
|
||||
test('should create fields at all occurrences when matchAll is true', async ({ request }) => {
|
||||
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
|
||||
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.INITIALS,
|
||||
placeholder: '{{initials}}',
|
||||
matchAll: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
orderBy: { positionY: 'asc' },
|
||||
});
|
||||
|
||||
// Should create three fields (one for each occurrence).
|
||||
expect(fields).toHaveLength(3);
|
||||
|
||||
// All should be INITIALS type.
|
||||
expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true);
|
||||
|
||||
// Verify they're at different Y positions.
|
||||
const yPositions = fields.map((f) => f.positionY.toNumber());
|
||||
const uniqueYPositions = new Set(yPositions);
|
||||
|
||||
expect(uniqueYPositions.size).toBe(3);
|
||||
});
|
||||
|
||||
test('should map placeholder recipients by signing order when adding items', async ({
|
||||
request,
|
||||
}) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
|
||||
await addRecipients(request, envelope.id, [
|
||||
{
|
||||
email: 'second.recipient@documenso.com',
|
||||
name: 'Second Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 2,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: 'first.recipient@documenso.com',
|
||||
name: 'First Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf');
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1);
|
||||
|
||||
expect(firstRecipient).toBeDefined();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
import { type Page, expect, test } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement');
|
||||
|
||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
FIXTURES_DIR,
|
||||
'project-proposal-single-recipient.pdf',
|
||||
);
|
||||
|
||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
FIXTURES_DIR,
|
||||
'project-proposal-multiple-fields-and-recipients.pdf',
|
||||
);
|
||||
|
||||
const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf');
|
||||
|
||||
const INVALID_FIELD_TYPE_PDF_PATH = path.join(FIXTURES_DIR, 'invalid-field-type.pdf');
|
||||
|
||||
const FIELD_TYPE_ONLY_PDF_PATH = path.join(FIXTURES_DIR, 'field-type-only.pdf');
|
||||
|
||||
const setTeamDefaultRecipients = async (
|
||||
teamId: number,
|
||||
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
|
||||
) => {
|
||||
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.update({
|
||||
where: {
|
||||
id: teamSettings.id,
|
||||
},
|
||||
data: {
|
||||
defaultRecipients,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupUserAndSignIn = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
return { user, team };
|
||||
};
|
||||
|
||||
const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) => {
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator('input[type=file]')
|
||||
.nth(1)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(pdfPath);
|
||||
|
||||
// Wait for redirect to v2 envelope editor.
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
// Extract envelope ID from URL.
|
||||
const urlParts = page.url().split('/');
|
||||
const envelopeId = urlParts.find((part) => part.startsWith('envelope_'));
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Could not extract envelope ID from URL');
|
||||
}
|
||||
|
||||
return envelopeId;
|
||||
};
|
||||
|
||||
test.describe('PDF Placeholders with single recipient', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await setTeamDefaultRecipients(team.id, [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || user.email,
|
||||
role: RecipientRole.CC,
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
await expect(async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const placeholderRecipient = recipients.find(
|
||||
(recipient) => recipient.email === 'recipient.1@documenso.com',
|
||||
);
|
||||
|
||||
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
expect(placeholderRecipient).toBeDefined();
|
||||
expect(defaultRecipient).toBeDefined();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
expect(fields.every((field) => field.recipientId === placeholderRecipient!.id)).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor shows recipients on the upload page under "Recipients" heading.
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor renders fields on a Konva canvas, so we verify via the database.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const fieldTypes = fields.map((f) => f.type).sort();
|
||||
expect(fieldTypes).toEqual(['EMAIL', 'NAME', 'SIGNATURE', 'TEXT'].sort());
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// Verify field metadata was correctly parsed from the placeholder.
|
||||
await expect(async () => {
|
||||
const textField = await prisma.field.findFirst({
|
||||
where: { envelopeId, type: 'TEXT' },
|
||||
});
|
||||
|
||||
expect(textField).toBeDefined();
|
||||
expect(textField!.fieldMeta).toBeDefined();
|
||||
|
||||
const meta = textField!.fieldMeta as Record<string, unknown>;
|
||||
expect(meta.required).toBe(true);
|
||||
expect(meta.textAlign).toBe('right');
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with multiple recipients', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor shows recipients on the upload page.
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
||||
'recipient.2@documenso.com',
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
||||
'recipient.3@documenso.com',
|
||||
);
|
||||
|
||||
// Verify recipients via the database for name validation since the v2 editor
|
||||
// only shows the "Name" label on the first recipient row.
|
||||
await expect(async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(3);
|
||||
expect(recipients[0].name).toBe('Recipient 1');
|
||||
expect(recipients[1].name).toBe('Recipient 2');
|
||||
expect(recipients[2].name).toBe('Recipient 3');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor renders fields on a Konva canvas, so we verify via the database.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const fieldTypes = fields.map((f) => f.type).sort();
|
||||
expect(fieldTypes).toEqual(
|
||||
['SIGNATURE', 'SIGNATURE', 'SIGNATURE', 'EMAIL', 'EMAIL', 'NAME', 'TEXT', 'NUMBER'].sort(),
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders without recipient identifier', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH);
|
||||
|
||||
// Placeholders like {{signature}}, {{name}}, {{email}} have no recipient
|
||||
// identifier and should be skipped entirely. No fields or auto-created
|
||||
// recipients should exist.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(0);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should skip a bare field type placeholder', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, FIELD_TYPE_ONLY_PDF_PATH);
|
||||
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(0);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with invalid field types', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH);
|
||||
|
||||
// Only the valid placeholders (signature,r1 and email,r2) should create fields.
|
||||
// The invalid ones (bogus,r1 and foobar,r2) should be skipped.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const fieldTypes = fields.map((f) => f.type).sort();
|
||||
expect(fieldTypes).toEqual(['EMAIL', 'SIGNATURE'].sort());
|
||||
}).toPass();
|
||||
|
||||
// Both valid recipients should still be created.
|
||||
await expect(async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(2);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
BIN
packages/assets/fixtures/auto-placement/field-type-only.pdf
Normal file
BIN
packages/assets/fixtures/auto-placement/field-type-only.pdf
Normal file
Binary file not shown.
BIN
packages/assets/fixtures/auto-placement/invalid-field-type.pdf
Normal file
BIN
packages/assets/fixtures/auto-placement/invalid-field-type.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -10,6 +10,9 @@ import {
|
|||
} from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
|
|
@ -68,7 +71,12 @@ export type CreateEnvelopeOptions = {
|
|||
type: EnvelopeType;
|
||||
title: string;
|
||||
externalId?: string;
|
||||
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
|
||||
envelopeItems: {
|
||||
title?: string;
|
||||
documentDataId: string;
|
||||
order?: number;
|
||||
placeholders?: PlaceholderInfo[];
|
||||
}[];
|
||||
formValues?: TDocumentFormValues;
|
||||
|
||||
userTimezone?: string;
|
||||
|
|
@ -164,8 +172,7 @@ export const createEnvelope = async ({
|
|||
});
|
||||
}
|
||||
|
||||
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
|
||||
data.envelopeItems;
|
||||
let envelopeItems = data.envelopeItems;
|
||||
|
||||
// Todo: Envelopes - Remove
|
||||
if (normalizePdf) {
|
||||
|
|
@ -298,7 +305,7 @@ export const createEnvelope = async ({
|
|||
const delegatedOwner = await getValidatedDelegatedOwner();
|
||||
const envelopeOwnerId = delegatedOwner?.id ?? userId;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const createdEnvelope = await prisma.$transaction(async (tx) => {
|
||||
const envelope = await tx.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
|
|
@ -423,6 +430,124 @@ export const createEnvelope = async ({
|
|||
}),
|
||||
);
|
||||
|
||||
// Create fields from PDF placeholders (extracted at upload time).
|
||||
const itemsWithPlaceholders = envelopeItems.filter(
|
||||
(item) => item.placeholders && item.placeholders.length > 0,
|
||||
);
|
||||
|
||||
if (itemsWithPlaceholders.length > 0) {
|
||||
// Collect all unique recipient placeholder references (e.g. "r1", "r2").
|
||||
const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []);
|
||||
const uniqueRecipientRefs = new Map<number, string>();
|
||||
|
||||
for (const p of allPlaceholders) {
|
||||
const match = p.recipient.match(/^r(\d+)$/i);
|
||||
|
||||
if (match) {
|
||||
const index = Number(match[1]);
|
||||
|
||||
if (!uniqueRecipientRefs.has(index)) {
|
||||
uniqueRecipientRefs.set(index, `Recipient ${index}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing recipients (may have been created above from data.recipients or defaults).
|
||||
let availableRecipients = await tx.recipient.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
const shouldCreatePlaceholderRecipients =
|
||||
(!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0;
|
||||
|
||||
// If recipients were not provided, create placeholder recipients even when defaults exist.
|
||||
if (shouldCreatePlaceholderRecipients) {
|
||||
const existingRecipientEmails = new Set(
|
||||
availableRecipients.map((recipient) => recipient.email.toLowerCase()),
|
||||
);
|
||||
|
||||
const placeholderRecipients = Array.from(
|
||||
uniqueRecipientRefs.entries(),
|
||||
([recipientIndex, name]) => ({
|
||||
envelopeId: envelope.id,
|
||||
email: `recipient.${recipientIndex}@documenso.com`,
|
||||
name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: recipientIndex,
|
||||
token: nanoid(),
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
}),
|
||||
).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));
|
||||
|
||||
if (placeholderRecipients.length > 0) {
|
||||
await tx.recipient.createMany({
|
||||
data: placeholderRecipients,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
availableRecipients = await tx.recipient.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of itemsWithPlaceholders) {
|
||||
const envelopeItem = envelope.envelopeItems.find(
|
||||
(ei) => ei.documentDataId === item.documentDataId,
|
||||
);
|
||||
|
||||
if (!envelopeItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
item.placeholders ?? [],
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
data.recipients && data.recipients.length > 0
|
||||
? data.recipients.map((r) => {
|
||||
const found = availableRecipients.find((cr) => cr.email === r.email);
|
||||
|
||||
if (!found) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Recipient not found for email: ${r.email}`,
|
||||
});
|
||||
}
|
||||
|
||||
return found;
|
||||
})
|
||||
: undefined,
|
||||
availableRecipients,
|
||||
),
|
||||
envelopeItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createdEnvelope = await tx.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
|
@ -432,8 +557,12 @@ export const createEnvelope = async ({
|
|||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
envelopeAttachments: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -491,4 +620,6 @@ export const createEnvelope = async ({
|
|||
|
||||
return createdEnvelope;
|
||||
});
|
||||
|
||||
return createdEnvelope;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { PDF } from '@libpdf/core';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
|
|
@ -11,30 +14,53 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
|
|||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
|
||||
|
||||
type CoordinatePosition = {
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PlaceholderPosition = {
|
||||
placeholder: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/**
|
||||
* When true, creates a field at every occurrence of the placeholder in the PDF.
|
||||
* When false or omitted, only the first occurrence is used.
|
||||
*/
|
||||
matchAll?: boolean;
|
||||
};
|
||||
|
||||
type FieldPosition = CoordinatePosition | PlaceholderPosition;
|
||||
|
||||
export type CreateEnvelopeFieldInput = TFieldAndMeta & {
|
||||
/**
|
||||
* The ID of the item to insert the fields into.
|
||||
*
|
||||
* If blank, the first item will be used.
|
||||
*/
|
||||
envelopeItemId?: string;
|
||||
|
||||
recipientId: number;
|
||||
} & FieldPosition;
|
||||
|
||||
export interface CreateEnvelopeFieldsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
fields: (TFieldAndMeta & {
|
||||
/**
|
||||
* The ID of the item to insert the fields into.
|
||||
*
|
||||
* If blank, the first item will be used.
|
||||
*/
|
||||
envelopeItemId?: string;
|
||||
|
||||
recipientId: number;
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
})[];
|
||||
fields: CreateEnvelopeFieldInput[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
const isPlaceholderPosition = (position: FieldPosition): position is PlaceholderPosition => {
|
||||
return 'placeholder' in position;
|
||||
};
|
||||
|
||||
export const createEnvelopeFields = async ({
|
||||
userId,
|
||||
teamId,
|
||||
|
|
@ -55,8 +81,8 @@ export const createEnvelopeFields = async ({
|
|||
recipients: true,
|
||||
fields: true,
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -82,8 +108,33 @@ export const createEnvelopeFields = async ({
|
|||
});
|
||||
}
|
||||
|
||||
// Field validation.
|
||||
const validatedFields = fields.map((field) => {
|
||||
const hasPlaceholderFields = fields.some((field) => isPlaceholderPosition(field));
|
||||
|
||||
/*
|
||||
Cache of loaded PDF documents keyed by envelope item ID.
|
||||
Only loaded when at least one field uses placeholder positioning.
|
||||
We keep the full PDF objects so we can both read text and draw white boxes
|
||||
over resolved placeholders before saving back.
|
||||
*/
|
||||
const pdfCache = new Map<string, PDF>();
|
||||
|
||||
if (hasPlaceholderFields) {
|
||||
for (const item of envelope.envelopeItems) {
|
||||
const bytes = await getFileServerSide(item.documentData);
|
||||
const pdfDoc = await PDF.load(new Uint8Array(bytes));
|
||||
|
||||
pdfCache.set(item.id, pdfDoc);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Collect placeholder bounding boxes that need to be whited out, grouped by
|
||||
envelope item ID. Populated during field resolution below.
|
||||
*/
|
||||
const placeholderWhiteouts = new Map<string, Array<{ pageIndex: number; bbox: BoundingBox }>>();
|
||||
|
||||
// Field validation and placeholder resolution.
|
||||
const validatedFields = fields.flatMap((field) => {
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// The item to attach the fields to MUST belong to the document.
|
||||
|
|
@ -111,10 +162,84 @@ export const createEnvelopeFields = async ({
|
|||
});
|
||||
}
|
||||
|
||||
const envelopeItemId = field.envelopeItemId || firstEnvelopeItem.id;
|
||||
|
||||
/*
|
||||
Resolve field position(s). Placeholder fields are resolved by searching the
|
||||
PDF text for the placeholder string and using its bounding box.
|
||||
When matchAll is true, all occurrences produce fields.
|
||||
*/
|
||||
if (isPlaceholderPosition(field)) {
|
||||
const pdfDoc = pdfCache.get(envelopeItemId);
|
||||
|
||||
if (!pdfDoc) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Could not load PDF for envelope item ${envelopeItemId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const matches = pdfDoc.findText(field.placeholder);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Placeholder "${field.placeholder}" not found in PDF`,
|
||||
});
|
||||
}
|
||||
|
||||
const matchesToProcess = field.matchAll ? matches : [matches[0]];
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
return matchesToProcess.map((match) => {
|
||||
const page = pages[match.pageIndex];
|
||||
|
||||
/*
|
||||
Record this placeholder's bounding box for whiteout. The bbox is in
|
||||
the original PDF coordinate system (points, bottom-left origin).
|
||||
*/
|
||||
if (!placeholderWhiteouts.has(envelopeItemId)) {
|
||||
placeholderWhiteouts.set(envelopeItemId, []);
|
||||
}
|
||||
|
||||
placeholderWhiteouts.get(envelopeItemId)!.push({
|
||||
pageIndex: match.pageIndex,
|
||||
bbox: match.bbox,
|
||||
});
|
||||
|
||||
/*
|
||||
Convert point-based coordinates (bottom-left origin) to percentage-based
|
||||
coordinates (top-left origin) matching the system's field coordinate format.
|
||||
*/
|
||||
const topLeftY = page.height - match.bbox.y - match.bbox.height;
|
||||
|
||||
const widthPercent = field.width ?? (match.bbox.width / page.width) * 100;
|
||||
const heightPercent = field.height ?? (match.bbox.height / page.height) * 100;
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
fieldMeta: field.fieldMeta,
|
||||
recipientId: field.recipientId,
|
||||
envelopeItemId,
|
||||
recipientEmail: recipient.email,
|
||||
page: match.pageIndex + 1,
|
||||
positionX: (match.bbox.x / page.width) * 100,
|
||||
positionY: (topLeftY / page.height) * 100,
|
||||
width: widthPercent,
|
||||
height: heightPercent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
envelopeItemId: field.envelopeItemId || firstEnvelopeItem.id, // Fallback to first envelope item if no envelope item ID is provided.
|
||||
type: field.type,
|
||||
fieldMeta: field.fieldMeta,
|
||||
recipientId: field.recipientId,
|
||||
envelopeItemId,
|
||||
recipientEmail: recipient.email,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -162,6 +287,39 @@ export const createEnvelopeFields = async ({
|
|||
return newlyCreatedFields;
|
||||
});
|
||||
|
||||
/*
|
||||
Draw white rectangles over each resolved placeholder in the PDF to hide the
|
||||
placeholder text, then persist the modified PDFs back to document storage.
|
||||
*/
|
||||
for (const [envelopeItemId, whiteouts] of placeholderWhiteouts) {
|
||||
const pdfDoc = pdfCache.get(envelopeItemId);
|
||||
|
||||
if (!pdfDoc) {
|
||||
continue;
|
||||
}
|
||||
|
||||
whiteoutRegions(pdfDoc, whiteouts);
|
||||
|
||||
const modifiedPdfBytes = await pdfDoc.save();
|
||||
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: 'document.pdf',
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),
|
||||
});
|
||||
|
||||
await prisma.envelopeItem.update({
|
||||
where: { id: envelopeItemId },
|
||||
data: { documentDataId: newDocumentData.id },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
fields: createdFields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
};
|
||||
|
|
|
|||
237
packages/lib/server-only/pdf/auto-place-fields.ts
Normal file
237
packages/lib/server-only/pdf/auto-place-fields.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { PDF, rgb } from '@libpdf/core';
|
||||
import type { FieldType, Recipient } from '@prisma/client';
|
||||
|
||||
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||
const MIN_HEIGHT_THRESHOLD = 0.01;
|
||||
|
||||
export type BoundingBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw white rectangles over specified regions in a loaded PDF document.
|
||||
*
|
||||
* Mutates the PDF in place. Coordinates use bottom-left origin (standard PDF coordinates).
|
||||
*/
|
||||
export const whiteoutRegions = (
|
||||
pdfDoc: PDF,
|
||||
regions: Array<{ pageIndex: number; bbox: BoundingBox }>,
|
||||
): void => {
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const { pageIndex, bbox } of regions) {
|
||||
const page = pages[pageIndex];
|
||||
|
||||
page.drawRectangle({
|
||||
x: bbox.x,
|
||||
y: bbox.y,
|
||||
width: bbox.width,
|
||||
height: bbox.height,
|
||||
color: rgb(1, 1, 1),
|
||||
borderColor: rgb(1, 1, 1),
|
||||
borderWidth: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export type PlaceholderInfo = {
|
||||
placeholder: string;
|
||||
recipient: string;
|
||||
fieldAndMeta: TFieldAndMeta;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export type FieldToCreate = TFieldAndMeta & {
|
||||
envelopeItemId?: string;
|
||||
recipientId: number;
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
|
||||
const placeholders: PlaceholderInfo[] = [];
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const pageWidth = page.width;
|
||||
const pageHeight = page.height;
|
||||
|
||||
const matches = page.findText(PLACEHOLDER_REGEX);
|
||||
|
||||
for (const match of matches) {
|
||||
const placeholder = match.text;
|
||||
|
||||
/*
|
||||
Extract the inner content from the placeholder match.
|
||||
E.g. '{{SIGNATURE, r1, required=true}}' -> 'SIGNATURE, r1, required=true'
|
||||
*/
|
||||
const innerMatch = placeholder.match(/^\{\{([^}]+)\}\}$/);
|
||||
|
||||
if (!innerMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
|
||||
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
||||
|
||||
let fieldType: FieldType;
|
||||
|
||||
try {
|
||||
fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
|
||||
} catch {
|
||||
// Skip placeholders with unrecognized field types.
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
A recipient identifier (e.g. "r1", "R2") is required for auto-placement.
|
||||
Placeholders without an explicit recipient like {{name}} are reserved for
|
||||
future API use where callers can reference a placeholder by name with
|
||||
optional dimensions instead of absolute coordinates.
|
||||
*/
|
||||
if (!recipientOrMeta || !/^r\d+$/i.test(recipientOrMeta)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = recipientOrMeta;
|
||||
|
||||
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
|
||||
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
|
||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
|
||||
/*
|
||||
LibPDF returns bbox in points with bottom-left origin.
|
||||
Convert Y to top-left origin for consistency with the rest of the system.
|
||||
*/
|
||||
const topLeftY = pageHeight - match.bbox.y - match.bbox.height;
|
||||
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
recipient,
|
||||
fieldAndMeta,
|
||||
page: page.index + 1,
|
||||
x: match.bbox.x,
|
||||
y: topLeftY,
|
||||
width: match.bbox.width,
|
||||
height: match.bbox.height,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw white rectangles over placeholder text in a PDF.
|
||||
*
|
||||
* Accepts optional pre-extracted placeholders to avoid re-parsing the PDF.
|
||||
*/
|
||||
export const removePlaceholdersFromPDF = async (
|
||||
pdf: Buffer,
|
||||
placeholders?: PlaceholderInfo[],
|
||||
): Promise<Buffer> => {
|
||||
const resolved = placeholders ?? (await extractPlaceholdersFromPDF(pdf));
|
||||
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
/*
|
||||
Convert PlaceholderInfo[] to whiteout regions.
|
||||
PlaceholderInfo uses top-left origin, but whiteoutRegions expects bottom-left.
|
||||
*/
|
||||
const regions = resolved.map((p) => {
|
||||
const page = pages[p.page - 1];
|
||||
const bottomLeftY = page.height - p.y - p.height;
|
||||
|
||||
return {
|
||||
pageIndex: p.page - 1,
|
||||
bbox: { x: p.x, y: bottomLeftY, width: p.width, height: p.height },
|
||||
};
|
||||
});
|
||||
|
||||
whiteoutRegions(pdfDoc, regions);
|
||||
|
||||
const modifiedPdfBytes = await pdfDoc.save();
|
||||
|
||||
return Buffer.from(modifiedPdfBytes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract placeholders from a PDF and remove them from the document.
|
||||
*
|
||||
* Returns the cleaned PDF buffer and the extracted placeholders. If no
|
||||
* placeholders are found the original buffer is returned as-is.
|
||||
*/
|
||||
export const extractPdfPlaceholders = async (
|
||||
pdf: Buffer,
|
||||
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||
|
||||
if (placeholders.length === 0) {
|
||||
return { cleanedPdf: pdf, placeholders: [] };
|
||||
}
|
||||
|
||||
const cleanedPdf = await removePlaceholdersFromPDF(pdf, placeholders);
|
||||
|
||||
return { cleanedPdf, placeholders };
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert pre-extracted PlaceholderInfo[] to field creation inputs.
|
||||
*
|
||||
* Pure data transform — converts point-based coordinates to percentages and
|
||||
* resolves recipient references via the provided callback. No DB calls.
|
||||
*/
|
||||
export const convertPlaceholdersToFieldInputs = (
|
||||
placeholders: PlaceholderInfo[],
|
||||
recipientResolver: (recipientPlaceholder: string, placeholder: string) => Pick<Recipient, 'id'>,
|
||||
envelopeItemId?: string,
|
||||
): FieldToCreate[] => {
|
||||
return placeholders.map((p) => {
|
||||
const xPercent = (p.x / p.pageWidth) * 100;
|
||||
const yPercent = (p.y / p.pageHeight) * 100;
|
||||
const widthPercent = (p.width / p.pageWidth) * 100;
|
||||
const heightPercent = (p.height / p.pageHeight) * 100;
|
||||
|
||||
const finalHeightPercent =
|
||||
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
||||
|
||||
const recipient = recipientResolver(p.recipient, p.placeholder);
|
||||
|
||||
return {
|
||||
...p.fieldAndMeta,
|
||||
envelopeItemId,
|
||||
recipientId: recipient.id,
|
||||
page: p.page,
|
||||
positionX: xPercent,
|
||||
positionY: yPercent,
|
||||
width: widthPercent,
|
||||
height: finalHeightPercent,
|
||||
};
|
||||
});
|
||||
};
|
||||
152
packages/lib/server-only/pdf/helpers.ts
Normal file
152
packages/lib/server-only/pdf/helpers.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { FieldType } from '@prisma/client';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
type RecipientPlaceholderInfo = {
|
||||
email: string;
|
||||
name: string;
|
||||
recipientIndex: number;
|
||||
};
|
||||
|
||||
/*
|
||||
Parse field type string to FieldType enum.
|
||||
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
||||
This ensures we handle case variations and whitespace, and provides clear error messages.
|
||||
*/
|
||||
export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldType => {
|
||||
const normalizedType = fieldTypeString.toUpperCase().trim();
|
||||
|
||||
return match(normalizedType)
|
||||
.with('SIGNATURE', () => FieldType.SIGNATURE)
|
||||
.with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE)
|
||||
.with('INITIALS', () => FieldType.INITIALS)
|
||||
.with('NAME', () => FieldType.NAME)
|
||||
.with('EMAIL', () => FieldType.EMAIL)
|
||||
.with('DATE', () => FieldType.DATE)
|
||||
.with('TEXT', () => FieldType.TEXT)
|
||||
.with('NUMBER', () => FieldType.NUMBER)
|
||||
.with('RADIO', () => FieldType.RADIO)
|
||||
.with('CHECKBOX', () => FieldType.CHECKBOX)
|
||||
.with('DROPDOWN', () => FieldType.DROPDOWN)
|
||||
.otherwise(() => {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid field type: ${fieldTypeString}`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Transform raw field metadata from placeholder format to schema format.
|
||||
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
||||
Converts string values to proper types (booleans, numbers).
|
||||
*/
|
||||
export const parseFieldMetaFromPlaceholder = (
|
||||
rawFieldMeta: Record<string, string>,
|
||||
fieldType: FieldType,
|
||||
): Record<string, unknown> | undefined => {
|
||||
if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(rawFieldMeta).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldTypeString = String(fieldType).toLowerCase();
|
||||
|
||||
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
||||
type: fieldTypeString,
|
||||
};
|
||||
|
||||
/*
|
||||
rawFieldMeta is an object with string keys and string values.
|
||||
It contains string values because the PDF parser returns the values as strings.
|
||||
|
||||
E.g. { 'required': 'true', 'fontSize': '12', 'maxValue': '100', 'minValue': '0', 'characterLimit': '100' }
|
||||
*/
|
||||
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
||||
|
||||
for (const [property, value] of rawFieldMetaEntries) {
|
||||
if (property === 'readOnly' || property === 'required') {
|
||||
parsedFieldMeta[property] = value === 'true';
|
||||
} else if (
|
||||
property === 'fontSize' ||
|
||||
property === 'maxValue' ||
|
||||
property === 'minValue' ||
|
||||
property === 'characterLimit'
|
||||
) {
|
||||
const numValue = Number(value);
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
parsedFieldMeta[property] = numValue;
|
||||
}
|
||||
} else {
|
||||
parsedFieldMeta[property] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedFieldMeta;
|
||||
};
|
||||
|
||||
const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
|
||||
const indexMatch = placeholder.match(/^r(\d+)$/i);
|
||||
|
||||
if (!indexMatch) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`,
|
||||
});
|
||||
}
|
||||
|
||||
const recipientIndex = Number(indexMatch[1]);
|
||||
|
||||
return {
|
||||
email: `recipient.${recipientIndex}@documenso.com`,
|
||||
name: `Recipient ${recipientIndex}`,
|
||||
recipientIndex,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Finds a recipient based on a placeholder reference.
|
||||
If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.).
|
||||
Otherwise, uses email-based matching from createdRecipients.
|
||||
*/
|
||||
export const findRecipientByPlaceholder = (
|
||||
recipientPlaceholder: string,
|
||||
placeholder: string,
|
||||
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
|
||||
createdRecipients: Pick<Recipient, 'id' | 'email'>[],
|
||||
): Pick<Recipient, 'id' | 'email'> => {
|
||||
if (recipients && recipients.length > 0) {
|
||||
/*
|
||||
Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc.
|
||||
recipientIndex is 1-based, so we subtract 1 to get the array index.
|
||||
*/
|
||||
const { recipientIndex } = extractRecipientPlaceholder(recipientPlaceholder);
|
||||
const recipientArrayIndex = recipientIndex - 1;
|
||||
|
||||
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
|
||||
});
|
||||
}
|
||||
|
||||
return recipients[recipientArrayIndex];
|
||||
}
|
||||
|
||||
/*
|
||||
Use email-based matching for placeholder recipients.
|
||||
*/
|
||||
const { email } = extractRecipientPlaceholder(recipientPlaceholder);
|
||||
const recipient = createdRecipients.find((r) => r.email === email);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Could not find recipient ID for placeholder: ${placeholder}`,
|
||||
});
|
||||
}
|
||||
|
||||
return recipient;
|
||||
};
|
||||
|
|
@ -28,5 +28,7 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
|
|||
pdfDoc.flattenAnnotations();
|
||||
}
|
||||
|
||||
return Buffer.from(await pdfDoc.save());
|
||||
const normalizedPdfBytes = await pdfDoc.save();
|
||||
|
||||
return Buffer.from(normalizedPdfBytes);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
|
@ -88,7 +87,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
|
|||
type: z.literal('text'),
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.coerce
|
||||
.number({ invalid_type_error: msg`Value must be a number`.id })
|
||||
.number({ invalid_type_error: 'Value must be a number' })
|
||||
.min(0)
|
||||
.optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import {
|
||||
convertPlaceholdersToFieldInputs,
|
||||
extractPdfPlaceholders,
|
||||
} from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
|
@ -84,14 +91,31 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
|||
});
|
||||
}
|
||||
|
||||
// For each file, stream to s3 and create the document data.
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
|
||||
let buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (envelope.formValues) {
|
||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
@ -131,6 +155,65 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
|||
),
|
||||
});
|
||||
|
||||
// Create fields from placeholders if the envelope already has recipients.
|
||||
if (envelope.recipients.length > 0) {
|
||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
for (const uploadedItem of envelopeItems) {
|
||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdItem = createdItems.find(
|
||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||
);
|
||||
|
||||
if (!createdItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
uploadedItem.placeholders,
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
orderedRecipients,
|
||||
orderedRecipients,
|
||||
),
|
||||
createdItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: createdItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdItems;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { EnvelopeType } from '@prisma/client';
|
|||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
|
||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
|
|
@ -69,7 +71,7 @@ export const createEnvelopeRoute = authenticatedProcedure
|
|||
});
|
||||
}
|
||||
|
||||
// For each file, stream to s3 and create the document data.
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let pdf = Buffer.from(await file.arrayBuffer());
|
||||
|
|
@ -82,20 +84,22 @@ export const createEnvelopeRoute = authenticatedProcedure
|
|||
});
|
||||
}
|
||||
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(
|
||||
{
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdf),
|
||||
},
|
||||
{
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
},
|
||||
);
|
||||
const normalized = await normalizePdf(pdf, {
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const createEnvelopeFieldsMeta: TrpcRouteMeta = {
|
|||
},
|
||||
};
|
||||
|
||||
const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
const ZCreateFieldBaseSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
recipientId: z.number().describe('The ID of the recipient to create the field for'),
|
||||
envelopeItemId: z
|
||||
|
|
@ -31,14 +31,51 @@ const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
|
|||
.describe(
|
||||
'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.',
|
||||
),
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
positionY: ZClampedFieldPositionYSchema,
|
||||
width: ZClampedFieldWidthSchema,
|
||||
height: ZClampedFieldHeightSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Position a field using explicit percentage-based coordinates.
|
||||
*/
|
||||
const ZCoordinatePositionSchema = z.object({
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
positionY: ZClampedFieldPositionYSchema,
|
||||
width: ZClampedFieldWidthSchema,
|
||||
height: ZClampedFieldHeightSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Position a field using a PDF text placeholder (e.g. "{{name}}").
|
||||
*
|
||||
* The placeholder text is matched in the envelope item's PDF and the field is
|
||||
* placed at the bounding box of that match. Width and height can optionally be
|
||||
* overridden; when omitted the dimensions of the placeholder text are used.
|
||||
*/
|
||||
const ZPlaceholderPositionSchema = z.object({
|
||||
placeholder: z
|
||||
.string()
|
||||
.describe(
|
||||
'Text to search for in the PDF (e.g. "{{name}}"). The field will be placed at the location of this text.',
|
||||
),
|
||||
width: ZClampedFieldWidthSchema.optional().describe(
|
||||
'Override the width of the field. When omitted, the width of the placeholder text is used.',
|
||||
),
|
||||
height: ZClampedFieldHeightSchema.optional().describe(
|
||||
'Override the height of the field. When omitted, the height of the placeholder text is used.',
|
||||
),
|
||||
matchAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'When true, creates a field at every occurrence of the placeholder in the PDF. When false or omitted, only the first occurrence is used.',
|
||||
),
|
||||
});
|
||||
|
||||
const ZCreateFieldSchema = ZCreateFieldBaseSchema.and(
|
||||
z.union([ZCoordinatePositionSchema, ZPlaceholderPositionSchema]),
|
||||
);
|
||||
|
||||
export const ZCreateEnvelopeFieldsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: ZCreateFieldSchema.array(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue