import type { I18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import type { Field, RecipientRole, Signature } from '@prisma/client'; import { SigningStatus } from '@prisma/client'; import Konva from 'konva'; import 'konva/skia-backend'; import fs from 'node:fs'; import path from 'node:path'; import { DateTime } from 'luxon'; import type { Canvas } from 'skia-canvas'; import { Image as SkiaImage } from 'skia-canvas'; import { UAParser } from 'ua-parser-js'; import { renderSVG } from 'uqr'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { APP_I18N_OPTIONS } from '../../constants/i18n'; import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs'; import { svgToPng } from '../../utils/images/svg-to-png'; import { ensureFontLibrary } from './helpers'; type ColumnWidths = [number, number, number]; type BaseAuditLog = Pick; export type CertificateRecipient = { id: number; name: string; email: string; role: RecipientRole; rejectionReason: string | null; signingStatus: SigningStatus; signatureField?: Pick & { signature?: Pick | null; }; authLevel: string; logs: { emailed: BaseAuditLog | null; sent: BaseAuditLog | null; opened: BaseAuditLog | null; completed: BaseAuditLog | null; rejected: BaseAuditLog | null; }; }; type GenerateCertificateOptions = { recipients: CertificateRecipient[]; envelopeId: string; qrToken: string | null; hidePoweredBy: boolean; i18n: I18n; envelopeOwner: { name: string; email: string; }; pageWidth: number; pageHeight: number; }; // Helper function to get device info from user agent const getDevice = (userAgent?: string | null): string => { if (!userAgent) { return 'Unknown'; } const parser = new UAParser(userAgent); parser.setUA(userAgent); const result = parser.getResult(); return `${result.os.name} - ${result.browser.name} ${result.browser.version}`; }; const textMutedForegroundLight = '#929DAE'; const textForeground = '#000'; const textMutedForeground = '#64748B'; const textRejectedRed = '#dc2626'; const textBase = 10; const textSm = 9; const textXs = 8; const fontMedium = '500'; const columnWidthPercentages = [30, 30, 40]; const rowPadding = 12; const tableHeaderHeight = 38; const pageTopMargin = 72; const pageBottomMargin = 24; const contentMaxWidth = 768; const titleFontSize = 18; type RenderLabelAndTextOptions = { label: string; text: string; width: number; y?: number; labelFill?: string; valueFill?: string; }; const renderLabelAndText = (options: RenderLabelAndTextOptions) => { const { width, y } = options; const group = new Konva.Group({ y, }); const labelFill = options.labelFill ?? textMutedForeground; const valueFill = options.valueFill ?? textMutedForeground; const label = new Konva.Text({ x: 0, y: 0, text: `${options.label}: `, fontStyle: fontMedium, fontFamily: 'Inter', fill: labelFill, fontSize: textSm, }); group.add(label); const value = new Konva.Text({ x: label.width(), y: 0, width: width - label.width(), fontFamily: 'Inter', text: options.text, fill: valueFill, wrap: 'char', fontSize: textSm, }); group.add(value); return group; }; type RenderRowHeaderOptions = { columnWidths: number[]; i18n: I18n; }; const renderRowHeader = (options: RenderRowHeaderOptions) => { const { columnWidths, i18n } = options; const columnOneWidth = columnWidths[0]; const columnTwoWidth = columnWidths[1]; const columnThreeWidth = columnWidths[2]; const headerRow = new Konva.Group(); const headerFontStyling = { fontFamily: 'Inter', fontSize: 11, fontStyle: fontMedium, verticalAlign: 'middle', fill: textMutedForeground, height: tableHeaderHeight, }; const header1 = new Konva.Text({ x: rowPadding, width: columnOneWidth, text: i18n._(msg`Signer Events`), ...headerFontStyling, }); headerRow.add(header1); const header2 = new Konva.Text({ x: columnOneWidth + rowPadding, width: columnTwoWidth, text: i18n._(msg`Signature`), ...headerFontStyling, }); headerRow.add(header2); const header3 = new Konva.Text({ x: columnOneWidth + columnTwoWidth + rowPadding, width: columnThreeWidth, text: i18n._(msg`Details`), ...headerFontStyling, }); headerRow.add(header3); return headerRow; }; const columnPadding = 10; type RenderColumnOptions = { recipient: CertificateRecipient; width: number; i18n: I18n; envelopeOwner: { name: string; email: string; }; }; const renderColumnOne = (options: RenderColumnOptions) => { const { recipient, width, i18n } = options; const columnGroup = new Konva.Group(); const textSectionPadding = 8; const textFontStyling = { x: 0, fontFamily: 'Inter', wrap: 'char', lineHeight: 1.2, fill: textMutedForeground, width: width - columnPadding, }; if (recipient.name) { const nameText = new Konva.Text({ y: 0, text: recipient.name, fontSize: textBase, ...textFontStyling, fontStyle: fontMedium, }); columnGroup.add(nameText); } const emailText = new Konva.Text({ y: columnGroup.getClientRect().height, text: recipient.email, fontSize: textBase, ...textFontStyling, }); columnGroup.add(emailText); const roleText = new Konva.Text({ y: columnGroup.getClientRect().height + textSectionPadding, text: i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName), fontSize: textSm, ...textFontStyling, }); columnGroup.add(roleText); const authLabel = new Konva.Text({ y: columnGroup.getClientRect().height + textSectionPadding, text: `${i18n._(msg`Authentication Level`)}:`, fontSize: textSm, fontStyle: fontMedium, ...textFontStyling, }); columnGroup.add(authLabel); const authValue = new Konva.Text({ y: columnGroup.getClientRect().height, text: recipient.authLevel, fontSize: textSm, ...textFontStyling, }); columnGroup.add(authValue); return columnGroup; }; const renderColumnTwo = (options: RenderColumnOptions) => { const { recipient, width, i18n } = options; // Column 2: Signature const column = new Konva.Group(); const columnWidth = width - columnPadding; const isRejected = Boolean(recipient.logs.rejected); if (recipient.signatureField?.secondaryId) { // Signature container with green border const signatureContainer = new Konva.Group({ x: 0, y: 0 }); const minSignatureHeight = 40; const maxSignatureWidth = 100; // Signature content if (recipient.signatureField?.signature?.signatureImageAsBase64) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const img = new SkiaImage( recipient.signatureField?.signature?.signatureImageAsBase64, ) as unknown as HTMLImageElement; const signatureImage = new Konva.Image({ image: img, x: 4, y: 4, width: maxSignatureWidth, height: maxSignatureWidth * (img.height / img.width), }); signatureContainer.add(signatureImage); } else if (recipient.signatureField?.signature?.typedSignature) { const typedSig = new Konva.Text({ x: 2, text: recipient.signatureField?.signature?.typedSignature, padding: 4, fontFamily: 'Caveat', fontSize: 16, align: 'center', verticalAlign: 'middle', width: maxSignatureWidth, }); if (typedSig.getClientRect().height < minSignatureHeight) { typedSig.setAttrs({ height: minSignatureHeight, }); } signatureContainer.add(typedSig); } // Do not add the signature container for rejected recipients. if (!isRejected) { column.add(signatureContainer); } const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight); const signatureBorder = new Konva.Rect({ x: 2, y: 2, width: maxSignatureWidth, height: signatureHeight, stroke: 'rgba(122, 196, 85, 0.6)', strokeWidth: 1, cornerRadius: 8, }); signatureContainer.add(signatureBorder); const signatureShadow = new Konva.Rect({ x: 0, y: 0, width: maxSignatureWidth + 4, height: signatureHeight + 4, stroke: 'rgba(122, 196, 85, 0.1)', strokeWidth: 4, cornerRadius: 8, }); signatureContainer.add(signatureShadow); // Signature ID const sigIdLabel = new Konva.Text({ x: 0, y: isRejected ? 0 : signatureHeight + 10, text: `${i18n._(msg`Signature ID`)}:`, fill: textMutedForeground, width: columnWidth, fontFamily: 'Inter', fontSize: textSm, fontStyle: fontMedium, lineHeight: 1.4, }); column.add(sigIdLabel); const sigIdValue = new Konva.Text({ x: 0, y: column.getClientRect().height, text: recipient.signatureField.secondaryId.toUpperCase(), fill: textMutedForeground, fontFamily: 'monospace', fontSize: textSm, width: columnWidth, wrap: 'char', }); column.add(sigIdValue); } else { const naText = new Konva.Text({ x: 0, y: 0, text: 'N/A', fill: textMutedForeground, fontFamily: 'Inter', fontSize: textSm, }); column.add(naText); } const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed; const ipLabelAndText = renderLabelAndText({ label: i18n._(msg`IP Address`), text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`), width, y: column.getClientRect().height + 6, }); column.add(ipLabelAndText); const deviceLabelAndText = renderLabelAndText({ label: i18n._(msg`Device`), text: getDevice(relevantLog?.userAgent), width, y: column.getClientRect().height + 6, }); column.add(deviceLabelAndText); return column; }; const renderColumnThree = (options: RenderColumnOptions) => { const { recipient, width, i18n, envelopeOwner } = options; const column = new Konva.Group(); type DetailItem = { label: string; value: string; labelFill?: string; valueFill?: string; }; const itemsToRender: DetailItem[] = [ { label: i18n._(msg`Sent`), value: recipient.logs.emailed ? DateTime.fromJSDate(recipient.logs.emailed.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') : recipient.logs.sent ? DateTime.fromJSDate(recipient.logs.sent.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') : i18n._(msg`Unknown`), }, { label: i18n._(msg`Viewed`), value: recipient.logs.opened ? DateTime.fromJSDate(recipient.logs.opened.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') : i18n._(msg`Unknown`), }, ]; if (recipient.logs.rejected) { itemsToRender.push({ label: i18n._(msg`Rejected`), value: DateTime.fromJSDate(recipient.logs.rejected.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), labelFill: textRejectedRed, valueFill: textRejectedRed, }); } else { itemsToRender.push({ label: i18n._(msg`Signed`), value: recipient.logs.completed ? DateTime.fromJSDate(recipient.logs.completed.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') : i18n._(msg`Unknown`), }); } const isOwner = recipient.email.toLowerCase() === envelopeOwner.email.toLowerCase(); itemsToRender.push({ label: i18n._(msg`Reason`), value: recipient.signingStatus === SigningStatus.REJECTED ? recipient.rejectionReason || '' : isOwner ? i18n._(msg`I am the owner of this document`) : i18n._(RECIPIENT_ROLE_SIGNING_REASONS[recipient.role]), }); for (const [index, item] of itemsToRender.entries()) { const labelAndText = renderLabelAndText({ label: item.label, text: item.value, width, y: column.getClientRect().height + (index === 0 ? 0 : 8), labelFill: item.labelFill, valueFill: item.valueFill, }); column.add(labelAndText); } return column; }; type RenderRowOptions = { recipient: CertificateRecipient; columnWidths: ColumnWidths; i18n: I18n; envelopeOwner: { name: string; email: string; }; }; const renderRow = (options: RenderRowOptions) => { const { recipient, columnWidths, i18n, envelopeOwner } = options; const rowGroup = new Konva.Group(); const width = columnWidths[0] + columnWidths[1] + columnWidths[2]; // Draw top border line. const borderLine = new Konva.Line({ points: [0, 0, width + rowPadding * 2, 0], stroke: '#e5e7eb', strokeWidth: 1, }); rowGroup.add(borderLine); // Column 1: Signer Events const columnGroup = renderColumnOne({ recipient, width: columnWidths[0], i18n, envelopeOwner, }); columnGroup.setAttrs({ x: rowPadding, y: rowPadding, } satisfies Partial); rowGroup.add(columnGroup); const columnTwoGroup = renderColumnTwo({ recipient, width: columnWidths[1], i18n, envelopeOwner, }); columnTwoGroup.setAttrs({ x: rowPadding + columnWidths[0], y: rowPadding, } satisfies Partial); rowGroup.add(columnTwoGroup); // Column 3: Details const columnThreeGroup = renderColumnThree({ recipient, width: columnWidths[2], i18n, envelopeOwner, }); columnThreeGroup.setAttrs({ x: rowPadding + columnWidths[0] + columnWidths[1], y: rowPadding, } satisfies Partial); rowGroup.add(columnThreeGroup); const rowBottomPadding = new Konva.Rect({ x: 0, y: rowGroup.getClientRect().height, width: rowGroup.getClientRect().width, height: rowPadding, }); rowGroup.add(rowBottomPadding); return rowGroup; }; const renderBranding = async ({ qrToken, i18n }: { qrToken: string | null; i18n: I18n }) => { const branding = new Konva.Group(); const brandingHeight = 12; const text = new Konva.Text({ x: 0, verticalAlign: 'middle', text: i18n._(msg`Signing certificate provided by`) + ':', fontStyle: fontMedium, fontFamily: 'Inter', fontSize: textSm, height: brandingHeight, }); const logoPath = path.join(process.cwd(), 'public/static/logo.png'); const logo = fs.readFileSync(logoPath); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const img = new SkiaImage(logo) as unknown as HTMLImageElement; const documensoImage = new Konva.Image({ image: img, height: brandingHeight, width: brandingHeight * (img.width / img.height), x: text.width() + 16, }); const qrSize = qrToken ? 72 : 0; const logoGroup = new Konva.Group({ y: qrSize + 16, }); logoGroup.add(text); logoGroup.add(documensoImage); branding.add(logoGroup); if (qrToken) { const qrSvg = renderSVG(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrToken}`, { ecc: 'Q', }); const svgImage = await svgToPng(qrSvg); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const qrSkiaImage = new SkiaImage(svgImage) as unknown as HTMLImageElement; const qrImage = new Konva.Image({ image: qrSkiaImage, height: qrSize, width: qrSize, x: branding.getClientRect().width - qrSize, y: 0, }); branding.add(qrImage); } return branding; }; type GroupRowsIntoPagesOptions = { recipients: CertificateRecipient[]; maxHeight: number; i18n: I18n; columnWidths: ColumnWidths; envelopeOwner: { name: string; email: string; }; }; const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => { const { recipients, maxHeight, i18n, columnWidths, envelopeOwner } = options; const rowHeader = renderRowHeader({ columnWidths, i18n }); const rowHeaderHeight = rowHeader.getClientRect().height; const groupedRows: Konva.Group[][] = [[]]; let availablePageHeight = maxHeight - rowHeaderHeight; let currentGroupedRowIndex = 0; // Group rows into pages. for (const recipient of recipients) { const row = renderRow({ recipient, columnWidths, i18n, envelopeOwner }); const rowHeight = row.getClientRect().height; if (rowHeight > availablePageHeight) { currentGroupedRowIndex++; groupedRows[currentGroupedRowIndex] = [row]; availablePageHeight = maxHeight - rowHeaderHeight; } else { groupedRows[currentGroupedRowIndex].push(row); } // Reduce available height by the row height. availablePageHeight -= rowHeight; } return groupedRows; }; type RenderTablesOptions = { groupedRows: Konva.Group[][]; columnWidths: ColumnWidths; i18n: I18n; }; const renderTables = (options: RenderTablesOptions) => { const { groupedRows, columnWidths, i18n } = options; const tables: Konva.Group[] = []; // Render the rows for each page. for (const rows of groupedRows) { const table = new Konva.Group(); const tableHeader = renderRowHeader({ columnWidths, i18n }); table.add(tableHeader); for (const row of rows) { row.setAttrs({ x: 0, y: table.getClientRect().height, } satisfies Partial); table.add(row); } // Add table background and border. const tableClientRect = table.getClientRect(); const cardRect = new Konva.Rect({ x: tableClientRect.x, y: tableClientRect.y, width: tableClientRect.width, height: tableClientRect.height, stroke: '#e5e7eb', strokeWidth: 1.5, cornerRadius: 8, }); table.add(cardRect); tables.push(table); } return tables; }; export async function renderCertificate({ recipients, envelopeId, qrToken, hidePoweredBy, i18n, envelopeOwner, pageWidth, pageHeight, }: GenerateCertificateOptions) { ensureFontLibrary(); const minimumMargin = 10; const tableWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth); const tableContentWidth = tableWidth - rowPadding * 2; const margin = (pageWidth - tableWidth) / 2; const columnOneWidth = (tableContentWidth * columnWidthPercentages[0]) / 100; const columnTwoWidth = (tableContentWidth * columnWidthPercentages[1]) / 100; const columnThreeWidth = (tableContentWidth * columnWidthPercentages[2]) / 100; const columnWidths: ColumnWidths = [columnOneWidth, columnTwoWidth, columnThreeWidth]; // Helper to render a Konva stage to a PNG buffer let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight }); const maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin; const groupedRows = groupRowsIntoPages({ recipients, maxHeight: maxTableHeight, columnWidths, i18n, envelopeOwner, }); const tables = renderTables({ groupedRows, columnWidths, i18n }); const brandingGroup = await renderBranding({ qrToken, i18n }); const brandingRect = brandingGroup.getClientRect(); const brandingTopPadding = 24; const pages: Uint8Array[] = []; let isQrPlaced = false; // Add a table to each page. for (const [index, table] of tables.entries()) { stage.destroyChildren(); const page = new Konva.Layer(); const group = new Konva.Group(); const titleText = new Konva.Text({ x: margin, y: 0, height: pageTopMargin, verticalAlign: 'middle', text: i18n._(msg`Signing Certificate`), fontFamily: 'Inter', fontSize: titleFontSize, fontStyle: '700', }); table.setAttrs({ x: margin, y: pageTopMargin, } satisfies Partial); group.add(titleText); group.add(table); // Add QR code and branding on the last page if there is space. if (index === tables.length - 1 && !hidePoweredBy) { const remainingHeight = pageHeight - group.getClientRect().height - pageBottomMargin; if (brandingRect.height + brandingTopPadding <= remainingHeight) { brandingGroup.setAttrs({ x: pageWidth - brandingRect.width - margin, y: group.getClientRect().height + brandingTopPadding, } satisfies Partial); page.add(brandingGroup); isQrPlaced = true; } } const footerText = new Konva.Text({ x: margin, y: pageHeight - textXs - 10, text: `${i18n._(msg`Envelope ID`)}: ${envelopeId}`, fontFamily: 'Inter', fontSize: textXs, fill: textMutedForegroundLight, }); page.add(footerText); page.add(group); stage.add(page); // Export the page and save it. const canvas = page.canvas._canvas as unknown as Canvas; // eslint-disable-line @typescript-eslint/consistent-type-assertions const buffer = await canvas.toBuffer('pdf'); pages.push(new Uint8Array(buffer)); } // Need to create an empty page for the QR code if it hasn't been placed yet. if (!hidePoweredBy && !isQrPlaced) { const page = new Konva.Layer(); brandingGroup.setAttrs({ x: pageWidth - brandingRect.width - margin, y: pageTopMargin / 2, // Less padding since there's nothing else on this page. } satisfies Partial); const overflowFooterText = new Konva.Text({ x: margin, y: pageHeight - textXs - 10, text: `${i18n._(msg`Envelope ID`)}: ${envelopeId}`, fontFamily: 'Inter', fontSize: textXs, fill: textMutedForegroundLight, }); page.add(overflowFooterText); page.add(brandingGroup); stage.add(page); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const canvas = page.canvas._canvas as unknown as Canvas; const buffer = await canvas.toBuffer('pdf'); pages.push(new Uint8Array(buffer)); } stage.destroy(); stage = null; return pages; }