@@ -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