mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
283 lines
8.2 KiB
TypeScript
283 lines
8.2 KiB
TypeScript
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
|
import { logger } from '@documenso/lib/utils/logger';
|
|
import { PDF, rgb } from '@libpdf/core';
|
|
import type { FieldType, Recipient } from '@prisma/client';
|
|
|
|
import {
|
|
parseFieldMetaFromPlaceholder,
|
|
parseFieldTypeFromPlaceholder,
|
|
parsePlaceholderData,
|
|
parseRawFieldMetaFromPlaceholder,
|
|
} 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;
|
|
};
|
|
|
|
type ExtractPlaceholdersLogContext = {
|
|
envelopeId?: string;
|
|
fileName?: string;
|
|
};
|
|
|
|
export const extractPlaceholdersFromPDF = async (
|
|
pdf: Buffer,
|
|
logContext?: ExtractPlaceholdersLogContext,
|
|
): 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 = parsePlaceholderData(innerMatch[1]);
|
|
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;
|
|
|
|
/*
|
|
Parse and validate the field metadata. A malformed selection placeholder
|
|
(e.g. an unknown validation rule or a default value that doesn't match an
|
|
option) is skipped like an invalid field type rather than aborting the whole
|
|
upload, which may contain other valid placeholders and files.
|
|
*/
|
|
let fieldAndMeta: TFieldAndMeta;
|
|
|
|
try {
|
|
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
|
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
|
|
|
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
|
|
type: fieldType,
|
|
fieldMeta: parsedFieldMeta,
|
|
});
|
|
|
|
/*
|
|
Surface schema failures as INVALID_BODY (400) instead of letting the raw
|
|
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
|
|
*/
|
|
if (!parsedFieldAndMeta.success) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
|
|
});
|
|
}
|
|
|
|
fieldAndMeta = parsedFieldAndMeta.data;
|
|
} catch (error) {
|
|
const appError = AppError.parseError(error);
|
|
|
|
logger.warn(
|
|
{
|
|
envelopeId: logContext?.envelopeId,
|
|
fileName: logContext?.fileName,
|
|
placeholder,
|
|
page: page.index + 1,
|
|
code: appError.code,
|
|
message: appError.message,
|
|
},
|
|
'Skipping placeholder with invalid field metadata',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
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,
|
|
logContext?: ExtractPlaceholdersLogContext,
|
|
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
|
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
|
|
|
|
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,
|
|
};
|
|
});
|
|
};
|