diff --git a/apps/remix/app/components/tables/internal-audit-log-table.tsx b/apps/remix/app/components/tables/internal-audit-log-table.tsx index 03cff1d83..cce0ca35f 100644 --- a/apps/remix/app/components/tables/internal-audit-log-table.tsx +++ b/apps/remix/app/components/tables/internal-audit-log-table.tsx @@ -1,48 +1,20 @@ import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; -import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; +import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import type { DateTimeFormatOptions } from 'luxon'; import { DateTime } from 'luxon'; -import { match, P } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; export type AuditLogDataTableProps = { logs: TDocumentAuditLog[]; }; -const dateFormat: DateTimeFormatOptions = { - ...DateTime.DATETIME_SHORT, - hourCycle: 'h12', -}; - -/** - * Get the color indicator for the audit log type - */ - -const getAuditLogIndicatorColor = (type: string) => - match(type) - .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500') - .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500') - .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500') - .with( - P.union(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED), - () => 'bg-blue-500', - ) - .otherwise(() => 'bg-muted'); - /** * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. */ -const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => { - if (!userAgent) { - return msg`N/A`; - } - +const formatUserAgent = (userAgent: string, userAgentInfo: UAParser.IResult) => { const browser = userAgentInfo.browser.name; const version = userAgentInfo.browser.version; const os = userAgentInfo.os.name; @@ -63,74 +35,45 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => { const parser = new UAParser(); return ( -
+
{logs.map((log, index) => { parser.setUA(log.userAgent || ''); const formattedAction = formatDocumentAuditLogAction(i18n, log); const userAgentInfo = parser.getResult(); + const createdAt = DateTime.fromJSDate(log.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale); + + const metaSegments = [ + log.email, + log.ipAddress, + log.userAgent ? _(formatUserAgent(log.userAgent, userAgentInfo)) : null, + ].filter((segment): segment is string => Boolean(segment)); + return ( - 0 ? 'print:mt-8' : ''}`} + className="flex gap-5 py-2.5" style={{ pageBreakInside: 'avoid', breakInside: 'avoid', }} > - - {/* Header Section with indicator, event type, and timestamp */} -
-
-
+
+
{createdAt.toFormat('yyyy-MM-dd')}
-
-
- {log.type.replace(/_/g, ' ')} -
+
{createdAt.toFormat('hh:mm:ss a')}
+
-
- {formattedAction.description} -
-
+
+
{formattedAction.description}
+ + {metaSegments.length > 0 && ( +
+ {metaSegments.join(' · ')}
- -
- {DateTime.fromJSDate(log.createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toLocaleString(dateFormat)} -
-
- -
- - {/* Details Section - Two column layout */} -
-
-
{_(msg`User`)}
- -
{log.email || 'N/A'}
-
- -
-
- {_(msg`IP Address`)} -
- -
{log.ipAddress || 'N/A'}
-
- -
-
- {_(msg`User Agent`)} -
- -
{_(formatUserAgent(log.userAgent, userAgentInfo))}
-
-
- - + )} +
+
); })}
diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx index f2f3612b7..b158236fa 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7,7 +7,6 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find- import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { getTranslations } from '@documenso/lib/utils/i18n'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { EnvelopeType } from '@prisma/client'; @@ -106,82 +105,86 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) { return (
-
-

{_(msg`Audit Log`)}

-
+
+

{_(msg`Audit Log`)}

- - -

- {_(msg`Envelope ID`)} +

{document.title}

+
- {document.envelopeId} -

+
+
+
{_(msg`Envelope ID`)}
-

- {_(msg`Enclosed Document`)} +

{document.envelopeId}
+
- {document.title} -

+
+
{_(msg`Owner`)}
-

- {_(msg`Status`)} +

+ {document.user.name} ({document.user.email}) +
+
- - {_(document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description).toUpperCase()} - -

+
+
{_(msg`Status`)}
-

- {_(msg`Owner`)} +

+ {_(document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description)} +
+
- - {document.user.name} ({document.user.email}) - -

+
+
{_(msg`Time Zone`)}
-

- {_(msg`Created At`)} +

+ {document.documentMeta?.timezone ?? 'N/A'} +
+
- - {DateTime.fromJSDate(document.createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')} - -

+
+
{_(msg`Created At`)}
-

- {_(msg`Last Updated`)} +

+ {DateTime.fromJSDate(document.createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')} +
+
- - {DateTime.fromJSDate(document.updatedAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')} - -

+
+
{_(msg`Last Updated`)}
-

- {_(msg`Time Zone`)} +

+ {DateTime.fromJSDate(document.updatedAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')} +
+
- {document.documentMeta?.timezone ?? 'N/A'} -

+
+
{_(msg`Enclosed Document`)}
-
-

{_(msg`Recipients`)}

+
{document.title}
+
-
    +
    +
    {_(msg`Recipients`)}
    + +
    +
      {document.recipients.map((recipient) => ( -
    • +
    • + {recipient.name} ({recipient.email}) ·{' '} - [{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] - {' '} - {recipient.name} ({recipient.email}) + {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +
    • ))}
    -
    - - + +
+
diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx index d659dac4f..3b37e280c 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -9,11 +9,8 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { getTranslations } from '@documenso/lib/utils/i18n'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; import { EnvelopeType, FieldType, SigningStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { redirect } from 'react-router'; @@ -203,165 +200,178 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps) return (
-
-

{_(msg`Signing Certificate`)}

-
+
+

{_(msg`Signing Certificate`)}

- - - - - - {_(msg`Signer Events`)} - {_(msg`Signature`)} - {_(msg`Details`)} - {/* Security */} - - +

{document.title}

+ - - {document.recipients.map((recipient, i) => { - const logs = getRecipientAuditLogs(recipient.id); - const signature = getRecipientSignatureField(recipient.id); +
+ + + - return ( - - -
{recipient.name}
-
{recipient.email}
-

- {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} -

+
-

- {_(msg`Authentication Level`)}:{' '} - {getAuthenticationLevel(recipient.id)} -

- + + + - - {signature ? ( - <> -
- {signature.signature?.signatureImageAsBase64 && ( - Signature - )} +
+ {document.recipients.map((recipient, i) => { + const logs = getRecipientAuditLogs(recipient.id); + const signature = getRecipientSignatureField(recipient.id); + const isRejected = Boolean(logs.DOCUMENT_RECIPIENT_REJECTED[0]); - {signature.signature?.typedSignature && ( -

- {signature.signature?.typedSignature} -

- )} - + return ( + + + +
+ {_(msg`Signer Events`)} + + {_(msg`Signature`)} + {_(msg`Details`)}
+
{recipient.name}
-

- {_(msg`Signature ID`)}:{' '} - {signature.secondaryId} -

- - ) : ( -

- N/A -

+
{recipient.email}
+ +
+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +
+ +
+
{_(msg`Authentication Level`)}
+ +
+ {getAuthenticationLevel(recipient.id)} +
+
+
+ {signature ? ( +
+ {!isRejected && ( +
+ {signature.signature?.signatureImageAsBase64 && ( + Signature + )} + + {signature.signature?.typedSignature && ( +

{signature.signature?.typedSignature}

+ )} +
)} -

- {_(msg`IP Address`)}:{' '} - - {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)} - -

+
+
{_(msg`Signature ID`)}
-

- {_(msg`Device`)}:{' '} - - {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)} - -

- - - -
-

- {_(msg`Sent`)}:{' '} - - {logs.EMAIL_SENT[0] - ? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : logs.DOCUMENT_SENT[0] - ? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : _(msg`Unknown`)} - -

- -

- {_(msg`Viewed`)}:{' '} - - {logs.DOCUMENT_OPENED[0] - ? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : _(msg`Unknown`)} - -

- - {logs.DOCUMENT_RECIPIENT_REJECTED[0] ? ( -

- {_(msg`Rejected`)}:{' '} - - {logs.DOCUMENT_RECIPIENT_REJECTED[0] - ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : _(msg`Unknown`)} - -

- ) : ( -

- {_(msg`Signed`)}:{' '} - - {logs.DOCUMENT_RECIPIENT_COMPLETED[0] - ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : _(msg`Unknown`)} - -

- )} - -

- {_(msg`Reason`)}:{' '} - - {recipient.signingStatus === SigningStatus.REJECTED - ? recipient.rejectionReason - : _( - isOwner(recipient.email) - ? FRIENDLY_SIGNING_REASONS['__OWNER__'] - : FRIENDLY_SIGNING_REASONS[recipient.role], - )} - -

+
{signature.secondaryId}
-
- - ); - })} - -
-
-
+
+ ) : ( +

{_(msg`N/A`)}

+ )} + +
+
{_(msg`IP Address`)}
+ +
+ {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)} +
+
+ +
+
{_(msg`Device`)}
+ +
+ {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)} +
+
+ + + +
+
+
{_(msg`Sent`)}
+ +
+ {logs.EMAIL_SENT[0] + ? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : logs.DOCUMENT_SENT[0] + ? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} +
+
+ +
+
{_(msg`Viewed`)}
+ +
+ {logs.DOCUMENT_OPENED[0] + ? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} +
+
+ + {logs.DOCUMENT_RECIPIENT_REJECTED[0] ? ( +
+
{_(msg`Rejected`)}
+ +
+ {DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')} +
+
+ ) : ( +
+
{_(msg`Signed`)}
+ +
+ {logs.DOCUMENT_RECIPIENT_COMPLETED[0] + ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} +
+
+ )} + +
+
{_(msg`Reason`)}
+ +
+ {recipient.signingStatus === SigningStatus.REJECTED + ? recipient.rejectionReason + : _( + isOwner(recipient.email) + ? FRIENDLY_SIGNING_REASONS['__OWNER__'] + : FRIENDLY_SIGNING_REASONS[recipient.role], + )} +
+
+
+ + + ); + })} + + {!hidePoweredBy && (
@@ -377,7 +387,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
-

+

{_(msg`Signing certificate provided by`)}:

diff --git a/packages/lib/server-only/pdf/generate-certificate-pdf.ts b/packages/lib/server-only/pdf/generate-certificate-pdf.ts index f3b629e3d..e51749399 100644 --- a/packages/lib/server-only/pdf/generate-certificate-pdf.ts +++ b/packages/lib/server-only/pdf/generate-certificate-pdf.ts @@ -137,6 +137,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti }), envelopeOwner, envelopeId: envelope.id, + envelopeTitle: envelope.title, qrToken: envelope.qrToken, hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false, pageWidth, diff --git a/packages/lib/server-only/pdf/render-audit-logs.ts b/packages/lib/server-only/pdf/render-audit-logs.ts index faf982f55..36ae9269b 100644 --- a/packages/lib/server-only/pdf/render-audit-logs.ts +++ b/packages/lib/server-only/pdf/render-audit-logs.ts @@ -5,18 +5,15 @@ import Konva from 'konva'; import 'konva/skia-backend'; import fs from 'node:fs'; import path from 'node:path'; -import type { DateTimeFormatOptions } from 'luxon'; import { DateTime } from 'luxon'; import type { Canvas } from 'skia-canvas'; import { Image as SkiaImage } from 'skia-canvas'; -import { match, P } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { DOCUMENT_STATUS } from '../../constants/document'; import { APP_I18N_OPTIONS } from '../../constants/i18n'; import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; import type { TDocumentAuditLog } from '../../types/document-audit-logs'; -import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs'; import { ensureFontLibrary } from './helpers'; @@ -46,126 +43,165 @@ type GenerateAuditLogsOptions = { const parser = new UAParser(); -const textMutedForegroundLight = '#929DAE'; const textForeground = '#000'; const textMutedForeground = '#64748B'; -const textBase = 10; -const textSm = 9; -const textXs = 8; +const textMutedForegroundLight = '#929DAE'; +const hairlineColor = '#E5E7EB'; + const fontMedium = '500'; +const fontSemibold = '600'; + +const textXs = 8; +const textSm = 9.5; + +const pageMarginX = 48; +const pageTopMargin = 56; +const pageBottomMargin = 64; +const contentMaxWidth = 640; -const pageTopMargin = 60; -const pageBottomMargin = 27; -const contentMaxWidth = 768; -const rowPadding = 10; const titleFontSize = 18; +const headerGap = 24; +const sectionGap = 28; -type RenderOverviewCardLabelAndTextOptions = { +const timeColumnWidth = 88; +const rowColumnGap = 20; +const rowVerticalPadding = 10; + +type RenderedRow = { + group: Konva.Group; + height: number; +}; + +type RenderFieldOptions = { label: string; text: string | string[]; width: number; - groupX?: number; + x?: number; + wrapChar?: boolean; }; -const renderOverviewCardLabels = (options: RenderOverviewCardLabelAndTextOptions) => { - const { width, text } = options; - - const labelYSpacing = 4; +/** + * A single description-list field: a small muted label above its value(s). + */ +const renderField = (options: RenderFieldOptions) => { + const { label, text, width, x, wrapChar } = options; const group = new Konva.Group({ - x: options.groupX ?? 0, + x: x ?? 0, }); - const label = new Konva.Text({ - x: 0, - y: 0, - text: options.label, - fontStyle: fontMedium, + const labelText = new Konva.Text({ + text: label, fontFamily: 'Inter', - fill: textForeground, - fontSize: textSm, + fontSize: textXs, + fontStyle: fontMedium, + fill: textMutedForeground, }); - group.add(label); + group.add(labelText); - if (typeof text === 'string') { - const value = new Konva.Text({ - x: 0, - y: label.height() + labelYSpacing, - width: width - label.width(), + const values = typeof text === 'string' ? [text] : text; + + let y = labelText.height() + 5; + + for (const value of values) { + const valueText = new Konva.Text({ + y, + width, + text: value, fontFamily: 'Inter', - text, - fill: textForeground, - wrap: 'char', fontSize: textSm, + lineHeight: 1.4, + fill: textForeground, + wrap: wrapChar ? 'char' : 'word', }); - group.add(value); - } else { - for (const textValue of text) { - const value = new Konva.Text({ - x: 0, - y: group.getClientRect().height + 4, - width: width - label.width(), - fontFamily: 'Inter', - text: '• ' + textValue, - fill: textForeground, - wrap: 'char', - fontSize: textSm, - }); + group.add(valueText); - group.add(value); - } + y += valueText.height() + 3; } return group; }; -type RenderVerticalLabelAndTextOptions = { - label: string; - text: string; - width?: number; - align?: 'left' | 'right'; - x?: number; - y?: number; - textFontFamily?: string; +type RenderDocumentHeaderOptions = { + envelope: Omit & { + documentMeta: DocumentMeta; + }; + width: number; + i18n: I18n; }; -const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions) => { - const { label, text, width, align, x, y, textFontFamily } = options; +/** + * First page header: title with the document title underneath. + */ +const renderDocumentHeader = (options: RenderDocumentHeaderOptions) => { + const { envelope, width, i18n } = options; - const group = new Konva.Group({ - x: x ?? 0, - y: y ?? 0, - }); + const group = new Konva.Group(); - const konvaLabel = new Konva.Text({ - align: align ?? 'left', + const title = new Konva.Text({ + text: i18n._(msg`Audit Log`), fontFamily: 'Inter', - width, - text: label, - fontSize: textXs, - fill: textMutedForegroundLight, - }); - - group.add(konvaLabel); - - const konvaText = new Konva.Text({ - y: group.getClientRect().height + 6, - align: align ?? 'left', - fontFamily: textFontFamily ?? 'Inter', - width, - text: text, - fontSize: textXs, + fontSize: titleFontSize, + fontStyle: fontSemibold, + letterSpacing: -0.2, fill: textForeground, }); - group.add(konvaText); + group.add(title); + + const subtitle = new Konva.Text({ + y: title.height() + 6, + width, + text: envelope.title, + fontFamily: 'Inter', + fontSize: textSm, + lineHeight: 1.4, + fill: textMutedForeground, + }); + + group.add(subtitle); return group; }; -type RenderOverviewCardOptions = { +type RenderContinuationHeaderOptions = { + width: number; + i18n: I18n; +}; + +/** + * Compact running header for pages after the first. + */ +const renderContinuationHeader = (options: RenderContinuationHeaderOptions) => { + const { width, i18n } = options; + + const group = new Konva.Group(); + + const title = new Konva.Text({ + text: i18n._(msg`Audit Log`), + fontFamily: 'Inter', + fontSize: textSm, + fontStyle: fontMedium, + fill: textMutedForeground, + }); + + group.add(title); + + const rule = new Konva.Line({ + points: [0, 0, width, 0], + stroke: hairlineColor, + strokeWidth: 1, + y: title.height() + 10, + }); + + group.add(rule); + + return group; +}; + +type RenderOverviewOptions = { envelope: Omit & { documentMeta: DocumentMeta; }; @@ -179,122 +215,93 @@ type RenderOverviewCardOptions = { i18n: I18n; }; -const renderOverviewCard = (options: RenderOverviewCardOptions) => { +/** + * Borderless two column description list of the envelope metadata. + */ +const renderOverview = (options: RenderOverviewOptions) => { const { envelope, envelopeItems, envelopeOwner, recipients, width, i18n } = options; - const cardPadding = 16; - const overviewCard = new Konva.Group(); + const columnGap = 32; + const columnWidth = (width - columnGap) / 2; + const rowGap = 18; - const columnSpacing = 10; - const columnWidth = (width - columnSpacing) / 2; - const rowVerticalSpacing = 32; + const group = new Konva.Group(); - const rowOne = new Konva.Group({ - x: cardPadding, - y: cardPadding, - }); + const fieldPairs: [RenderFieldOptions, RenderFieldOptions][] = [ + [ + { + label: i18n._(msg`Envelope ID`), + text: envelope.id, + width: columnWidth, + wrapChar: true, + }, + { + label: i18n._(msg`Owner`), + text: `${envelopeOwner.name} (${envelopeOwner.email})`, + width: columnWidth, + }, + ], + [ + { + label: i18n._(msg`Status`), + text: i18n._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description), + width: columnWidth, + }, + { + label: i18n._(msg`Time Zone`), + text: envelope.documentMeta?.timezone || 'N/A', + width: columnWidth, + }, + ], + [ + { + label: i18n._(msg`Created At`), + text: DateTime.fromJSDate(envelope.createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), + width: columnWidth, + }, + { + label: i18n._(msg`Last Updated`), + text: DateTime.fromJSDate(envelope.updatedAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), + width: columnWidth, + }, + ], + [ + { + label: i18n._(msg`Enclosed Documents`), + text: envelopeItems, + width: columnWidth, + }, + { + label: i18n._(msg`Recipients`), + text: recipients.map( + (recipient) => + `${recipient.name} (${recipient.email}) · ${i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}`, + ), + width: columnWidth, + }, + ], + ]; - const envelopeIdLabel = renderOverviewCardLabels({ - label: i18n._(msg`Envelope ID`), - text: envelope.id, - width: columnWidth, - }); - const ownerLabel = renderOverviewCardLabels({ - label: i18n._(msg`Owner`), - text: `${envelopeOwner.name} (${envelopeOwner.email})`, - width: columnWidth, - groupX: columnWidth + columnSpacing, - }); + let y = 0; - rowOne.add(envelopeIdLabel); - rowOne.add(ownerLabel); - overviewCard.add(rowOne); + for (const [left, right] of fieldPairs) { + const leftField = renderField(left); + const rightField = renderField({ ...right, x: columnWidth + columnGap }); - const rowTwo = new Konva.Group({ - x: cardPadding, - y: overviewCard.getClientRect().height + rowVerticalSpacing, - }); + leftField.y(y); + rightField.y(y); - const statusLabel = renderOverviewCardLabels({ - label: i18n._(msg`Status`), - text: i18n._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description).toUpperCase(), - width: columnWidth, - }); - const timeZoneLabel = renderOverviewCardLabels({ - label: i18n._(msg`Time Zone`), - text: envelope.documentMeta?.timezone || 'N/A', - width: columnWidth, - groupX: columnWidth + columnSpacing, - }); + group.add(leftField); + group.add(rightField); - rowTwo.add(statusLabel); - rowTwo.add(timeZoneLabel); - overviewCard.add(rowTwo); + y += Math.max(leftField.getClientRect().height, rightField.getClientRect().height) + rowGap; + } - const rowThree = new Konva.Group({ - x: cardPadding, - y: overviewCard.getClientRect().height + rowVerticalSpacing, - }); - - const createdAtLabel = renderOverviewCardLabels({ - label: i18n._(msg`Created At`), - text: DateTime.fromJSDate(envelope.createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), - width: columnWidth, - }); - const lastUpdatedLabel = renderOverviewCardLabels({ - label: i18n._(msg`Last Updated`), - text: DateTime.fromJSDate(envelope.updatedAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), - width: columnWidth, - groupX: columnWidth + columnSpacing, - }); - - rowThree.add(createdAtLabel); - rowThree.add(lastUpdatedLabel); - overviewCard.add(rowThree); - - const rowFour = new Konva.Group({ - x: cardPadding, - y: overviewCard.getClientRect().height + rowVerticalSpacing, - }); - - const enclosedDocumentsLabel = renderOverviewCardLabels({ - label: i18n._(msg`Enclosed Documents`), - text: envelopeItems, - width: columnWidth, - }); - - const recipientsLabel = renderOverviewCardLabels({ - label: i18n._(msg`Recipients`), - text: recipients.map( - (recipient) => - `[${i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] ${recipient.name} (${recipient.email})`, - ), - width: columnWidth, - groupX: columnWidth + columnSpacing, - }); - - rowFour.add(enclosedDocumentsLabel); - rowFour.add(recipientsLabel); - overviewCard.add(rowFour); - - // Create rect border around the overview card - const cardRect = new Konva.Rect({ - x: 0, - y: 0, - width, - height: overviewCard.getClientRect().height + cardPadding * 2, - stroke: '#e5e7eb', - strokeWidth: 1.5, - cornerRadius: 8, - }); - - overviewCard.add(cardRect); - - return overviewCard; + return group; }; type RenderRowOptions = { @@ -303,136 +310,86 @@ type RenderRowOptions = { i18n: I18n; }; -const renderRow = (options: RenderRowOptions) => { +/** + * A single audit event: timestamp column beside the event description with a + * muted actor line (email · IP address · device) underneath. + */ +const renderRow = (options: RenderRowOptions): RenderedRow => { const { auditLog, width, i18n } = options; - const paddingWithinCard = 12; + const group = new Konva.Group(); - const columnSpacing = 10; - const columnWidth = (width - paddingWithinCard * 2 - columnSpacing) / 2; + const eventX = timeColumnWidth + rowColumnGap; + const eventWidth = width - eventX; - const indicatorWidth = 3; - const indicatorPaddingRight = 10; - const rowGroup = new Konva.Group(); + const createdAt = DateTime.fromJSDate(auditLog.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale); - const rowHeaderGroup = new Konva.Group(); - - const auditLogIndicatorColor = new Konva.Circle({ - x: indicatorWidth, - y: indicatorWidth + 3, - radius: indicatorWidth, - fill: getAuditLogIndicatorColor(auditLog.type), - }); - - const auditLogTypeText = new Konva.Text({ - x: indicatorWidth + indicatorPaddingRight, - y: 0, - width: columnWidth - indicatorWidth - indicatorPaddingRight, - text: auditLog.type.replace(/_/g, ' '), + const dateText = new Konva.Text({ + y: rowVerticalPadding, + width: timeColumnWidth, + text: createdAt.toFormat('yyyy-MM-dd'), fontFamily: 'Inter', - fontSize: textSm, - fontStyle: fontMedium, - fill: textMutedForeground, - }); - - const auditLogDescriptionText = new Konva.Text({ - x: indicatorWidth + indicatorPaddingRight, - y: auditLogTypeText.height() + 4, - width: columnWidth - indicatorWidth - indicatorPaddingRight, - text: formatDocumentAuditLogAction(i18n, auditLog).description, - fontFamily: 'Inter', - fontSize: textSm, + fontSize: textXs + 0.5, fill: textForeground, }); - const auditLogTimestampText = new Konva.Text({ - x: columnWidth + columnSpacing, - width: columnWidth, - text: DateTime.fromJSDate(auditLog.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale).toLocaleString(dateFormat), + const timeText = new Konva.Text({ + y: dateText.y() + dateText.height() + 3, + width: timeColumnWidth, + text: createdAt.toFormat('hh:mm:ss a'), fontFamily: 'Inter', - align: 'right', - fontSize: textSm, + fontSize: textXs - 0.5, fill: textMutedForeground, }); - rowHeaderGroup.add(auditLogIndicatorColor); - rowHeaderGroup.add(auditLogTypeText); - rowHeaderGroup.add(auditLogDescriptionText); - rowHeaderGroup.add(auditLogTimestampText); + group.add(dateText); + group.add(timeText); - rowHeaderGroup.setAttrs({ - x: paddingWithinCard, - y: paddingWithinCard, - } satisfies Partial); - - rowGroup.add(rowHeaderGroup); - - // Draw border line. - const borderLine = new Konva.Line({ - points: [0, 0, width - paddingWithinCard * 2, 0], - stroke: '#e5e7eb', - strokeWidth: 1, - x: paddingWithinCard, - y: rowGroup.getClientRect().height + paddingWithinCard + 12, + const descriptionText = new Konva.Text({ + x: eventX, + y: rowVerticalPadding, + width: eventWidth, + text: formatDocumentAuditLogAction(i18n, auditLog).description, + fontFamily: 'Inter', + fontSize: textSm, + lineHeight: 1.35, + fill: textForeground, }); - rowGroup.add(borderLine); + group.add(descriptionText); - const bottomSection = new Konva.Group({ - x: paddingWithinCard, - y: rowGroup.getClientRect().height + paddingWithinCard + 12, - }); - - // Row 1 Column 1 - const userLabel = renderVerticalLabelAndText({ - label: i18n._(msg`User`).toUpperCase(), - text: auditLog.email || 'N/A', - align: 'left', - width: columnWidth, - textFontFamily: 'ui-monospace', - }); - - // Row 1 Column 2 - const ipAddressLabel = renderVerticalLabelAndText({ - label: i18n._(msg`IP Address`).toUpperCase(), - text: auditLog.ipAddress || 'N/A', - align: 'right', - x: columnWidth + columnSpacing, - width: columnWidth, - textFontFamily: 'ui-monospace', - }); - - bottomSection.add(userLabel); - bottomSection.add(ipAddressLabel); + let eventBottom = descriptionText.y() + descriptionText.height(); parser.setUA(auditLog.userAgent || ''); const userAgentInfo = parser.getResult(); - // Row 2 Column 1 - const userAgentLabel = renderVerticalLabelAndText({ - label: i18n._(msg`User Agent`).toUpperCase(), - text: i18n._(formatUserAgent(auditLog.userAgent, userAgentInfo)), - align: 'left', - width, - y: bottomSection.getClientRect().height + 16, - }); + const metaSegments = [ + auditLog.email, + auditLog.ipAddress, + auditLog.userAgent ? i18n._(formatUserAgent(auditLog.userAgent, userAgentInfo)) : null, + ].filter((segment): segment is string => Boolean(segment)); - bottomSection.add(userAgentLabel); - rowGroup.add(bottomSection); + if (metaSegments.length > 0) { + const metaText = new Konva.Text({ + x: eventX, + y: eventBottom + 4, + width: eventWidth, + text: metaSegments.join(' · '), + fontFamily: 'Inter', + fontSize: textXs, + lineHeight: 1.35, + fill: textMutedForeground, + }); - const cardRect = new Konva.Rect({ - x: 0, - y: 0, - width: rowGroup.getClientRect().width, - height: rowGroup.getClientRect().height + paddingWithinCard * 2, - stroke: '#e5e7eb', - strokeWidth: 1, - cornerRadius: 8, - }); + group.add(metaText); - rowGroup.add(cardRect); + eventBottom = metaText.y() + metaText.height(); + } - return rowGroup; + const timeBottom = timeText.y() + timeText.height(); + const height = Math.max(eventBottom, timeBottom) + rowVerticalPadding; + + return { group, height }; }; const renderBranding = () => { @@ -461,103 +418,38 @@ type GroupRowsIntoPagesOptions = { maxHeight: number; contentWidth: number; i18n: I18n; - overviewCard: Konva.Group; + firstPageContentHeight: number; + continuationHeaderHeight: number; }; const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => { - const { auditLogs, maxHeight, contentWidth, i18n, overviewCard } = options; + const { auditLogs, maxHeight, contentWidth, i18n, firstPageContentHeight, continuationHeaderHeight } = options; - const groupedRows: Konva.Group[][] = [[]]; + const groupedRows: RenderedRow[][] = [[]]; - const overviewCardHeight = overviewCard.getClientRect().height; - - // First page has title + overview card - let availableHeight = maxHeight - pageTopMargin - overviewCardHeight; + // First page has the document header and overview above the rows. + let availableHeight = maxHeight - pageTopMargin - firstPageContentHeight; let currentGroupedRowIndex = 0; - // Group rows into pages. for (const auditLog of auditLogs) { const row = renderRow({ auditLog, width: contentWidth, i18n }); - const rowHeight = row.getClientRect().height; - const requiredHeight = rowHeight + rowPadding; - - if (requiredHeight > availableHeight) { + if (row.height > availableHeight && groupedRows[currentGroupedRowIndex].length > 0) { currentGroupedRowIndex++; - groupedRows[currentGroupedRowIndex] = [row]; + groupedRows[currentGroupedRowIndex] = []; - // Subsequent pages only have title (no overview card) - availableHeight = maxHeight - pageTopMargin; - } else { - groupedRows[currentGroupedRowIndex].push(row); + // Subsequent pages only have the compact running header. + availableHeight = maxHeight - pageTopMargin - continuationHeaderHeight; } - // Reduce available height by the row height. - availableHeight -= requiredHeight; + groupedRows[currentGroupedRowIndex].push(row); + + availableHeight -= row.height; } return groupedRows; }; -type RenderPagesOptions = { - groupedRows: Konva.Group[][]; - margin: number; - pageTopMargin: number; - i18n: I18n; - overviewCard: Konva.Group; -}; - -const renderPages = (options: RenderPagesOptions) => { - const { groupedRows, margin, pageTopMargin, i18n, overviewCard } = options; - - const rowPadding = 10; - const pages: Konva.Group[] = []; - - // Render the rows for each page. - for (const [pageIndex, rows] of groupedRows.entries()) { - const pageGroup = new Konva.Group(); - - // Add title to each page - const pageTitle = new Konva.Text({ - x: margin, - y: 0, - height: pageTopMargin, - verticalAlign: 'middle', - text: i18n._(msg`Audit Log`), - fill: textForeground, - fontFamily: 'Inter', - fontSize: titleFontSize, - fontStyle: '700', - }); - pageGroup.add(pageTitle); - - // Add overview card only on first page - if (pageIndex === 0) { - overviewCard.setAttrs({ - x: margin, - y: pageGroup.getClientRect().height, - }); - pageGroup.add(overviewCard); - } - - // Add rows to the page - for (const row of rows) { - const yPosition = pageGroup.getClientRect().height + rowPadding; - - row.setAttrs({ - x: margin, - y: yPosition, - }); - - pageGroup.add(row); - } - - pages.push(pageGroup); - } - - return pages; -}; - export async function renderAuditLogs({ envelope, envelopeOwner, @@ -571,76 +463,150 @@ export async function renderAuditLogs({ }: GenerateAuditLogsOptions) { ensureFontLibrary(); - const minimumMargin = 10; - - const contentWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth); + const contentWidth = Math.min(pageWidth - pageMarginX * 2, contentMaxWidth); const margin = (pageWidth - contentWidth) / 2; let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight }); - const overviewCard = renderOverviewCard({ + const documentHeader = renderDocumentHeader({ envelope, width: contentWidth, i18n }); + const overview = renderOverview({ envelope, - envelopeOwner, envelopeItems, + envelopeOwner, recipients, width: contentWidth, i18n, }); + const documentHeaderHeight = documentHeader.getClientRect().height; + const overviewHeight = overview.getClientRect().height; + const firstPageContentHeight = documentHeaderHeight + headerGap + overviewHeight + sectionGap; + + const continuationHeader = renderContinuationHeader({ width: contentWidth, i18n }); + const continuationHeaderHeight = continuationHeader.getClientRect().height + headerGap; + const groupedRows = groupRowsIntoPages({ auditLogs, maxHeight: pageHeight - pageBottomMargin, contentWidth, i18n, - overviewCard, + firstPageContentHeight, + continuationHeaderHeight, }); - const pageGroups = renderPages({ - groupedRows, - margin, - pageTopMargin, - i18n, - overviewCard, - }); + // Assemble the content group for each page so heights are known before + // footers and branding are placed. + const pageGroups: Konva.Group[] = []; - const brandingGroup = renderBranding(); - const brandingRect = brandingGroup.getClientRect(); + for (const [pageIndex, rows] of groupedRows.entries()) { + const pageGroup = new Konva.Group({ + x: margin, + y: pageTopMargin, + }); + + let yCursor = 0; + + if (pageIndex === 0) { + pageGroup.add(documentHeader); + yCursor += documentHeaderHeight + headerGap; + + overview.y(yCursor); + pageGroup.add(overview); + yCursor += overviewHeight + sectionGap; + } else { + const header = renderContinuationHeader({ width: contentWidth, i18n }); + pageGroup.add(header); + yCursor += header.getClientRect().height + headerGap; + } + + for (const [rowIndex, row] of rows.entries()) { + if (rowIndex > 0) { + const separator = new Konva.Line({ + points: [0, 0, contentWidth, 0], + stroke: hairlineColor, + strokeWidth: 0.75, + y: yCursor, + }); + + pageGroup.add(separator); + } + + row.group.y(yCursor); + pageGroup.add(row.group); + + yCursor += row.height; + } + + pageGroups.push(pageGroup); + } + + const brandingGroup = !hidePoweredBy ? renderBranding() : null; const brandingTopPadding = 24; + // Work out whether the branding fits below the content of the last page, or + // whether it needs a page of its own, so the total page count is known + // before rendering footers. + let isBrandingPlacedOnLastPage = false; + + if (brandingGroup) { + const lastPageGroup = pageGroups[pageGroups.length - 1]; + const lastPageContentBottom = lastPageGroup.y() + lastPageGroup.getClientRect().height; + const brandingRect = brandingGroup.getClientRect(); + + isBrandingPlacedOnLastPage = + lastPageContentBottom + brandingTopPadding + brandingRect.height <= pageHeight - pageBottomMargin; + } + + const totalPages = pageGroups.length + (brandingGroup && !isBrandingPlacedOnLastPage ? 1 : 0); + + const renderFooter = (page: Konva.Layer, pageNumber: number) => { + const footerY = pageHeight - pageBottomMargin + 24; + + const envelopeIdText = new Konva.Text({ + x: margin, + y: footerY, + width: contentWidth, + text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`, + fontFamily: 'Inter', + fontSize: textXs - 0.5, + fill: textMutedForegroundLight, + }); + + const pageNumberText = new Konva.Text({ + x: margin, + y: footerY, + width: contentWidth, + align: 'right', + text: `${pageNumber} / ${totalPages}`, + fontFamily: 'Inter', + fontSize: textXs - 0.5, + fill: textMutedForegroundLight, + }); + + page.add(envelopeIdText); + page.add(pageNumberText); + }; + const pages: Uint8Array[] = []; - let isBrandingPlaced = false; - - // Render each page group to PDF for (const [index, pageGroup] of pageGroups.entries()) { stage.destroyChildren(); const page = new Konva.Layer(); - const footerText = new Konva.Text({ - x: margin, - y: pageHeight - textXs - 10, - text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`, - fontFamily: 'Inter', - fontSize: textXs, - fill: textMutedForegroundLight, - }); - page.add(footerText); - page.add(pageGroup); + renderFooter(page, index + 1); - // Add branding on the last page if there is space. - if (index === pageGroups.length - 1 && !hidePoweredBy) { - const remainingHeight = pageHeight - pageGroup.getClientRect().height - pageBottomMargin; + if (brandingGroup && isBrandingPlacedOnLastPage && index === pageGroups.length - 1) { + const brandingRect = brandingGroup.getClientRect(); - if (brandingRect.height + brandingTopPadding <= remainingHeight) { - brandingGroup.setAttrs({ - x: pageWidth - brandingRect.width - margin, - y: pageGroup.getClientRect().height + brandingTopPadding, - } satisfies Partial); + // Anchor the branding to the bottom of the page rather than letting it + // float directly under the content. + brandingGroup.setAttrs({ + x: pageWidth - brandingRect.width - margin, + y: pageHeight - pageBottomMargin - brandingRect.height, + } satisfies Partial); - page.add(brandingGroup); - isBrandingPlaced = true; - } + page.add(brandingGroup); } stage.add(page); @@ -651,27 +617,21 @@ export async function renderAuditLogs({ pages.push(new Uint8Array(buffer)); } - // Need to create an empty page for the branding if it hasn't been placed yet. - if (!hidePoweredBy && !isBrandingPlaced) { + // Branding gets a page of its own when it does not fit under the last page. + if (brandingGroup && !isBrandingPlacedOnLastPage) { stage.destroyChildren(); const page = new Konva.Layer(); + const brandingRect = brandingGroup.getClientRect(); + brandingGroup.setAttrs({ x: pageWidth - brandingRect.width - margin, - y: pageTopMargin, + y: pageHeight - pageBottomMargin - brandingRect.height, } satisfies Partial); - const overflowFooterText = new Konva.Text({ - x: margin, - y: pageHeight - textXs - 10, - text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`, - fontFamily: 'Inter', - fontSize: textXs, - fill: textMutedForegroundLight, - }); - page.add(overflowFooterText); - page.add(brandingGroup); + renderFooter(page, totalPages); + stage.add(page); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -687,30 +647,7 @@ export async function renderAuditLogs({ return pages; } -const dateFormat: DateTimeFormatOptions = { - ...DateTime.DATETIME_SHORT, - hourCycle: 'h12', -}; - -/** - * Get the color indicator for the audit log type - */ -const getAuditLogIndicatorColor = (type: string) => - match(type) - .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => '#22c55e') // bg-green-500 - .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => '#ef4444') // bg-red-500 - .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => '#f97316') // bg-orange-500 - .with( - P.union(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED), - () => '#3b82f6', // bg-blue-500 - ) - .otherwise(() => '#f1f5f9'); // bg-muted - -const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => { - if (!userAgent) { - return msg`N/A`; - } - +const formatUserAgent = (userAgent: string, userAgentInfo: UAParser.IResult) => { const browser = userAgentInfo.browser.name; const version = userAgentInfo.browser.version; const os = userAgentInfo.os.name; diff --git a/packages/lib/server-only/pdf/render-certificate.ts b/packages/lib/server-only/pdf/render-certificate.ts index a30d265ee..b6f790343 100644 --- a/packages/lib/server-only/pdf/render-certificate.ts +++ b/packages/lib/server-only/pdf/render-certificate.ts @@ -47,6 +47,7 @@ export type CertificateRecipient = { type GenerateCertificateOptions = { recipients: CertificateRecipient[]; envelopeId: string; + envelopeTitle: string; qrToken: string | null; hidePoweredBy: boolean; i18n: I18n; @@ -73,122 +74,208 @@ const getDevice = (userAgent?: string | null): string => { 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 textMutedForegroundLight = '#929DAE'; +const textRejectedRed = '#DC2626'; +const hairlineColor = '#E5E7EB'; -const columnWidthPercentages = [30, 30, 40]; -const rowPadding = 12; -const tableHeaderHeight = 38; -const pageTopMargin = 72; -const pageBottomMargin = 24; -const contentMaxWidth = 768; +const fontMedium = '500'; +const fontSemibold = '600'; + +const textXs = 8; +const textSm = 9.5; + +const pageMarginX = 48; +const pageTopMargin = 56; +const pageBottomMargin = 64; +const contentMaxWidth = 640; const titleFontSize = 18; +const headerGap = 24; -type RenderLabelAndTextOptions = { +const columnWidthPercentages = [30, 30, 40]; +const columnGap = 16; +const rowVerticalPadding = 12; +const fieldGap = 10; + +/** + * Formats a timestamp consistently across the certificate. + */ +const formatTimestamp = (date: Date) => + DateTime.fromJSDate(date).setLocale(APP_I18N_OPTIONS.defaultLocale).toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'); + +type RenderedRow = { + group: Konva.Group; + height: number; +}; + +type RenderFieldOptions = { label: string; text: string; width: number; y?: number; labelFill?: string; valueFill?: string; + valueFontFamily?: string; + wrapChar?: boolean; }; -const renderLabelAndText = (options: RenderLabelAndTextOptions) => { - const { width, y } = options; +/** + * A single description-list field: a small muted label above its value. + */ +const renderField = (options: RenderFieldOptions) => { + const { label, text, width, y, labelFill, valueFill, valueFontFamily, wrapChar } = options; const group = new Konva.Group({ - y, + y: y ?? 0, }); - const labelFill = options.labelFill ?? textMutedForeground; - const valueFill = options.valueFill ?? textMutedForeground; - - const label = new Konva.Text({ - x: 0, - y: 0, - text: `${options.label}: `, + const labelText = new Konva.Text({ + text: label, + fontFamily: 'Inter', + fontSize: textXs, fontStyle: fontMedium, - fontFamily: 'Inter', - fill: labelFill, - fontSize: textSm, + fill: labelFill ?? textMutedForeground, }); - group.add(label); + group.add(labelText); - 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, + const valueText = new Konva.Text({ + y: labelText.height() + 4, + width, + text, + fontFamily: valueFontFamily ?? 'Inter', + fontSize: textXs + 0.5, + lineHeight: 1.35, + fill: valueFill ?? textForeground, + wrap: wrapChar ? 'char' : 'word', }); - group.add(value); + group.add(valueText); return group; }; -type RenderRowHeaderOptions = { - columnWidths: number[]; +type RenderDocumentHeaderOptions = { + envelopeTitle: string; + width: number; i18n: I18n; }; -const renderRowHeader = (options: RenderRowHeaderOptions) => { - const { columnWidths, i18n } = options; +/** + * First page header: title with the document title underneath. + */ +const renderDocumentHeader = (options: RenderDocumentHeaderOptions) => { + const { envelopeTitle, width, i18n } = options; - const columnOneWidth = columnWidths[0]; - const columnTwoWidth = columnWidths[1]; - const columnThreeWidth = columnWidths[2]; + const group = new Konva.Group(); - const headerRow = new Konva.Group(); - - const headerFontStyling = { + const title = new Konva.Text({ + text: i18n._(msg`Signing Certificate`), fontFamily: 'Inter', - fontSize: 11, - fontStyle: fontMedium, - verticalAlign: 'middle', + fontSize: titleFontSize, + fontStyle: fontSemibold, + letterSpacing: -0.2, + fill: textForeground, + }); + + group.add(title); + + const subtitle = new Konva.Text({ + y: title.height() + 6, + width, + text: envelopeTitle, + fontFamily: 'Inter', + fontSize: textSm, + lineHeight: 1.4, 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); + group.add(subtitle); - const header3 = new Konva.Text({ - x: columnOneWidth + columnTwoWidth + rowPadding, - width: columnThreeWidth, - text: i18n._(msg`Details`), - ...headerFontStyling, - }); - headerRow.add(header3); - - return headerRow; + return group; }; -const columnPadding = 10; +type RenderContinuationHeaderOptions = { + width: number; + i18n: I18n; +}; + +/** + * Compact running header for pages after the first. + */ +const renderContinuationHeader = (options: RenderContinuationHeaderOptions) => { + const { width, i18n } = options; + + const group = new Konva.Group(); + + const title = new Konva.Text({ + text: i18n._(msg`Signing Certificate`), + fontFamily: 'Inter', + fontSize: textSm, + fontStyle: fontMedium, + fill: textMutedForeground, + }); + + group.add(title); + + const rule = new Konva.Line({ + points: [0, 0, width, 0], + stroke: hairlineColor, + strokeWidth: 1, + y: title.height() + 10, + }); + + group.add(rule); + + return group; +}; + +type RenderTableHeaderOptions = { + columnWidths: ColumnWidths; + i18n: I18n; +}; + +/** + * Column labels for the recipients table with a hairline rule underneath. + */ +const renderTableHeader = (options: RenderTableHeaderOptions) => { + const { columnWidths, i18n } = options; + + const group = new Konva.Group(); + + const labels = [i18n._(msg`Signer Events`), i18n._(msg`Signature`), i18n._(msg`Details`)]; + + let x = 0; + + for (const [index, label] of labels.entries()) { + const labelText = new Konva.Text({ + x, + width: columnWidths[index] - columnGap, + text: label, + fontFamily: 'Inter', + fontSize: textXs, + fontStyle: fontMedium, + fill: textMutedForeground, + }); + + group.add(labelText); + + x += columnWidths[index]; + } + + const rule = new Konva.Line({ + points: [0, 0, columnWidths[0] + columnWidths[1] + columnWidths[2], 0], + stroke: hairlineColor, + strokeWidth: 1, + y: group.getClientRect().height + 8, + }); + + group.add(rule); + + return group; +}; type RenderColumnOptions = { recipient: CertificateRecipient; @@ -203,76 +290,64 @@ type RenderColumnOptions = { 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, - }; + const column = new Konva.Group(); if (recipient.name) { const nameText = new Konva.Text({ - y: 0, + width, text: recipient.name, - fontSize: textBase, - ...textFontStyling, + fontFamily: 'Inter', + fontSize: textSm, fontStyle: fontMedium, + lineHeight: 1.35, + fill: textForeground, }); - columnGroup.add(nameText); + column.add(nameText); } const emailText = new Konva.Text({ - y: columnGroup.getClientRect().height, + y: column.getClientRect().height + (recipient.name ? 2 : 0), + width, text: recipient.email, - fontSize: textBase, - ...textFontStyling, + fontFamily: 'Inter', + fontSize: textXs, + lineHeight: 1.35, + fill: textMutedForeground, + wrap: 'char', }); - columnGroup.add(emailText); + column.add(emailText); const roleText = new Konva.Text({ - y: columnGroup.getClientRect().height + textSectionPadding, + y: emailText.y() + emailText.height() + 3, + width, text: i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName), - fontSize: textSm, - ...textFontStyling, + fontFamily: 'Inter', + fontSize: textXs, + lineHeight: 1.35, + fill: textMutedForeground, }); - 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); + column.add(roleText); - const authValue = new Konva.Text({ - y: columnGroup.getClientRect().height, + const authField = renderField({ + label: i18n._(msg`Authentication Level`), text: recipient.authLevel, - fontSize: textSm, - ...textFontStyling, + width, + y: roleText.y() + roleText.height() + fieldGap, }); - columnGroup.add(authValue); - return columnGroup; + column.add(authField); + + return column; }; 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) { @@ -348,60 +423,46 @@ const renderColumnTwo = (options: RenderColumnOptions) => { }); 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, + const signatureIdField = renderField({ + label: i18n._(msg`Signature ID`), text: recipient.signatureField.secondaryId.toUpperCase(), - fill: textMutedForeground, - fontFamily: 'monospace', - fontSize: textSm, - width: columnWidth, - wrap: 'char', + width, + y: isRejected ? 0 : signatureHeight + fieldGap, + valueFontFamily: 'monospace', + wrapChar: true, }); - column.add(sigIdValue); + + column.add(signatureIdField); } else { const naText = new Konva.Text({ - x: 0, - y: 0, - text: 'N/A', + text: i18n._(msg`N/A`), fill: textMutedForeground, fontFamily: 'Inter', - fontSize: textSm, + fontSize: textXs + 0.5, }); + column.add(naText); } const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed; - const ipLabelAndText = renderLabelAndText({ + const ipField = renderField({ label: i18n._(msg`IP Address`), text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`), width, - y: column.getClientRect().height + 6, + y: column.getClientRect().height + fieldGap, }); - column.add(ipLabelAndText); - const deviceLabelAndText = renderLabelAndText({ + column.add(ipField); + + const deviceField = renderField({ label: i18n._(msg`Device`), text: getDevice(relevantLog?.userAgent), width, - y: column.getClientRect().height + 6, + y: column.getClientRect().height + fieldGap, }); - column.add(deviceLabelAndText); + + column.add(deviceField); return column; }; @@ -422,42 +483,28 @@ const renderColumnThree = (options: RenderColumnOptions) => { { 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)') + ? formatTimestamp(recipient.logs.emailed.createdAt) : recipient.logs.sent - ? DateTime.fromJSDate(recipient.logs.sent.createdAt) - .setLocale(APP_I18N_OPTIONS.defaultLocale) - .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + ? formatTimestamp(recipient.logs.sent.createdAt) : 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`), + value: recipient.logs.opened ? formatTimestamp(recipient.logs.opened.createdAt) : 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)'), + value: formatTimestamp(recipient.logs.rejected.createdAt), 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`), + value: recipient.logs.completed ? formatTimestamp(recipient.logs.completed.createdAt) : i18n._(msg`Unknown`), }); } @@ -474,15 +521,16 @@ const renderColumnThree = (options: RenderColumnOptions) => { }); for (const [index, item] of itemsToRender.entries()) { - const labelAndText = renderLabelAndText({ + const field = renderField({ label: item.label, text: item.value, width, - y: column.getClientRect().height + (index === 0 ? 0 : 8), + y: column.getClientRect().height + (index === 0 ? 0 : fieldGap), labelFill: item.labelFill, valueFill: item.valueFill, }); - column.add(labelAndText); + + column.add(field); } return column; @@ -498,69 +546,37 @@ type RenderRowOptions = { }; }; -const renderRow = (options: RenderRowOptions) => { +const renderRow = (options: RenderRowOptions): RenderedRow => { const { recipient, columnWidths, i18n, envelopeOwner } = options; - const rowGroup = new Konva.Group(); + const group = new Konva.Group(); - const width = columnWidths[0] + columnWidths[1] + columnWidths[2]; + const columns = [renderColumnOne, renderColumnTwo, renderColumnThree]; - // Draw top border line. - const borderLine = new Konva.Line({ - points: [0, 0, width + rowPadding * 2, 0], - stroke: '#e5e7eb', - strokeWidth: 1, - }); + let x = 0; + let maxColumnBottom = 0; - rowGroup.add(borderLine); + for (const [index, renderColumn] of columns.entries()) { + const column = renderColumn({ + recipient, + width: columnWidths[index] - columnGap, + i18n, + envelopeOwner, + }); - // Column 1: Signer Events - const columnGroup = renderColumnOne({ - recipient, - width: columnWidths[0], - i18n, - envelopeOwner, - }); - columnGroup.setAttrs({ - x: rowPadding, - y: rowPadding, - } satisfies Partial); - rowGroup.add(columnGroup); + column.setAttrs({ + x, + y: rowVerticalPadding, + } satisfies Partial); - const columnTwoGroup = renderColumnTwo({ - recipient, - width: columnWidths[1], - i18n, - envelopeOwner, - }); - columnTwoGroup.setAttrs({ - x: rowPadding + columnWidths[0], - y: rowPadding, - } satisfies Partial); - rowGroup.add(columnTwoGroup); + group.add(column); - // 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); + maxColumnBottom = Math.max(maxColumnBottom, rowVerticalPadding + column.getClientRect().height); - const rowBottomPadding = new Konva.Rect({ - x: 0, - y: rowGroup.getClientRect().height, - width: rowGroup.getClientRect().width, - height: rowPadding, - }); - rowGroup.add(rowBottomPadding); + x += columnWidths[index]; + } - return rowGroup; + return { group, height: maxColumnBottom + rowVerticalPadding }; }; const renderBranding = async ({ qrToken, i18n }: { qrToken: string | null; i18n: I18n }) => { @@ -571,10 +587,11 @@ const renderBranding = async ({ qrToken, i18n }: { qrToken: string | null; i18n: const text = new Konva.Text({ x: 0, verticalAlign: 'middle', - text: i18n._(msg`Signing certificate provided by`) + ':', + text: `${i18n._(msg`Signing certificate provided by`)}:`, fontStyle: fontMedium, fontFamily: 'Inter', - fontSize: textSm, + fontSize: textXs, + fill: textMutedForeground, height: brandingHeight, }); @@ -594,7 +611,7 @@ const renderBranding = async ({ qrToken, i18n }: { qrToken: string | null; i18n: const qrSize = qrToken ? 72 : 0; const logoGroup = new Konva.Group({ - y: qrSize + 16, + y: qrSize ? qrSize + 16 : 0, }); logoGroup.add(text); logoGroup.add(documensoImage); @@ -633,89 +650,51 @@ type GroupRowsIntoPagesOptions = { name: string; email: string; }; + firstPageContentHeight: number; + continuationHeaderHeight: number; + tableHeaderHeight: number; }; const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => { - const { recipients, maxHeight, i18n, columnWidths, envelopeOwner } = options; + const { + recipients, + maxHeight, + i18n, + columnWidths, + envelopeOwner, + firstPageContentHeight, + continuationHeaderHeight, + tableHeaderHeight, + } = options; - const rowHeader = renderRowHeader({ columnWidths, i18n }); - const rowHeaderHeight = rowHeader.getClientRect().height; + const groupedRows: RenderedRow[][] = [[]]; - const groupedRows: Konva.Group[][] = [[]]; - - let availablePageHeight = maxHeight - rowHeaderHeight; + // First page has the document header, then every page has the table header. + let availableHeight = maxHeight - pageTopMargin - firstPageContentHeight - tableHeaderHeight; 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) { + if (row.height > availableHeight && groupedRows[currentGroupedRowIndex].length > 0) { currentGroupedRowIndex++; - groupedRows[currentGroupedRowIndex] = [row]; - availablePageHeight = maxHeight - rowHeaderHeight; - } else { - groupedRows[currentGroupedRowIndex].push(row); + groupedRows[currentGroupedRowIndex] = []; + + availableHeight = maxHeight - pageTopMargin - continuationHeaderHeight - tableHeaderHeight; } - // Reduce available height by the row height. - availablePageHeight -= rowHeight; + groupedRows[currentGroupedRowIndex].push(row); + + availableHeight -= row.height; } 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, + envelopeTitle, qrToken, hidePoweredBy, i18n, @@ -725,93 +704,153 @@ export async function renderCertificate({ }: GenerateCertificateOptions) { ensureFontLibrary(); - const minimumMargin = 10; + const contentWidth = Math.min(pageWidth - pageMarginX * 2, contentMaxWidth); + const margin = (pageWidth - contentWidth) / 2; - const tableWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth); - const tableContentWidth = tableWidth - rowPadding * 2; - const margin = (pageWidth - tableWidth) / 2; + const columnWidths: ColumnWidths = [ + (contentWidth * columnWidthPercentages[0]) / 100, + (contentWidth * columnWidthPercentages[1]) / 100, + (contentWidth * columnWidthPercentages[2]) / 100, + ]; - 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 documentHeader = renderDocumentHeader({ envelopeTitle, width: contentWidth, i18n }); + const documentHeaderHeight = documentHeader.getClientRect().height; + const firstPageContentHeight = documentHeaderHeight + headerGap; + + const continuationHeader = renderContinuationHeader({ width: contentWidth, i18n }); + const continuationHeaderHeight = continuationHeader.getClientRect().height + headerGap; + + const tableHeaderHeight = renderTableHeader({ columnWidths, i18n }).getClientRect().height; const groupedRows = groupRowsIntoPages({ recipients, - maxHeight: maxTableHeight, + maxHeight: pageHeight - pageBottomMargin, columnWidths, i18n, envelopeOwner, + firstPageContentHeight, + continuationHeaderHeight, + tableHeaderHeight, }); - const tables = renderTables({ groupedRows, columnWidths, i18n }); + // Assemble the content group for each page so heights are known before + // footers and branding are placed. + const pageGroups: Konva.Group[] = []; - const brandingGroup = await renderBranding({ qrToken, i18n }); - const brandingRect = brandingGroup.getClientRect(); + for (const [pageIndex, rows] of groupedRows.entries()) { + const pageGroup = new Konva.Group({ + x: margin, + y: pageTopMargin, + }); + + let yCursor = 0; + + if (pageIndex === 0) { + pageGroup.add(documentHeader); + yCursor += documentHeaderHeight + headerGap; + } else { + const header = renderContinuationHeader({ width: contentWidth, i18n }); + pageGroup.add(header); + yCursor += header.getClientRect().height + headerGap; + } + + const tableHeader = renderTableHeader({ columnWidths, i18n }); + tableHeader.y(yCursor); + pageGroup.add(tableHeader); + yCursor += tableHeaderHeight; + + for (const [rowIndex, row] of rows.entries()) { + if (rowIndex > 0) { + const separator = new Konva.Line({ + points: [0, 0, contentWidth, 0], + stroke: hairlineColor, + strokeWidth: 0.75, + y: yCursor, + }); + + pageGroup.add(separator); + } + + row.group.y(yCursor); + pageGroup.add(row.group); + + yCursor += row.height; + } + + pageGroups.push(pageGroup); + } + + const brandingGroup = !hidePoweredBy ? await renderBranding({ qrToken, i18n }) : null; const brandingTopPadding = 24; + // Work out whether the branding fits below the content of the last page, or + // whether it needs a page of its own, so the total page count is known + // before rendering footers. + let isBrandingPlacedOnLastPage = false; + + if (brandingGroup) { + const lastPageGroup = pageGroups[pageGroups.length - 1]; + const lastPageContentBottom = lastPageGroup.y() + lastPageGroup.getClientRect().height; + const brandingRect = brandingGroup.getClientRect(); + + isBrandingPlacedOnLastPage = + lastPageContentBottom + brandingTopPadding + brandingRect.height <= pageHeight - pageBottomMargin; + } + + const totalPages = pageGroups.length + (brandingGroup && !isBrandingPlacedOnLastPage ? 1 : 0); + + const renderFooter = (page: Konva.Layer, pageNumber: number) => { + const footerY = pageHeight - pageBottomMargin + 24; + + const envelopeIdText = new Konva.Text({ + x: margin, + y: footerY, + width: contentWidth, + text: `${i18n._(msg`Envelope ID`)}: ${envelopeId}`, + fontFamily: 'Inter', + fontSize: textXs - 0.5, + fill: textMutedForegroundLight, + }); + + const pageNumberText = new Konva.Text({ + x: margin, + y: footerY, + width: contentWidth, + align: 'right', + text: `${pageNumber} / ${totalPages}`, + fontFamily: 'Inter', + fontSize: textXs - 0.5, + fill: textMutedForegroundLight, + }); + + page.add(envelopeIdText); + page.add(pageNumberText); + }; + const pages: Uint8Array[] = []; - let isQrPlaced = false; - - // Add a table to each page. - for (const [index, table] of tables.entries()) { + for (const [index, pageGroup] of pageGroups.entries()) { stage.destroyChildren(); const page = new Konva.Layer(); - const group = new Konva.Group(); + page.add(pageGroup); + renderFooter(page, index + 1); - const titleText = new Konva.Text({ - x: margin, - y: 0, - height: pageTopMargin, - verticalAlign: 'middle', - text: i18n._(msg`Signing Certificate`), - fontFamily: 'Inter', - fontSize: titleFontSize, - fontStyle: '700', - }); + if (brandingGroup && isBrandingPlacedOnLastPage && index === pageGroups.length - 1) { + const brandingRect = brandingGroup.getClientRect(); - table.setAttrs({ - x: margin, - y: pageTopMargin, - } satisfies Partial); + // Anchor the branding to the bottom of the page rather than letting it + // float directly under the content. + brandingGroup.setAttrs({ + x: pageWidth - brandingRect.width - margin, + y: pageHeight - pageBottomMargin - brandingRect.height, + } 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; - } + page.add(brandingGroup); } - 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. @@ -820,26 +859,21 @@ export async function renderCertificate({ 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) { + // Branding gets a page of its own when it does not fit under the last page. + if (brandingGroup && !isBrandingPlacedOnLastPage) { + stage.destroyChildren(); const page = new Konva.Layer(); + const brandingRect = brandingGroup.getClientRect(); + brandingGroup.setAttrs({ x: pageWidth - brandingRect.width - margin, - y: pageTopMargin / 2, // Less padding since there's nothing else on this page. + y: pageHeight - pageBottomMargin - brandingRect.height, } 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); + renderFooter(page, totalPages); + stage.add(page); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions