mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting conflicts caused by biome rollout; preserve both feature streams: PR's external 2FA token + signing-session 2FA proof additions plus main's RateLimit/RecipientExpired/signingReminders/date-auto-insert. In complete-document-with-token.ts, drop the duplicate early field-fetching block introduced when main moved that logic later with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH check using derivedRecipientActionAuth.
This commit is contained in:
@@ -9,8 +9,8 @@ import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
);
|
||||
|
||||
const font = pdf.embedFont(new Uint8Array(fontBytes));
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { PDF, rgb } from '@libpdf/core';
|
||||
import type { FieldType, Recipient } from '@prisma/client';
|
||||
|
||||
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
||||
@@ -21,10 +20,7 @@ export type BoundingBox = {
|
||||
*
|
||||
* Mutates the PDF in place. Coordinates use bottom-left origin (standard PDF coordinates).
|
||||
*/
|
||||
export const whiteoutRegions = (
|
||||
pdfDoc: PDF,
|
||||
regions: Array<{ pageIndex: number; bbox: BoundingBox }>,
|
||||
): void => {
|
||||
export const whiteoutRegions = (pdfDoc: PDF, regions: Array<{ pageIndex: number; bbox: BoundingBox }>): void => {
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const { pageIndex, bbox } of regions) {
|
||||
@@ -117,7 +113,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
|
||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
||||
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
@@ -151,10 +147,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
*
|
||||
* Accepts optional pre-extracted placeholders to avoid re-parsing the PDF.
|
||||
*/
|
||||
export const removePlaceholdersFromPDF = async (
|
||||
pdf: Buffer,
|
||||
placeholders?: PlaceholderInfo[],
|
||||
): Promise<Buffer> => {
|
||||
export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: PlaceholderInfo[]): Promise<Buffer> => {
|
||||
const resolved = placeholders ?? (await extractPlaceholdersFromPDF(pdf));
|
||||
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
@@ -218,8 +211,7 @@ export const convertPlaceholdersToFieldInputs = (
|
||||
const widthPercent = (p.width / p.pageWidth) * 100;
|
||||
const heightPercent = (p.height / p.pageHeight) * 100;
|
||||
|
||||
const finalHeightPercent =
|
||||
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
||||
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
||||
|
||||
const recipient = recipientResolver(p.recipient, p.placeholder);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { getTranslations } from '../../utils/i18n';
|
||||
@@ -12,15 +12,24 @@ import { renderAuditLogs } from './render-audit-logs';
|
||||
|
||||
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
|
||||
envelopeItems: string[];
|
||||
additionalAuditLogs?: TDocumentAuditLog[];
|
||||
};
|
||||
|
||||
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
|
||||
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
|
||||
options;
|
||||
const {
|
||||
envelope,
|
||||
envelopeOwner,
|
||||
envelopeItems,
|
||||
recipients,
|
||||
language,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
additionalAuditLogs = [],
|
||||
} = options;
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
|
||||
|
||||
const [organisationClaim, auditLogs, messages] = await Promise.all([
|
||||
const [organisationClaim, partialAuditLogs, messages] = await Promise.all([
|
||||
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
|
||||
getAuditLogs(envelope.id),
|
||||
getTranslations(documentLanguage),
|
||||
@@ -31,6 +40,8 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
|
||||
messages,
|
||||
});
|
||||
|
||||
const auditLogs: TDocumentAuditLog[] = [...additionalAuditLogs, ...partialAuditLogs];
|
||||
|
||||
const auditLogPages = await renderAuditLogs({
|
||||
envelope,
|
||||
envelopeOwner,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import type { Envelope, Field, Recipient, Signature } from '@prisma/client';
|
||||
import type { DocumentMeta, Envelope, Field, Recipient, Signature } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -16,7 +15,14 @@ import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-c
|
||||
import { renderCertificate } from './render-certificate';
|
||||
|
||||
export type GenerateCertificatePdfOptions = {
|
||||
envelope: Envelope & {
|
||||
/**
|
||||
* Note: completedAt is not included since it's not real at this point in time.
|
||||
*
|
||||
* If we actually need it here in the future, we will need to preserve the
|
||||
* completedAt value and pass it to the final `envelope.update` function when
|
||||
* the document is initially sealed.
|
||||
*/
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeOwner: {
|
||||
@@ -66,22 +72,17 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
|
||||
(log) => log.type === 'DOCUMENT_SENT',
|
||||
);
|
||||
|
||||
const documentOpened: TDocumentAuditLogBaseSchema | undefined = auditLogs[
|
||||
'DOCUMENT_OPENED'
|
||||
].find((log) => log.type === 'DOCUMENT_OPENED' && log.data.recipientId === recipientId);
|
||||
const documentOpened: TDocumentAuditLogBaseSchema | undefined = auditLogs['DOCUMENT_OPENED'].find(
|
||||
(log) => log.type === 'DOCUMENT_OPENED' && log.data.recipientId === recipientId,
|
||||
);
|
||||
|
||||
const documentRecipientCompleted: TDocumentAuditLogBaseSchema | undefined = auditLogs[
|
||||
'DOCUMENT_RECIPIENT_COMPLETED'
|
||||
].find(
|
||||
(log) =>
|
||||
log.type === 'DOCUMENT_RECIPIENT_COMPLETED' && log.data.recipientId === recipientId,
|
||||
);
|
||||
].find((log) => log.type === 'DOCUMENT_RECIPIENT_COMPLETED' && log.data.recipientId === recipientId);
|
||||
|
||||
const documentRecipientRejected: TDocumentAuditLogBaseSchema | undefined = auditLogs[
|
||||
'DOCUMENT_RECIPIENT_REJECTED'
|
||||
].find(
|
||||
(log) => log.type === 'DOCUMENT_RECIPIENT_REJECTED' && log.data.recipientId === recipientId,
|
||||
);
|
||||
].find((log) => log.type === 'DOCUMENT_RECIPIENT_REJECTED' && log.data.recipientId === recipientId);
|
||||
|
||||
const extractedAuthMethods = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
@@ -136,6 +137,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
|
||||
};
|
||||
}),
|
||||
envelopeOwner,
|
||||
envelopeId: envelope.id,
|
||||
qrToken: envelope.qrToken,
|
||||
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
|
||||
pageWidth,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { groupBy } from 'remeda';
|
||||
|
||||
import { insertFieldInPDFV2 } from './insert-field-in-pdf-v2';
|
||||
|
||||
type GeneratePartialSignedPdfOptions = {
|
||||
pdfData: Uint8Array;
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a PDF with all currently-inserted fields burned in. Used to serve
|
||||
* partially signed envelopes during the `PENDING` window before the seal job
|
||||
* has had a chance to produce the final sealed PDF.
|
||||
*
|
||||
* No PKI signature, no certificate page, no audit log appendix - this is a
|
||||
* preview of the in-progress envelope, not a final executed document.
|
||||
*/
|
||||
export const generatePartialSignedPdf = async ({ pdfData, fields }: GeneratePartialSignedPdfOptions) => {
|
||||
const pdfDoc = await PDF.load(pdfData);
|
||||
|
||||
pdfDoc.flattenAll();
|
||||
pdfDoc.upgradeVersion('1.7');
|
||||
|
||||
const fieldsGroupedByPage = groupBy(fields, (field) => field.page);
|
||||
|
||||
for (const [pageNumber, pageFields] of Object.entries(fieldsGroupedByPage)) {
|
||||
const page = pdfDoc.getPage(Number(pageNumber) - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${pageNumber} does not exist`);
|
||||
}
|
||||
|
||||
const pageWidth = page.width;
|
||||
const pageHeight = page.height;
|
||||
const overlayBytes = await insertFieldInPDFV2({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields: pageFields,
|
||||
});
|
||||
|
||||
const overlayPdf = await PDF.load(overlayBytes);
|
||||
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
|
||||
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
|
||||
switch (page.rotation) {
|
||||
case 90:
|
||||
translateX = pageHeight;
|
||||
translateY = 0;
|
||||
break;
|
||||
case 180:
|
||||
translateX = pageWidth;
|
||||
translateY = pageHeight;
|
||||
break;
|
||||
case 270:
|
||||
translateX = 0;
|
||||
translateY = pageWidth;
|
||||
break;
|
||||
}
|
||||
|
||||
page.drawPage(embeddedPage, {
|
||||
x: translateX,
|
||||
y: translateY,
|
||||
rotate: {
|
||||
angle: page.rotation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pdfDoc.flattenAll();
|
||||
|
||||
return await pdfDoc.save({ useXRefStream: true });
|
||||
};
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { PDFPage } from '@cantoo/pdf-lib';
|
||||
import type { PDF } from '@libpdf/core';
|
||||
|
||||
import { PDF_SIZE_A4_72PPI } from '../../constants/pdf';
|
||||
|
||||
const MIN_CERT_PAGE_WIDTH = 300;
|
||||
const MIN_CERT_PAGE_HEIGHT = 300;
|
||||
|
||||
/**
|
||||
* Gets the effective page size for PDF operations.
|
||||
@@ -7,12 +13,46 @@ import type { PDFPage } from '@cantoo/pdf-lib';
|
||||
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
|
||||
*/
|
||||
export const getPageSize = (page: PDFPage) => {
|
||||
const cropBox = page.getCropBox();
|
||||
const mediaBox = page.getMediaBox();
|
||||
let mediaBox;
|
||||
let cropBox;
|
||||
|
||||
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
|
||||
return mediaBox;
|
||||
try {
|
||||
mediaBox = page.getMediaBox();
|
||||
} catch {
|
||||
// MediaBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
|
||||
}
|
||||
|
||||
return cropBox;
|
||||
try {
|
||||
cropBox = page.getCropBox();
|
||||
} catch {
|
||||
// CropBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
|
||||
}
|
||||
|
||||
if (mediaBox && cropBox) {
|
||||
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
|
||||
return mediaBox;
|
||||
}
|
||||
|
||||
return cropBox;
|
||||
}
|
||||
|
||||
// If either box is missing or invalid, fall back to MediaBox if available, otherwise CropBox, or default to A4 size.
|
||||
return mediaBox || cropBox || PDF_SIZE_A4_72PPI;
|
||||
};
|
||||
|
||||
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
|
||||
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
|
||||
|
||||
if (!lastPage) {
|
||||
return PDF_SIZE_A4_72PPI;
|
||||
}
|
||||
|
||||
const width = Math.round(lastPage.width);
|
||||
const height = Math.round(lastPage.height);
|
||||
|
||||
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
|
||||
return PDF_SIZE_A4_72PPI;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
@@ -1,8 +1,43 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import path from 'node:path';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
/**
|
||||
* Ensure all required fonts are registered in the skia-canvas FontLibrary.
|
||||
*
|
||||
* Fonts are registered once per process and retained — calling this multiple
|
||||
* times is a no-op after the first invocation.
|
||||
*/
|
||||
export const ensureFontLibrary = () => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
if (!FontLibrary.has('Caveat')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
});
|
||||
}
|
||||
|
||||
if (!FontLibrary.has('Inter')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
|
||||
});
|
||||
}
|
||||
|
||||
if (!FontLibrary.has('Noto Sans')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
|
||||
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
|
||||
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
|
||||
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type RecipientPlaceholderInfo = {
|
||||
email: string;
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import type { PDFDocument, PDFFont, PDFTextField } from '@cantoo/pdf-lib';
|
||||
import {
|
||||
RotationTypes,
|
||||
TextAlignment,
|
||||
degrees,
|
||||
radiansToDegrees,
|
||||
rgb,
|
||||
setFontAndSize,
|
||||
} from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { degrees, RotationTypes, radiansToDegrees, rgb, setFontAndSize, TextAlignment } from '@cantoo/pdf-lib';
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
@@ -21,6 +10,9 @@ import {
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
@@ -37,12 +29,8 @@ import { getPageSize } from './get-page-size';
|
||||
|
||||
export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
@@ -109,13 +97,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
|
||||
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
debugX,
|
||||
debugY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
const adjustedPosition = adjustPositionForRotation(pageWidth, pageHeight, debugX, debugY, pageRotationInDegrees);
|
||||
|
||||
debugX = adjustedPosition.xPos;
|
||||
debugY = adjustedPosition.yPos;
|
||||
@@ -187,9 +169,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
|
||||
} else {
|
||||
const signatureText = field.signature?.typedSignature ?? '';
|
||||
|
||||
const longestLineInTextForWidth = signatureText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
const longestLineInTextForWidth = signatureText.split('\n').sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
@@ -401,9 +381,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
|
||||
* - True = text will overflow downwards.
|
||||
* - False = text will overflow sideways.
|
||||
*/
|
||||
const isMultiline =
|
||||
field.type === FieldType.TEXT &&
|
||||
(textWidth > fieldWidth || field.customText.includes('\n'));
|
||||
const isMultiline = field.type === FieldType.TEXT && (textWidth > fieldWidth || field.customText.includes('\n'));
|
||||
|
||||
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
||||
const padding = 8;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// sort-imports-ignore
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import Konva from 'konva';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import Konva from 'konva';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
type InsertFieldInPDFV2Options = {
|
||||
pageWidth: number;
|
||||
@@ -16,21 +14,8 @@ type InsertFieldInPDFV2Options = {
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
export const insertFieldInPDFV2 = async ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
|
||||
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
|
||||
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
|
||||
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
|
||||
});
|
||||
export const insertFieldInPDFV2 = async ({ pageWidth, pageHeight, fields }: InsertFieldInPDFV2Options) => {
|
||||
ensureFontLibrary();
|
||||
|
||||
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
let layer: Konva.Layer | null = new Konva.Layer();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import type { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees, rgb } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { degrees, RotationTypes, radiansToDegrees, rgb } from '@cantoo/pdf-lib';
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
@@ -14,6 +10,9 @@ import {
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
@@ -30,12 +29,8 @@ import { getPageSize } from './get-page-size';
|
||||
|
||||
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
@@ -95,13 +90,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
|
||||
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
debugX,
|
||||
debugY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
const adjustedPosition = adjustPositionForRotation(pageWidth, pageHeight, debugX, debugY, pageRotationInDegrees);
|
||||
|
||||
debugX = adjustedPosition.xPos;
|
||||
debugY = adjustedPosition.yPos;
|
||||
@@ -173,9 +162,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
|
||||
} else {
|
||||
const signatureText = field.signature?.typedSignature ?? '';
|
||||
|
||||
const longestLineInTextForWidth = signatureText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
const longestLineInTextForWidth = signatureText.split('\n').sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
@@ -314,9 +301,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
|
||||
|
||||
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
||||
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
const longestLineInTextForWidth = field.customText.split('\n').sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = customFontSize || maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
@@ -346,13 +331,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
const adjustedPosition = adjustPositionForRotation(pageWidth, pageHeight, textX, textY, pageRotationInDegrees);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import type { Envelope, RecipientRole } from '@prisma/client';
|
||||
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
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 { FontLibrary } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
import { P } from 'ts-pattern';
|
||||
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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
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';
|
||||
|
||||
export type AuditLogRecipient = {
|
||||
id: number;
|
||||
@@ -30,7 +28,7 @@ export type AuditLogRecipient = {
|
||||
};
|
||||
|
||||
type GenerateAuditLogsOptions = {
|
||||
envelope: Envelope & {
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeItems: string[];
|
||||
@@ -57,7 +55,7 @@ const textXs = 8;
|
||||
const fontMedium = '500';
|
||||
|
||||
const pageTopMargin = 60;
|
||||
const pageBottomMargin = 15;
|
||||
const pageBottomMargin = 27;
|
||||
const contentMaxWidth = 768;
|
||||
const rowPadding = 10;
|
||||
const titleFontSize = 18;
|
||||
@@ -168,7 +166,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions)
|
||||
};
|
||||
|
||||
type RenderOverviewCardOptions = {
|
||||
envelope: Envelope & {
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeItems: string[];
|
||||
@@ -219,9 +217,7 @@ const renderOverviewCard = (options: RenderOverviewCardOptions) => {
|
||||
|
||||
const statusLabel = renderOverviewCardLabels({
|
||||
label: i18n._(msg`Status`),
|
||||
text: i18n
|
||||
._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description)
|
||||
.toUpperCase(),
|
||||
text: i18n._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description).toUpperCase(),
|
||||
width: columnWidth,
|
||||
});
|
||||
const timeZoneLabel = renderOverviewCardLabels({
|
||||
@@ -352,9 +348,7 @@ const renderRow = (options: RenderRowOptions) => {
|
||||
const auditLogTimestampText = new Konva.Text({
|
||||
x: columnWidth + columnSpacing,
|
||||
width: columnWidth,
|
||||
text: DateTime.fromJSDate(auditLog.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat),
|
||||
text: DateTime.fromJSDate(auditLog.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale).toLocaleString(dateFormat),
|
||||
fontFamily: 'Inter',
|
||||
align: 'right',
|
||||
fontSize: textSm,
|
||||
@@ -575,13 +569,7 @@ export async function renderAuditLogs({
|
||||
i18n,
|
||||
hidePoweredBy,
|
||||
}: GenerateAuditLogsOptions) {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
|
||||
});
|
||||
ensureFontLibrary();
|
||||
|
||||
const minimumMargin = 10;
|
||||
|
||||
@@ -601,7 +589,7 @@ export async function renderAuditLogs({
|
||||
|
||||
const groupedRows = groupRowsIntoPages({
|
||||
auditLogs,
|
||||
maxHeight: pageHeight,
|
||||
maxHeight: pageHeight - pageBottomMargin,
|
||||
contentWidth,
|
||||
i18n,
|
||||
overviewCard,
|
||||
@@ -628,6 +616,16 @@ export async function renderAuditLogs({
|
||||
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);
|
||||
|
||||
// Add branding on the last page if there is space.
|
||||
@@ -663,6 +661,16 @@ export async function renderAuditLogs({
|
||||
y: pageTopMargin,
|
||||
} satisfies Partial<Konva.GroupConfig>);
|
||||
|
||||
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);
|
||||
stage.add(page);
|
||||
|
||||
@@ -693,10 +701,7 @@ const getAuditLogIndicatorColor = (type: string) =>
|
||||
.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,
|
||||
),
|
||||
P.union(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED),
|
||||
() => '#3b82f6', // bg-blue-500
|
||||
)
|
||||
.otherwise(() => '#f1f5f9'); // bg-muted
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Field, Signature } from '@prisma/client';
|
||||
import type { Field, RecipientRole, Signature } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import type { RecipientRole } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import { DateTime } from 'luxon';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { renderSVG } from 'uqr';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { APP_I18N_OPTIONS } from '../../constants/i18n';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
|
||||
import { svgToPng } from '../../utils/images/svg-to-png';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
type ColumnWidths = [number, number, number];
|
||||
|
||||
@@ -49,6 +45,7 @@ export type CertificateRecipient = {
|
||||
|
||||
type GenerateCertificateOptions = {
|
||||
recipients: CertificateRecipient[];
|
||||
envelopeId: string;
|
||||
qrToken: string | null;
|
||||
hidePoweredBy: boolean;
|
||||
i18n: I18n;
|
||||
@@ -78,6 +75,7 @@ const getDevice = (userAgent?: string | null): string => {
|
||||
const textMutedForegroundLight = '#929DAE';
|
||||
const textForeground = '#000';
|
||||
const textMutedForeground = '#64748B';
|
||||
const textRejectedRed = '#dc2626';
|
||||
const textBase = 10;
|
||||
const textSm = 9;
|
||||
const textXs = 8;
|
||||
@@ -87,7 +85,7 @@ const columnWidthPercentages = [30, 30, 40];
|
||||
const rowPadding = 12;
|
||||
const tableHeaderHeight = 38;
|
||||
const pageTopMargin = 72;
|
||||
const pageBottomMargin = 12;
|
||||
const pageBottomMargin = 24;
|
||||
const contentMaxWidth = 768;
|
||||
|
||||
const titleFontSize = 18;
|
||||
@@ -97,6 +95,8 @@ type RenderLabelAndTextOptions = {
|
||||
text: string;
|
||||
width: number;
|
||||
y?: number;
|
||||
labelFill?: string;
|
||||
valueFill?: string;
|
||||
};
|
||||
|
||||
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
@@ -106,13 +106,16 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
y,
|
||||
});
|
||||
|
||||
const labelFill = options.labelFill ?? textMutedForeground;
|
||||
const valueFill = options.valueFill ?? textMutedForeground;
|
||||
|
||||
const label = new Konva.Text({
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: `${options.label}: `,
|
||||
fontStyle: fontMedium,
|
||||
fontFamily: 'Inter',
|
||||
fill: textMutedForeground,
|
||||
fill: labelFill,
|
||||
fontSize: textSm,
|
||||
});
|
||||
|
||||
@@ -124,7 +127,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
width: width - label.width(),
|
||||
fontFamily: 'Inter',
|
||||
text: options.text,
|
||||
fill: textMutedForeground,
|
||||
fill: valueFill,
|
||||
wrap: 'char',
|
||||
fontSize: textSm,
|
||||
});
|
||||
@@ -269,6 +272,8 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
|
||||
const columnWidth = width - columnPadding;
|
||||
|
||||
const isRejected = Boolean(recipient.logs.rejected);
|
||||
|
||||
if (recipient.signatureField?.secondaryId) {
|
||||
// Signature container with green border
|
||||
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
|
||||
@@ -313,7 +318,10 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
signatureContainer.add(typedSig);
|
||||
}
|
||||
|
||||
column.add(signatureContainer);
|
||||
// Do not add the signature container for rejected recipients.
|
||||
if (!isRejected) {
|
||||
column.add(signatureContainer);
|
||||
}
|
||||
|
||||
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
|
||||
|
||||
@@ -342,7 +350,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
// Signature ID
|
||||
const sigIdLabel = new Konva.Text({
|
||||
x: 0,
|
||||
y: signatureHeight + 10,
|
||||
y: isRejected ? 0 : signatureHeight + 10,
|
||||
text: `${i18n._(msg`Signature ID`)}:`,
|
||||
fill: textMutedForeground,
|
||||
width: columnWidth,
|
||||
@@ -376,9 +384,11 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
column.add(naText);
|
||||
}
|
||||
|
||||
const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed;
|
||||
|
||||
const ipLabelAndText = renderLabelAndText({
|
||||
label: i18n._(msg`IP Address`),
|
||||
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
|
||||
text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`),
|
||||
width,
|
||||
y: column.getClientRect().height + 6,
|
||||
});
|
||||
@@ -386,7 +396,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
|
||||
const deviceLabelAndText = renderLabelAndText({
|
||||
label: i18n._(msg`Device`),
|
||||
text: getDevice(recipient.logs.completed?.userAgent),
|
||||
text: getDevice(relevantLog?.userAgent),
|
||||
width,
|
||||
y: column.getClientRect().height + 6,
|
||||
});
|
||||
@@ -400,7 +410,14 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
|
||||
const column = new Konva.Group();
|
||||
|
||||
const itemsToRender = [
|
||||
type DetailItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
labelFill?: string;
|
||||
valueFill?: string;
|
||||
};
|
||||
|
||||
const itemsToRender: DetailItem[] = [
|
||||
{
|
||||
label: i18n._(msg`Sent`),
|
||||
value: recipient.logs.emailed
|
||||
@@ -429,6 +446,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
|
||||
labelFill: textRejectedRed,
|
||||
valueFill: textRejectedRed,
|
||||
});
|
||||
} else {
|
||||
itemsToRender.push({
|
||||
@@ -459,6 +478,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
text: item.value,
|
||||
width,
|
||||
y: column.getClientRect().height + (index === 0 ? 0 : 8),
|
||||
labelFill: item.labelFill,
|
||||
valueFill: item.valueFill,
|
||||
});
|
||||
column.add(labelAndText);
|
||||
}
|
||||
@@ -693,6 +714,7 @@ const renderTables = (options: RenderTablesOptions) => {
|
||||
|
||||
export async function renderCertificate({
|
||||
recipients,
|
||||
envelopeId,
|
||||
qrToken,
|
||||
hidePoweredBy,
|
||||
i18n,
|
||||
@@ -700,13 +722,7 @@ export async function renderCertificate({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: GenerateCertificateOptions) {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
|
||||
});
|
||||
ensureFontLibrary();
|
||||
|
||||
const minimumMargin = 10;
|
||||
|
||||
@@ -784,6 +800,16 @@ export async function renderCertificate({
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -802,6 +828,16 @@ export async function renderCertificate({
|
||||
y: pageTopMargin / 2, // Less padding since there's nothing else on this page.
|
||||
} satisfies Partial<Konva.GroupConfig>);
|
||||
|
||||
const overflowFooterText = new Konva.Text({
|
||||
x: margin,
|
||||
y: pageHeight - textXs - 10,
|
||||
text: `${i18n._(msg`Envelope ID`)}: ${envelopeId}`,
|
||||
fontFamily: 'Inter',
|
||||
fontSize: textXs,
|
||||
fill: textMutedForegroundLight,
|
||||
});
|
||||
page.add(overflowFooterText);
|
||||
|
||||
page.add(brandingGroup);
|
||||
stage.add(page);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user