mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 02:15:05 +10:00
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:
@@ -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');
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
Reference in New Issue
Block a user