mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
Compare commits
14 Commits
main
...
498a2be1c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 498a2be1c7 | |||
| 3e84aa632f | |||
| a08a77e98b | |||
| 13d9ca7a0e | |||
| d25565b7d0 | |||
| 91421a7d62 | |||
| a9f1e39b10 | |||
| b37748654e | |||
| b3ed80d721 | |||
| b3cb750470 | |||
| 1e52493144 | |||
| ab95e80987 | |||
| 1780a5c262 | |||
| cb9bf407f7 |
13
package-lock.json
generated
13
package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"inngest-cli": "^0.29.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mupdf": "^1.0.0",
|
||||
"pdf2json": "^4.0.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "3.24.1"
|
||||
@ -27198,6 +27199,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf2json": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
|
||||
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"pdf2json": "bin/pdf2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "3.11.174",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||
|
||||
@ -74,6 +74,7 @@
|
||||
"inngest-cli": "^0.29.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mupdf": "^1.0.0",
|
||||
"pdf2json": "^4.0.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "3.24.1"
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../assets/project-proposal-single-recipient.pdf',
|
||||
);
|
||||
|
||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../assets/project-proposal-multiple-fields-and-recipients.pdf',
|
||||
);
|
||||
|
||||
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 uploadPdfAndContinue = async (page: Page, pdfPath: string, continueClicks: number = 1) => {
|
||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||
await fileInput.waitFor({ state: 'attached' });
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
for (let i = 0; i < continueClicks; i++) {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
};
|
||||
|
||||
test.describe('PDF Placeholders with single recipient', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupUserAndSignIn(page);
|
||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
|
||||
await expect(page.getByPlaceholder('Name')).toHaveValue('Recipient 1');
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupUserAndSignIn(page);
|
||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await expect(page.locator('[data-field-type="SIGNATURE"]')).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="EMAIL"]')).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupUserAndSignIn(page);
|
||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Advanced settings' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Advanced settings' })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Required field$/ })
|
||||
.getByRole('switch'),
|
||||
).toBeChecked();
|
||||
|
||||
await expect(page.getByRole('combobox')).toHaveText('Right');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with multiple recipients', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupUserAndSignIn(page);
|
||||
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 1);
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
||||
'recipient.2@documenso.com',
|
||||
);
|
||||
await expect(page.getByLabel('Name').nth(1)).toHaveValue('Recipient 2');
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
||||
'recipient.3@documenso.com',
|
||||
);
|
||||
await expect(page.getByLabel('Name').nth(2)).toHaveValue('Recipient 3');
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupUserAndSignIn(page);
|
||||
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 2);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await expect(page.locator('[data-field-type="SIGNATURE"]').first()).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(1)).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(2)).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="EMAIL"]').first()).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="EMAIL"]').nth(1)).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
||||
await expect(page.locator('[data-field-type="NUMBER"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { insertFieldsFromPlaceholdersInPDF } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@ -233,7 +234,7 @@ export const createEnvelope = async ({
|
||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const createdEnvelope = await prisma.$transaction(async (tx) => {
|
||||
const envelope = await tx.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
@ -353,8 +354,12 @@ export const createEnvelope = async ({
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
envelopeAttachments: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -390,4 +395,51 @@ export const createEnvelope = async ({
|
||||
|
||||
return createdEnvelope;
|
||||
});
|
||||
|
||||
for (const envelopeItem of createdEnvelope.envelopeItems) {
|
||||
const buffer = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
// Use normalized PDF if normalizePdf was true, otherwise use original
|
||||
const pdfToProcess = normalizePdf
|
||||
? await makeNormalizedPdf(Buffer.from(buffer))
|
||||
: Buffer.from(buffer);
|
||||
|
||||
await insertFieldsFromPlaceholdersInPDF(
|
||||
pdfToProcess,
|
||||
userId,
|
||||
teamId,
|
||||
{
|
||||
type: 'envelopeId',
|
||||
id: createdEnvelope.id,
|
||||
},
|
||||
requestMetadata,
|
||||
envelopeItem.id,
|
||||
);
|
||||
}
|
||||
|
||||
const finalEnvelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: createdEnvelope.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeAttachments: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!finalEnvelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return finalEnvelope;
|
||||
};
|
||||
|
||||
387
packages/lib/server-only/pdf/auto-place-fields.ts
Normal file
387
packages/lib/server-only/pdf/auto-place-fields.ts
Normal file
@ -0,0 +1,387 @@
|
||||
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import PDFParser from 'pdf2json';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
|
||||
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getPageSize } from './get-page-size';
|
||||
import {
|
||||
createRecipientsFromPlaceholders,
|
||||
extractRecipientPlaceholder,
|
||||
parseFieldMetaFromPlaceholder,
|
||||
parseFieldTypeFromPlaceholder,
|
||||
} from './helpers';
|
||||
|
||||
type TextPosition = {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
};
|
||||
|
||||
type CharIndexMapping = {
|
||||
textPositionIndex: number;
|
||||
};
|
||||
|
||||
type PlaceholderInfo = {
|
||||
placeholder: string;
|
||||
recipient: string;
|
||||
fieldAndMeta: TFieldAndMeta;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
type FieldToCreate = TFieldAndMeta & {
|
||||
envelopeItemId?: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/*
|
||||
Questions for later:
|
||||
- Does it handle multi-page PDFs? ✅ YES! ✅
|
||||
- Does it handle multiple recipients on the same page? ✅ YES! ✅
|
||||
- Does it handle multiple recipients on multiple pages? ✅ YES! ✅
|
||||
- What happens with incorrect placeholders? E.g. those containing non-accepted properties.
|
||||
- The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing. ✅
|
||||
- Need to handle envelopes with multiple items. ✅
|
||||
*/
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parser = new PDFParser(null, true);
|
||||
|
||||
parser.on('pdfParser_dataError', (errData) => {
|
||||
reject(errData);
|
||||
});
|
||||
|
||||
parser.on('pdfParser_dataReady', (pdfData) => {
|
||||
const placeholders: PlaceholderInfo[] = [];
|
||||
|
||||
pdfData.Pages.forEach((page, pageIndex) => {
|
||||
/*
|
||||
pdf2json returns the PDF page content as an array of characters.
|
||||
We need to concatenate the characters to get the full text.
|
||||
We also need to get the position of the text so we can place the placeholders in the correct position.
|
||||
|
||||
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
|
||||
*/
|
||||
let pageText = '';
|
||||
const textPositions: TextPosition[] = [];
|
||||
const charIndexMappings: CharIndexMapping[] = [];
|
||||
|
||||
page.Texts.forEach((text) => {
|
||||
/*
|
||||
R is an array of objects containing each character, its position and styling information.
|
||||
The decodedText stores the characters, without any other information.
|
||||
|
||||
textPositions stores each character and its position on the page.
|
||||
*/
|
||||
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
|
||||
|
||||
/*
|
||||
For each character in the decodedText, we store its position in the textPositions array.
|
||||
This allows us to quickly find the position of a character in the textPositions array by its index.
|
||||
*/
|
||||
for (let i = 0; i < decodedText.length; i++) {
|
||||
charIndexMappings.push({
|
||||
textPositionIndex: textPositions.length,
|
||||
});
|
||||
}
|
||||
|
||||
pageText += decodedText;
|
||||
|
||||
textPositions.push({
|
||||
text: decodedText,
|
||||
x: text.x,
|
||||
y: text.y,
|
||||
w: text.w || 0,
|
||||
});
|
||||
});
|
||||
|
||||
const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g);
|
||||
|
||||
/*
|
||||
A placeholder match has the following format:
|
||||
|
||||
[
|
||||
'{{fieldType,recipient,fieldMeta}}',
|
||||
'fieldType,recipient,fieldMeta',
|
||||
'index: <number>',
|
||||
'input: <pdf-text>'
|
||||
]
|
||||
*/
|
||||
for (const placeholderMatch of placeholderMatches) {
|
||||
const placeholder = placeholderMatch[0];
|
||||
const placeholderData = placeholderMatch[1].split(',').map((property) => property.trim());
|
||||
|
||||
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
|
||||
|
||||
const rawFieldMeta = Object.fromEntries(
|
||||
fieldMetaData.map((property) => property.split('=')),
|
||||
);
|
||||
|
||||
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
|
||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
|
||||
/*
|
||||
Find the position of where the placeholder starts and ends in the text.
|
||||
|
||||
Then find the position of the characters in the textPositions array.
|
||||
This allows us to quickly find the position of a character in the textPositions array by its index.
|
||||
*/
|
||||
if (placeholderMatch.index === undefined) {
|
||||
console.error('Placeholder match index is undefined for placeholder', placeholder);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const placeholderEndCharIndex = placeholderMatch.index + placeholder.length;
|
||||
|
||||
/*
|
||||
Get the index of the placeholder's first and last character in the textPositions array.
|
||||
Used to retrieve the character information from the textPositions array.
|
||||
|
||||
Example:
|
||||
startTextPosIndex - 1
|
||||
endTextPosIndex - 40
|
||||
*/
|
||||
const startTextPosIndex = charIndexMappings[placeholderMatch.index].textPositionIndex;
|
||||
const endTextPosIndex = charIndexMappings[placeholderEndCharIndex - 1].textPositionIndex;
|
||||
|
||||
/*
|
||||
Get the placeholder's first and last character information from the textPositions array.
|
||||
|
||||
Example:
|
||||
placeholderStart = { text: '{', x: 100, y: 100, w: 100 }
|
||||
placeholderEnd = { text: '}', x: 200, y: 100, w: 100 }
|
||||
*/
|
||||
const placeholderStart = textPositions[startTextPosIndex];
|
||||
const placeholderEnd = textPositions[endTextPosIndex];
|
||||
|
||||
const width = placeholderEnd.x + placeholderEnd.w * 0.1 - placeholderStart.x;
|
||||
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
recipient,
|
||||
fieldAndMeta,
|
||||
page: pageIndex + 1,
|
||||
x: placeholderStart.x,
|
||||
y: placeholderStart.y,
|
||||
width,
|
||||
height: 1,
|
||||
pageWidth: page.Width,
|
||||
pageHeight: page.Height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resolve(placeholders);
|
||||
});
|
||||
|
||||
parser.parseBuffer(pdf);
|
||||
});
|
||||
};
|
||||
|
||||
export const removePlaceholdersFromPDF = async (pdf: Buffer): Promise<Buffer> => {
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||
|
||||
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
const pageIndex = placeholder.page - 1;
|
||||
const page = pages[pageIndex];
|
||||
|
||||
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
|
||||
|
||||
/*
|
||||
Convert PDF2JSON coordinates to pdf-lib coordinates:
|
||||
|
||||
PDF2JSON uses relative "page units":
|
||||
- x, y, width, height are in page units
|
||||
- Page dimensions (Width, Height) are also in page units
|
||||
|
||||
pdf-lib uses absolute points (1 point = 1/72 inch):
|
||||
- Need to convert from page units to points
|
||||
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
|
||||
- Y-axis in PDF2JSON is top-down (origin at top-left)
|
||||
*/
|
||||
|
||||
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
|
||||
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
|
||||
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
|
||||
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
|
||||
|
||||
page.drawRectangle({
|
||||
x: xPoints,
|
||||
y: yPoints - heightPoints, // Adjust for height since y is at baseline
|
||||
width: widthPoints,
|
||||
height: heightPoints,
|
||||
color: rgb(1, 1, 1),
|
||||
borderColor: rgb(1, 1, 1),
|
||||
borderWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const modifiedPdfBytes = await pdfDoc.save();
|
||||
|
||||
return Buffer.from(modifiedPdfBytes);
|
||||
};
|
||||
|
||||
export const insertFieldsFromPlaceholdersInPDF = async (
|
||||
pdf: Buffer,
|
||||
userId: number,
|
||||
teamId: number,
|
||||
envelopeId: EnvelopeIdOptions,
|
||||
requestMetadata: ApiRequestMetadata,
|
||||
envelopeItemId?: string,
|
||||
recipients?: Pick<Recipient, 'id' | 'email'>[],
|
||||
): Promise<Buffer> => {
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||
|
||||
if (placeholders.length === 0) {
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/*
|
||||
A structure that maps the recipient index to the recipient name.
|
||||
Example: 1 => 'Recipient 1'
|
||||
*/
|
||||
const recipientPlaceholders = new Map<number, string>();
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
|
||||
|
||||
recipientPlaceholders.set(recipientIndex, name);
|
||||
}
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
secondaryId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
let createdRecipients: Pick<Recipient, 'id' | 'email'>[];
|
||||
|
||||
if (recipients && recipients.length > 0) {
|
||||
createdRecipients = recipients;
|
||||
} else {
|
||||
createdRecipients = await createRecipientsFromPlaceholders(
|
||||
recipientPlaceholders,
|
||||
envelope,
|
||||
envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
const fieldsToCreate: FieldToCreate[] = [];
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
/*
|
||||
Convert PDF2JSON coordinates to percentage-based coordinates (0-100)
|
||||
The UI expects positionX and positionY as percentages, not absolute points
|
||||
PDF2JSON uses relative coordinates: x/pageWidth and y/pageHeight give us the percentage
|
||||
*/
|
||||
const xPercent = (placeholder.x / placeholder.pageWidth) * 100;
|
||||
const yPercent = (placeholder.y / placeholder.pageHeight) * 100;
|
||||
|
||||
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
|
||||
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
|
||||
|
||||
let recipient: Pick<Recipient, 'id' | 'email'> | undefined;
|
||||
|
||||
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(placeholder.recipient);
|
||||
const recipientArrayIndex = recipientIndex - 1;
|
||||
|
||||
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Recipient placeholder ${placeholder.recipient} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
|
||||
});
|
||||
}
|
||||
|
||||
recipient = recipients[recipientArrayIndex];
|
||||
} else {
|
||||
/*
|
||||
Use email-based matching for placeholder recipients.
|
||||
*/
|
||||
const { email } = extractRecipientPlaceholder(placeholder.recipient);
|
||||
recipient = createdRecipients.find((r) => r.email === email);
|
||||
}
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Could not find recipient ID for placeholder: ${placeholder.placeholder}`,
|
||||
});
|
||||
}
|
||||
|
||||
const recipientId = recipient.id;
|
||||
|
||||
// Default height percentage if too small (use 2% as a reasonable default)
|
||||
const finalHeightPercent = heightPercent > 0.01 ? heightPercent : 2;
|
||||
|
||||
fieldsToCreate.push({
|
||||
...placeholder.fieldAndMeta,
|
||||
envelopeItemId,
|
||||
recipientId,
|
||||
pageNumber: placeholder.page,
|
||||
pageX: xPercent,
|
||||
pageY: yPercent,
|
||||
width: widthPercent,
|
||||
height: finalHeightPercent,
|
||||
});
|
||||
}
|
||||
|
||||
await createEnvelopeFields({
|
||||
userId,
|
||||
teamId,
|
||||
id: envelopeId,
|
||||
fields: fieldsToCreate,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return pdf;
|
||||
};
|
||||
191
packages/lib/server-only/pdf/helpers.ts
Normal file
191
packages/lib/server-only/pdf/helpers.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { type Envelope, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentRecipients } from '@documenso/lib/server-only/recipient/create-document-recipients';
|
||||
import { createTemplateRecipients } from '@documenso/lib/server-only/recipient/create-template-recipients';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export 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,
|
||||
};
|
||||
};
|
||||
|
||||
export const createRecipientsFromPlaceholders = async (
|
||||
recipientPlaceholders: Map<number, string>,
|
||||
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
|
||||
envelopeId: EnvelopeIdOptions,
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requestMetadata: ApiRequestMetadata,
|
||||
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
|
||||
const recipientsToCreate = Array.from(
|
||||
recipientPlaceholders.entries(),
|
||||
([recipientIndex, name]) => {
|
||||
return {
|
||||
email: `recipient.${recipientIndex}@documenso.com`,
|
||||
name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: recipientIndex,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const existingRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmails = new Set(existingRecipients.map((r) => r.email));
|
||||
const recipientsToCreateFiltered = recipientsToCreate.filter(
|
||||
(recipient) => !existingEmails.has(recipient.email),
|
||||
);
|
||||
|
||||
if (recipientsToCreateFiltered.length === 0) {
|
||||
return existingRecipients;
|
||||
}
|
||||
|
||||
const newRecipients = await match(envelope.type)
|
||||
.with(EnvelopeType.DOCUMENT, async () => {
|
||||
const { recipients } = await createDocumentRecipients({
|
||||
userId,
|
||||
teamId,
|
||||
id: envelopeId,
|
||||
recipients: recipientsToCreateFiltered,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return recipients;
|
||||
})
|
||||
.with(EnvelopeType.TEMPLATE, async () => {
|
||||
const templateId =
|
||||
envelopeId.type === 'templateId'
|
||||
? envelopeId.id
|
||||
: mapSecondaryIdToTemplateId(envelope.secondaryId ?? '');
|
||||
|
||||
const { recipients } = await createTemplateRecipients({
|
||||
userId,
|
||||
teamId,
|
||||
templateId,
|
||||
recipients: recipientsToCreateFiltered,
|
||||
});
|
||||
|
||||
return recipients;
|
||||
})
|
||||
.otherwise(() => {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid envelope type: ${envelope.type}`,
|
||||
});
|
||||
});
|
||||
|
||||
return [...existingRecipients, ...newRecipients];
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
|
||||
import { removePlaceholdersFromPDF } from './auto-place-fields';
|
||||
import { flattenAnnotations } from './flatten-annotations';
|
||||
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
|
||||
|
||||
@ -13,6 +14,7 @@ export const normalizePdf = async (pdf: Buffer) => {
|
||||
removeOptionalContentGroups(pdfDoc);
|
||||
await flattenForm(pdfDoc);
|
||||
flattenAnnotations(pdfDoc);
|
||||
const pdfWithoutPlaceholders = await removePlaceholdersFromPDF(pdf);
|
||||
|
||||
return Buffer.from(await pdfDoc.save());
|
||||
return pdfWithoutPlaceholders;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user