mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
This PR is handles the changes required to support envelopes. The new envelope editor/signing page will be hidden during release. The core changes here is to migrate the documents and templates model to a centralized envelopes model. Even though Documents and Templates are removed, from the user perspective they will still exist as we remap envelopes to documents and templates.
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
|
import type { PDFDocument } from '@cantoo/pdf-lib';
|
|
import { RotationTypes, degrees, radiansToDegrees, rgb } from '@cantoo/pdf-lib';
|
|
import fontkit from '@pdf-lib/fontkit';
|
|
import { FieldType } from '@prisma/client';
|
|
import { P, match } from 'ts-pattern';
|
|
|
|
import {
|
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
|
DEFAULT_STANDARD_FONT_SIZE,
|
|
MIN_HANDWRITING_FONT_SIZE,
|
|
MIN_STANDARD_FONT_SIZE,
|
|
} from '@documenso/lib/constants/pdf';
|
|
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
|
|
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|
import {
|
|
ZCheckboxFieldMeta,
|
|
ZDateFieldMeta,
|
|
ZEmailFieldMeta,
|
|
ZInitialsFieldMeta,
|
|
ZNameFieldMeta,
|
|
ZNumberFieldMeta,
|
|
ZRadioFieldMeta,
|
|
ZTextFieldMeta,
|
|
} from '../../types/field-meta';
|
|
import { getPageSize } from './get-page-size';
|
|
|
|
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
|
const [fontCaveat, fontNoto] = await Promise.all([
|
|
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
|
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
|
]);
|
|
|
|
const isSignatureField = isSignatureFieldType(field.type);
|
|
const isDebugMode =
|
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
|
|
|
|
pdf.registerFontkit(fontkit);
|
|
|
|
const pages = pdf.getPages();
|
|
|
|
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
|
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
|
|
|
const page = pages.at(field.page - 1);
|
|
|
|
if (!page) {
|
|
throw new Error(`Page ${field.page} does not exist`);
|
|
}
|
|
|
|
const pageRotation = page.getRotation();
|
|
|
|
let pageRotationInDegrees = match(pageRotation.type)
|
|
.with(RotationTypes.Degrees, () => pageRotation.angle)
|
|
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
|
|
.exhaustive();
|
|
|
|
// Round to the closest multiple of 90 degrees.
|
|
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
|
|
|
|
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
|
|
|
let { width: pageWidth, height: pageHeight } = getPageSize(page);
|
|
|
|
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
|
// However when we load the PDF in the backend, the rotation is applied.
|
|
//
|
|
// To account for this, we swap the width and height for pages that are rotated by 90/270
|
|
// degrees. This is so we can calculate the virtual position the field was placed if it
|
|
// was correctly oriented in the frontend.
|
|
//
|
|
// Then when we insert the fields, we apply a transformation to the position of the field
|
|
// so it is rotated correctly.
|
|
if (isPageRotatedToLandscape) {
|
|
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
|
}
|
|
|
|
const fieldWidth = pageWidth * (Number(field.width) / 100);
|
|
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
|
|
|
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
|
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
|
|
|
// Draw debug box if debug mode is enabled
|
|
if (isDebugMode) {
|
|
let debugX = fieldX;
|
|
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
debugX,
|
|
debugY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
debugX = adjustedPosition.xPos;
|
|
debugY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawRectangle({
|
|
x: debugX,
|
|
y: debugY,
|
|
width: fieldWidth,
|
|
height: fieldHeight,
|
|
borderColor: rgb(1, 0, 0), // Red
|
|
borderWidth: 1,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
}
|
|
|
|
const font = await pdf.embedFont(
|
|
isSignatureField ? fontCaveat : fontNoto,
|
|
isSignatureField ? { features: { calt: false } } : undefined,
|
|
);
|
|
|
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
|
await pdf.embedFont(fontCaveat);
|
|
}
|
|
|
|
await match(field)
|
|
.with(
|
|
{
|
|
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
|
|
},
|
|
async (field) => {
|
|
if (field.signature?.signatureImageAsBase64) {
|
|
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
|
|
|
|
let imageWidth = image.width;
|
|
let imageHeight = image.height;
|
|
|
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
|
|
|
imageWidth = imageWidth * scalingFactor;
|
|
imageHeight = imageHeight * scalingFactor;
|
|
|
|
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
|
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
|
|
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
|
imageY = pageHeight - imageY - imageHeight;
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
imageX,
|
|
imageY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
imageX = adjustedPosition.xPos;
|
|
imageY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawImage(image, {
|
|
x: imageX,
|
|
y: imageY,
|
|
width: imageWidth,
|
|
height: imageHeight,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
} else {
|
|
const signatureText = field.signature?.typedSignature ?? '';
|
|
|
|
const longestLineInTextForWidth = signatureText
|
|
.split('\n')
|
|
.sort((a, b) => b.length - a.length)[0];
|
|
|
|
let fontSize = maxFontSize;
|
|
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
|
let textHeight = font.heightAtSize(fontSize);
|
|
|
|
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
|
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
|
|
|
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
|
textHeight = font.heightAtSize(fontSize);
|
|
|
|
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
|
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
|
|
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
|
textY = pageHeight - textY - textHeight;
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
textX,
|
|
textY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
textX = adjustedPosition.xPos;
|
|
textY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawText(signatureText, {
|
|
x: textX,
|
|
y: textY,
|
|
size: fontSize,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
}
|
|
},
|
|
)
|
|
.with({ type: FieldType.CHECKBOX }, (field) => {
|
|
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
|
|
|
|
if (!meta.success) {
|
|
console.error(meta.error);
|
|
|
|
throw new Error('Invalid checkbox field meta');
|
|
}
|
|
|
|
const values = meta.data.values?.map((item) => ({
|
|
...item,
|
|
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
|
}));
|
|
|
|
const selected: string[] = fromCheckboxValue(field.customText);
|
|
|
|
for (const [index, item] of (values ?? []).entries()) {
|
|
const offsetY = index * 16;
|
|
|
|
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
|
|
|
if (selected.includes(item.value)) {
|
|
checkbox.check();
|
|
}
|
|
|
|
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
|
x: fieldX + 16,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
size: 12,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
|
|
checkbox.addToPage(page, {
|
|
x: fieldX,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
height: 8,
|
|
width: 8,
|
|
});
|
|
}
|
|
})
|
|
.with({ type: FieldType.RADIO }, (field) => {
|
|
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
|
|
|
|
if (!meta.success) {
|
|
console.error(meta.error);
|
|
|
|
throw new Error('Invalid radio field meta');
|
|
}
|
|
|
|
const values = meta?.data.values?.map((item) => ({
|
|
...item,
|
|
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
|
}));
|
|
|
|
const selected = field.customText.split(',');
|
|
|
|
for (const [index, item] of (values ?? []).entries()) {
|
|
const offsetY = index * 16;
|
|
|
|
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
|
|
|
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
|
x: fieldX + 16,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
size: 12,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
|
|
radio.addOptionToPage(item.value, page, {
|
|
x: fieldX,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
height: 8,
|
|
width: 8,
|
|
});
|
|
|
|
if (selected.includes(item.value)) {
|
|
radio.select(item.value);
|
|
}
|
|
}
|
|
})
|
|
.otherwise((field) => {
|
|
const fieldMetaParsers = {
|
|
[FieldType.TEXT]: ZTextFieldMeta,
|
|
[FieldType.NUMBER]: ZNumberFieldMeta,
|
|
[FieldType.DATE]: ZDateFieldMeta,
|
|
[FieldType.EMAIL]: ZEmailFieldMeta,
|
|
[FieldType.NAME]: ZNameFieldMeta,
|
|
[FieldType.INITIALS]: ZInitialsFieldMeta,
|
|
} as const;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
|
|
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
|
|
|
|
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
|
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
|
|
const longestLineInTextForWidth = field.customText
|
|
.split('\n')
|
|
.sort((a, b) => b.length - a.length)[0];
|
|
|
|
let fontSize = customFontSize || maxFontSize;
|
|
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
|
const textHeight = font.heightAtSize(fontSize);
|
|
|
|
if (!customFontSize) {
|
|
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
|
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
|
}
|
|
|
|
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
|
|
|
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
|
const padding = 8; // PDF points, roughly equivalent to 0.5rem
|
|
|
|
// Calculate X position based on text alignment with padding
|
|
let textX = fieldX + padding; // Left alignment starts after padding
|
|
if (textAlign === 'center') {
|
|
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
|
|
} else if (textAlign === 'right') {
|
|
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
|
|
}
|
|
|
|
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
|
|
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
|
textY = pageHeight - textY - textHeight;
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
textX,
|
|
textY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
textX = adjustedPosition.xPos;
|
|
textY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawText(field.customText, {
|
|
x: textX,
|
|
y: textY,
|
|
size: fontSize,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
});
|
|
|
|
return pdf;
|
|
};
|
|
|
|
const adjustPositionForRotation = (
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
xPos: number,
|
|
yPos: number,
|
|
pageRotationInDegrees: number,
|
|
) => {
|
|
if (pageRotationInDegrees === 270) {
|
|
xPos = pageWidth - xPos;
|
|
[xPos, yPos] = [yPos, xPos];
|
|
}
|
|
|
|
if (pageRotationInDegrees === 90) {
|
|
yPos = pageHeight - yPos;
|
|
[xPos, yPos] = [yPos, xPos];
|
|
}
|
|
|
|
// Invert all the positions since it's rotated by 180 degrees.
|
|
if (pageRotationInDegrees === 180) {
|
|
xPos = pageWidth - xPos;
|
|
yPos = pageHeight - yPos;
|
|
}
|
|
|
|
return {
|
|
xPos,
|
|
yPos,
|
|
};
|
|
};
|