// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 import fontkit from '@pdf-lib/fontkit'; import { FieldType } from '@prisma/client'; import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib'; import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb, setFontAndSize, } from 'pdf-lib'; 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'; export const 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); /** * Red box is the original field width, height and position. * * Blue box is the adjusted field width, height and position. It will represent * where the text will overflow into. */ 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 } = page.getSize(); // 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); const direction = meta.data.direction ?? 'vertical'; const topPadding = 12; const leftCheckboxPadding = 8; const leftCheckboxLabelPadding = 12; const checkboxSpaceY = 13; if (direction === 'horizontal') { // Horizontal layout: arrange checkboxes side by side with wrapping let currentX = leftCheckboxPadding; let currentY = topPadding; const maxWidth = pageWidth - fieldX - leftCheckboxPadding * 2; for (const [index, item] of (values ?? []).entries()) { const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`); if (selected.includes(item.value)) { checkbox.check(); } const labelText = item.value.includes('empty-value-') ? '' : item.value; const labelWidth = font.widthOfTextAtSize(labelText, 12); const itemWidth = leftCheckboxLabelPadding + labelWidth + 16; // checkbox + padding + label + margin // Check if item fits on current line, if not wrap to next line if (currentX + itemWidth > maxWidth && index > 0) { currentX = leftCheckboxPadding; currentY += checkboxSpaceY; } page.drawText(labelText, { x: fieldX + currentX + leftCheckboxLabelPadding, y: pageHeight - (fieldY + currentY), size: 12, font, rotate: degrees(pageRotationInDegrees), }); checkbox.addToPage(page, { x: fieldX + currentX, y: pageHeight - (fieldY + currentY), height: 8, width: 8, }); currentX += itemWidth; } } else { // Vertical layout: original behavior for (const [index, item] of (values ?? []).entries()) { const offsetY = index * checkboxSpaceY + topPadding; 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 + leftCheckboxPadding + leftCheckboxLabelPadding, y: pageHeight - (fieldY + offsetY), size: 12, font, rotate: degrees(pageRotationInDegrees), }); checkbox.addToPage(page, { x: fieldX + leftCheckboxPadding, 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(','); const topPadding = 12; const leftRadioPadding = 8; const leftRadioLabelPadding = 12; const radioSpaceY = 13; for (const [index, item] of (values ?? []).entries()) { const offsetY = index * radioSpaceY + topPadding; const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`); // Draw label. page.drawText(item.value.includes('empty-value-') ? '' : item.value, { x: fieldX + leftRadioPadding + leftRadioLabelPadding, y: pageHeight - (fieldY + offsetY), size: 12, font, rotate: degrees(pageRotationInDegrees), }); // Draw radio button. radio.addOptionToPage(item.value, page, { x: fieldX + leftRadioPadding, 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 fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers]; const meta = fieldMetaParser ? fieldMetaParser.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 : 'left'; let fontSize = customFontSize || maxFontSize; const textWidth = font.widthOfTextAtSize(field.customText, fontSize); const textHeight = font.heightAtSize(fontSize); // Scale font only if no custom font and height exceeds field height. if (!customFontSize) { const scalingFactor = Math.min(fieldHeight / textHeight, 1); fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize); } /** * Calculate whether the field should be multiline. * * - True = text will overflow downwards. * - False = text will overflow sideways. */ const isMultiline = field.type === FieldType.TEXT && (textWidth > fieldWidth || field.customText.includes('\n')); // Add padding similar to web display (roughly 0.5rem equivalent in PDF units) const padding = 8; const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding); // Invert the Y axis since PDFs use a bottom-left coordinate system let textFieldBoxY = pageHeight - fieldY - fieldHeight; const textFieldBoxX = textAlignmentOptions.xPos; const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`); textField.setAlignment(textAlignmentOptions.textAlignment); /** * From now on we will adjust the field size and position so the text * overflows correctly in the X or Y axis depending on the field type. */ let adjustedFieldWidth = fieldWidth - padding * 2; // let adjustedFieldHeight = fieldHeight; let adjustedFieldX = textFieldBoxX; let adjustedFieldY = textFieldBoxY; let textToInsert = field.customText; // The padding to use when fields go off the page. const pagePadding = 4; // Handle multiline text, which will overflow on the Y axis. if (isMultiline) { textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize); textField.enableMultiline(); textField.disableCombing(); textField.disableScrolling(); // Adjust the textFieldBox so it extends to the bottom of the page so text can wrap. textFieldBoxY = pageHeight - fieldY - fieldHeight; // Calculate how much PX from the current field to bottom of the page. const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding; // Field height will be from current to bottom of page. adjustedFieldHeight = fieldHeight + fieldYOffset; // Need to move the field Y so it offsets the new field height. adjustedFieldY = adjustedFieldY - fieldYOffset; } // Handle non-multiline text, which will overflow on the X axis. if (!isMultiline) { // Left align will extend all the way to the right of the page if (textAlignmentOptions.textAlignment === TextAlignment.Left) { adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding; } // Right align will extend all the way to the left of the page. if (textAlignmentOptions.textAlignment === TextAlignment.Right) { adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding; adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth; } // Center align will extend to the closest page edge, then use that * 2 as the width. if (textAlignmentOptions.textAlignment === TextAlignment.Center) { const fieldMidpoint = textFieldBoxX + fieldWidth / 2; const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2; // If field is closer to left edge, the width must be based of the left. if (isCloserToLeftEdge) { adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth; adjustedFieldX = pagePadding; } // If field is closer to right edge, the width must be based of the right if (!isCloserToLeftEdge) { adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2; adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding; } } } if (pageRotationInDegrees !== 0) { const adjustedPosition = adjustPositionForRotation( pageWidth, pageHeight, adjustedFieldX, adjustedFieldY, pageRotationInDegrees, ); adjustedFieldX = adjustedPosition.xPos; adjustedFieldY = adjustedPosition.yPos; } // Set properties for the text field setTextFieldFontSize(textField, font, fontSize); textField.setText(textToInsert); // Set the position and size of the text field textField.addToPage(page, { x: adjustedFieldX, y: adjustedFieldY, width: adjustedFieldWidth, height: adjustedFieldHeight, rotate: degrees(pageRotationInDegrees), font, // Hide borders. borderWidth: 0, borderColor: undefined, backgroundColor: undefined, ...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}), }); }); 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, }; }; const textAlignmentMap = { left: TextAlignment.Left, center: TextAlignment.Center, right: TextAlignment.Right, } as const; /** * Get the PDF-lib alignment position, and the X position of the field with padding included. * * @param textAlign - The text alignment of the field. * @param fieldX - The X position of the field. * @param isMultiline - Whether the field is multiline. * @param padding - The padding of the field. Defaults to 8. * * @returns The X position and text alignment for the field. */ const getTextAlignmentOptions = ( textAlign: 'left' | 'center' | 'right', fieldX: number, isMultiline: boolean, padding: number = 8, ) => { const textAlignment = textAlignmentMap[textAlign]; // For multiline, it needs to be centered so we just basic left padding. if (isMultiline) { return { xPos: fieldX + padding, textAlignment, }; } return match(textAlign) .with('left', () => ({ xPos: fieldX + padding, textAlignment, })) .with('center', () => ({ xPos: fieldX, textAlignment, })) .with('right', () => ({ xPos: fieldX - padding, textAlignment, })) .exhaustive(); }; /** * Break a long string into multiple lines so it fits within a given width, * using natural word breaking similar to word processors. * * - Keeps words together when possible * - Only breaks words when they're too long to fit on a line * - Handles whitespace intelligently * * @param text - The text to break into lines * @param maxWidth - The maximum width of each line in PX * @param font - The PDF font object * @param fontSize - The font size in points * @returns Object containing the result string and line count */ function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string { // Handle empty text if (!text) { return ''; } const lines: string[] = []; // Process each original line separately to preserve newlines for (const paragraph of text.split('\n')) { // If paragraph fits on one line or is empty, add it as-is if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) { lines.push(paragraph); continue; } // Split paragraph into words const words = paragraph.split(' '); let currentLine = ''; for (const word of words) { // Check if adding word to current line would exceed max width const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`; if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) { // Word fits, add it to current line currentLine = lineWithWord; } else { // Word doesn't fit on current line // First, save current line if it's not empty if (currentLine.length > 0) { lines.push(currentLine); currentLine = ''; } // Check if word fits on a line by itself if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) { // Word fits on its own line currentLine = word; } else { // Word is too long, need to break it character by character let charLine = ''; // Process each character in the word for (const char of word) { const nextCharLine = charLine + char; if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) { // Character fits, add it charLine = nextCharLine; } else { // Character doesn't fit, push current charLine and start a new one lines.push(charLine); charLine = char; } } // Add any remaining characters as the current line currentLine = charLine; } } } // Add the last line if not empty if (currentLine.length > 0) { lines.push(currentLine); } } return lines.join('\n'); } const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => { textField.defaultUpdateAppearances(font); textField.updateAppearances(font); try { textField.setFontSize(fontSize); } catch (err) { let da = textField.acroField.getDefaultAppearance() ?? ''; da += `\n ${setFontAndSize(font.name, fontSize)}`; textField.acroField.setDefaultAppearance(da); } textField.defaultUpdateAppearances(font); textField.updateAppearances(font); };