refactor: replace pdf-sign with libpdf/core for PDF operations (#2403)

Migrate from @documenso/pdf-sign and @cantoo/pdf-lib to @libpdf/core
for all PDF manipulation and signing operations. This includes:

- New signing transports for Google Cloud KMS and local certificates
- Consolidated PDF operations using libpdf API
- Added TSA (timestamp authority) helper for digital signatures
- Removed deprecated flatten and insert utilities
- Updated tests to use new PDF library
This commit is contained in:
Lucas Smith
2026-01-21 15:16:23 +11:00
committed by GitHub
parent ed7a0011c7
commit 9035240b4d
37 changed files with 1065 additions and 1468 deletions
+9
View File
@@ -6,6 +6,12 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () =>
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
export const NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER = () =>
env('NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER') === 'true';
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -30,3 +36,6 @@ export const IS_AI_FEATURES_CONFIGURED = () =>
*/
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () =>
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () =>
env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
@@ -1,12 +1,5 @@
import {
PDFDocument,
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
@@ -18,8 +11,8 @@ import {
import { nanoid } from 'nanoid';
import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { prisma } from '@documenso/prisma';
@@ -31,14 +24,9 @@ import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@@ -181,8 +169,8 @@ export const run = async ({
});
}
let certificateDoc: PDFDocument | null = null;
let auditLogDoc: PDFDocument | null = null;
let certificateDoc: PDF | null = null;
let auditLogDoc: PDF | null = null;
if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = {
@@ -208,7 +196,7 @@ export const run = async ({
? getCertificatePdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
}).then(async (buffer) => PDF.load(buffer))
: generateCertificatePdf(certificatePayload);
const makeAuditLogPdf = async () =>
@@ -216,7 +204,7 @@ export const run = async ({
? getAuditLogsPdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
}).then(async (buffer) => PDF.load(buffer))
: generateAuditLogPdf(certificatePayload);
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
@@ -342,8 +330,8 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
certificateDoc: PDFDocument | null;
auditLogDoc: PDFDocument | null;
certificateDoc: PDF | null;
auditLogDoc: PDF | null;
};
/**
@@ -360,48 +348,47 @@ const decorateAndSignPdf = async ({
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDFDocument.load(pdfData);
let pdfDoc = await PDF.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
pdfDoc.flattenAll();
// Upgrade to PDF 1.7 for better compatibility with signing
pdfDoc.upgradeVersion('1.7');
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
if (isRejected) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateDoc) {
const certificatePages = await pdfDoc.copyPages(
await pdfDoc.copyPagesFrom(
certificateDoc,
certificateDoc.getPageIndices(),
Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
if (auditLogDoc) {
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
await pdfDoc.copyPagesFrom(
auditLogDoc,
Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index),
);
}
// Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) {
const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true }));
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
await insertFieldInPDFV1(legacy_pdfLibDoc, field);
}
}
}
await pdfDoc.reload(await legacy_pdfLibDoc.save());
}
// Handle V2 envelope insertions.
@@ -410,87 +397,61 @@ const decorateAndSignPdf = async ({
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
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;
// 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.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
}
// Rotate the page to the orientation that the react-pdf renders on the frontend.
// Note: These transformations are undone at the end of the function.
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
const pageWidth = page.width;
const pageHeight = page.height;
switch (pageRotationInDegrees) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
const overlayBytes = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields,
});
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
const overlayPdf = await PDF.load(overlayBytes);
// Draw the SVG on the page
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
// Remove the transformations applied to the page if any were applied.
if (pageRotationInDegrees !== 0) {
page.pushOperators(popGraphicsState());
// Rotate the page to the orientation that the react-pdf renders on the frontend.
let translateX = 0;
let translateY = 0;
switch (page.rotation) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
}
// Draw the overlay on the page
page.drawPage(embeddedPage, {
x: translateX,
y: translateY,
rotate: {
angle: page.rotation,
},
});
}
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
await flattenForm(pdfDoc);
pdfDoc.flattenAll();
const pdfBytes = await pdfDoc.save();
pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true }));
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const pdfBytes = await signPdf({ pdf: pdfDoc });
const { name } = path.parse(envelopeItem.title);
@@ -500,7 +461,7 @@ const decorateAndSignPdf = async ({
const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
arrayBuffer: async () => Promise.resolve(pdfBytes),
});
return {
@@ -1,88 +1,72 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { type PDF, rgb } from '@libpdf/core';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page.
*/
export async function addRejectionStampToPdf(
pdfDoc: PDFDocument,
reason: string,
): Promise<PDFDocument> {
const pages = pdfDoc.getPages();
pdfDoc.registerFontkit(fontkit);
export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
const pages = pdf.getPages();
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
const font = await pdfDoc.embedFont(fontBytes, {
customName: 'Noto',
});
const font = pdf.embedFont(new Uint8Array(fontBytes));
const form = pdfDoc.getForm();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = getPageSize(page);
for (const page of pages) {
const height = page.height;
const width = page.width;
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';
const rejectedTitleFontSize = 36;
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
rejectedTitleTextField.acroField.setDefaultAppearance(
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
);
}
rejectedTitleTextField.updateAppearances(font);
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
rejectedTitleTextField.setText(rejectedTitleText);
rejectedTitleTextField.setAlignment(TextAlignment.Center);
const rejectedTitleTextWidth =
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
const rotationAngle = 45;
// Calculate the center position of the page
const centerX = width / 2;
const centerY = height / 2;
// Position the title text at the center of the page
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
const widthOfText = font.getTextWidth(rejectedTitleText, rejectedTitleFontSize);
// Add padding for the rectangle
const padding = 20;
const rectWidth = widthOfText + padding;
const rectHeight = rejectedTitleFontSize + padding;
const rectX = centerX - rectWidth / 2;
const rectY = centerY - rectHeight / 4;
// Draw the stamp background
page.drawRectangle({
x: rejectedTitleTextX - padding / 2,
y: rejectedTitleTextY - padding / 2,
width: rejectedTitleTextWidth + padding,
height: rejectedTitleTextHeight + padding,
x: rectX,
y: rectY,
width: rectWidth,
height: rectHeight,
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
borderWidth: 4,
rotate: {
angle: rotationAngle,
origin: 'center',
},
});
rejectedTitleTextField.addToPage(page, {
x: rejectedTitleTextX,
y: rejectedTitleTextY,
width: rejectedTitleTextWidth,
height: rejectedTitleTextHeight,
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
backgroundColor: undefined,
borderWidth: 0,
borderColor: undefined,
const textX = centerX - widthOfText / 2;
const textY = centerY;
// Draw the text centered within the rectangle
page.drawText(rejectedTitleText, {
x: textX,
y: textY,
size: rejectedTitleFontSize,
font,
color: rgb(220 / 255, 38 / 255, 38 / 255),
rotate: {
angle: rotationAngle,
origin: 'center',
},
});
}
return pdfDoc;
return pdf;
}
@@ -1,63 +0,0 @@
import { PDFAnnotation, PDFRef } from '@cantoo/pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
export const flattenAnnotations = (document: PDFDocument) => {
const pages = document.getPages();
for (const page of pages) {
const annotations = page.node.Annots()?.asArray() ?? [];
annotations.forEach((annotation) => {
if (!(annotation instanceof PDFRef)) {
return;
}
const actualAnnotation = page.node.context.lookup(annotation);
if (!(actualAnnotation instanceof PDFDict)) {
return;
}
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
const appearance = pdfAnnot.ensureAP();
// Skip annotations without a normal appearance
if (!appearance.has(PDFName.of('N'))) {
return;
}
const normalAppearance = pdfAnnot.getNormalAppearance();
const rectangle = pdfAnnot.getRectangle();
if (!(normalAppearance instanceof PDFRef)) {
// Not sure how to get the reference to the normal appearance yet
// so we should skip this annotation for now
return;
}
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xobj),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
page.node.removeAnnot(annotation);
});
}
};
@@ -1,170 +0,0 @@
import type { PDFField, PDFWidgetAnnotation } from '@cantoo/pdf-lib';
import {
PDFCheckBox,
PDFDict,
type PDFDocument,
PDFName,
PDFNumber,
PDFRadioGroup,
PDFRef,
PDFStream,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
if (catalog instanceof PDFDict) {
catalog.delete(PDFName.of('OCProperties'));
}
};
export const flattenForm = async (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
const fontNoto = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
document.registerFontkit(fontkit);
const font = await document.embedFont(fontNoto);
form.updateFieldAppearances(font);
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {
flattenWidget(document, field, widget);
}
try {
form.removeField(field);
} catch (error) {
console.error(error);
}
}
};
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
const pageRef = widget.P();
let page = document.getPages().find((page) => page.ref === pageRef);
if (!page) {
const widgetRef = document.context.getObjectRef(widget.dict);
if (!widgetRef) {
return null;
}
page = document.findPageForAnnotationRef(widgetRef);
if (!page) {
return null;
}
}
return page;
};
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const normalAppearance = widget.getNormalAppearance();
let normalAppearanceRef: PDFRef | null = null;
if (normalAppearance instanceof PDFRef) {
normalAppearanceRef = normalAppearance;
}
if (
normalAppearance instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
normalAppearanceRef = ref;
}
}
return normalAppearanceRef;
} catch (error) {
console.error(error);
return null;
}
};
/**
* Ensures that an appearance stream has the required dictionary entries to be
* used as a Form XObject. Some PDFs have appearance streams that are missing
* the /Subtype /Form entry, which causes Adobe Reader to fail to render them.
*
* Per PDF spec, a Form XObject stream requires:
* - /Subtype /Form (required)
* - /BBox (required, but should already exist for appearance streams)
* - /FormType 1 (optional, defaults to 1)
*/
const normalizeAppearanceStream = (document: PDFDocument, appearanceRef: PDFRef) => {
const appearanceStream = document.context.lookup(appearanceRef);
if (!(appearanceStream instanceof PDFStream)) {
return;
}
const dict = appearanceStream.dict;
// Ensure /Subtype /Form is set (required for XObject Form)
if (!dict.has(PDFName.of('Subtype'))) {
dict.set(PDFName.of('Subtype'), PDFName.of('Form'));
}
// Ensure /FormType is set (optional, but good practice)
if (!dict.has(PDFName.of('FormType'))) {
dict.set(PDFName.of('FormType'), PDFNumber.of(1));
}
};
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const page = getPageForWidget(document, widget);
if (!page) {
return;
}
const appearanceRef = getAppearanceRefForWidget(field, widget);
if (!appearanceRef) {
return;
}
// Ensure the appearance stream has required XObject Form dictionary entries
normalizeAppearanceStream(document, appearanceRef);
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
} catch (error) {
console.error(error);
}
};
@@ -1,3 +1,4 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma';
@@ -7,7 +8,6 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
@@ -43,7 +43,9 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
i18n,
});
return await mergeFilesIntoPdf(auditLogPages);
return await PDF.merge(auditLogPages, {
includeAnnotations: true,
});
};
const getAuditLogs = async (envelopeId: string) => {
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
@@ -144,17 +144,5 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
const certificatePages = await renderCertificate(payload);
return await mergeFilesIntoPdf(certificatePages);
return await PDF.merge(certificatePages);
};
export async function mergeFilesIntoPdf(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create();
for (const buffer of buffers) {
const pdf = await PDFDocument.load(buffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((p) => mergedPdf.addPage(p));
}
return mergedPdf;
}
@@ -1,10 +1,4 @@
import {
PDFCheckBox,
PDFDocument,
PDFDropdown,
PDFRadioGroup,
PDFTextField,
} from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
export type InsertFormValuesInPdfOptions = {
pdf: Buffer;
@@ -12,7 +6,7 @@ export type InsertFormValuesInPdfOptions = {
};
export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => {
const doc = await PDFDocument.load(pdf);
const doc = await PDF.load(pdf);
const form = doc.getForm();
@@ -20,41 +14,12 @@ export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValue
return pdf;
}
for (const [key, value] of Object.entries(formValues)) {
try {
const field = form.getField(key);
const filledForm = Object.entries(formValues).map(([key, value]) => [
key,
typeof value === 'boolean' ? value : value.toString(),
]);
if (!field) {
continue;
}
form.fill(Object.fromEntries(filledForm));
if (typeof value === 'boolean' && field instanceof PDFCheckBox) {
if (value) {
field.check();
} else {
field.uncheck();
}
}
if (field instanceof PDFTextField) {
field.setText(value.toString());
}
if (field instanceof PDFDropdown) {
field.select(value.toString());
}
if (field instanceof PDFRadioGroup) {
field.select(value.toString());
}
} catch (err) {
if (err instanceof Error) {
console.error(`Error setting value for field ${key}: ${err.message}`);
} else {
console.error(`Error setting value for field ${key}`);
}
}
}
return await doc.save().then((buf) => Buffer.from(buf));
return await doc.save({ incremental: true }).then((buf) => Buffer.from(buf));
};
@@ -1,26 +0,0 @@
import { PDFDocument } from '@cantoo/pdf-lib';
export async function insertImageInPDF(
pdfAsBase64: string,
image: string | Uint8Array | ArrayBuffer,
positionX: number,
positionY: number,
page = 0,
): Promise<string> {
const existingPdfBytes = pdfAsBase64;
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const pngImage = await pdfDoc.embedPng(image);
const drawSize = { width: 192, height: 64 };
pdfPage.drawImage(pngImage, {
x: positionX,
y: pdfPage.getHeight() - positionY - drawSize.height,
width: drawSize.width,
height: drawSize.height,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}
@@ -1,54 +0,0 @@
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { CAVEAT_FONT_PATH } from '../../constants/pdf';
export async function insertTextInPDF(
pdfAsBase64: string,
text: string,
positionX: number,
positionY: number,
page = 0,
useHandwritingFont = true,
customFontSize?: number,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH());
const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit);
const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = customFontSize || (useHandwritingFont ? 50 : 15);
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 250, height: 64 };
// Because pdf-lib use a bottom-left coordinate system, we need to invert the y position
// we then center the text in the middle by adding half the height of the text
// plus the height of the field and divide the result by 2
const invertedYPosition =
pdfPage.getHeight() - positionY - (fieldSize.height + textHeight / 2) / 2;
// We center the text by adding the width of the field, subtracting the width of the text
// and dividing the result by 2
const centeredXPosition = positionX + (fieldSize.width - textWidth) / 2;
pdfPage.drawText(text, {
x: centeredXPosition,
y: invertedYPosition,
size: textSize,
color: rgb(0, 0, 0),
font,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}
@@ -1,13 +1,11 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => {
const shouldFlattenForm = options.flattenForm ?? true;
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
const pdfDoc = await PDF.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', {
@@ -21,11 +19,13 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
});
}
removeOptionalContentGroups(pdfDoc);
pdfDoc.flattenLayers();
if (shouldFlattenForm) {
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
const form = pdfDoc.getForm();
if (shouldFlattenForm && form) {
form.flatten();
pdfDoc.flattenAnnotations();
}
return Buffer.from(await pdfDoc.save());
@@ -1,26 +0,0 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { PDFSignature, rectangle } from '@cantoo/pdf-lib';
export const normalizeSignatureAppearances = (document: PDFDocument) => {
const form = document.getForm();
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
};
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
@@ -25,7 +25,7 @@ export const putPdfFileServerSide = async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
const pdf = await PDF.load(new Uint8Array(arrayBuffer)).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
+10 -4
View File
@@ -6,14 +6,20 @@ declare global {
}
}
type EnvironmentVariable = keyof NodeJS.ProcessEnv;
// eslint-disable-next-line @typescript-eslint/ban-types
type EnvKey = keyof NodeJS.ProcessEnv | (string & {});
type EnvValue<K extends EnvKey> = K extends keyof NodeJS.ProcessEnv
? NodeJS.ProcessEnv[K]
: string | undefined;
export const env = (variable: EnvironmentVariable | (string & object)): string | undefined => {
export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
if (typeof window !== 'undefined' && typeof window.__ENV__ === 'object') {
return window.__ENV__[variable];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return window.__ENV__[variable as string] as EnvValue<K>;
}
return typeof process !== 'undefined' ? process?.env?.[variable] : undefined;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
};
export const createPublicEnv = () =>