diff --git a/.agents/plans/fresh-gold-rock-cert-page-width-mismatch.md b/.agents/plans/fresh-gold-rock-cert-page-width-mismatch.md new file mode 100644 index 000000000..d8302a250 --- /dev/null +++ b/.agents/plans/fresh-gold-rock-cert-page-width-mismatch.md @@ -0,0 +1,168 @@ +--- +date: 2026-02-11 +title: Cert Page Width Mismatch +--- + +## Problem + +Certificate and audit log pages are generated with hardcoded A4 dimensions (`PDF_SIZE_A4_72PPI`: 595×842) regardless of the actual document page sizes. When the source document uses a different page size (e.g., Letter, Legal, or custom dimensions), the certificate/audit log pages end up with a different width than the document pages. This causes problems with courts that expect uniform page dimensions throughout a PDF. + +**Both width and height must match** the last page of the document so the entire PDF prints uniformly. + +**Root cause**: In `seal-document.handler.ts` (lines 186-187), the certificate payload always uses: + +```ts +pageWidth: PDF_SIZE_A4_72PPI.width, // 595 +pageHeight: PDF_SIZE_A4_72PPI.height, // 842 +``` + +These hardcoded values flow into `generateCertificatePdf`, `generateAuditLogPdf`, `renderCertificate`, and `renderAuditLogs` — all of which use `pageWidth`/`pageHeight` to set Konva stage dimensions and layout content. + +## Key Files + +| File | Role | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `packages/lib/jobs/definitions/internal/seal-document.handler.ts` | Orchestrates sealing; passes page dimensions to cert/audit generators | +| `packages/lib/constants/pdf.ts` | Defines `PDF_SIZE_A4_72PPI` (595×842) | +| `packages/lib/server-only/pdf/generate-certificate-pdf.ts` | Generates certificate PDF; accepts `pageWidth`/`pageHeight` | +| `packages/lib/server-only/pdf/generate-audit-log-pdf.ts` | Generates audit log PDF; accepts `pageWidth`/`pageHeight` | +| `packages/lib/server-only/pdf/render-certificate.ts` | Renders certificate pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout | +| `packages/lib/server-only/pdf/render-audit-logs.ts` | Renders audit log pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout | +| `packages/lib/server-only/pdf/get-page-size.ts` | Existing utility — extend with `@libpdf/core` version | +| `packages/trpc/server/document-router/download-document-certificate.ts` | Standalone certificate download (also hardcodes A4) | +| `packages/trpc/server/document-router/download-document-audit-logs.ts` | Standalone audit log download (also hardcodes A4) | + +## Architecture + +### Current Flow + +1. **One cert PDF + one audit log PDF** generated per envelope with hardcoded A4 dims +2. Both appended to **every** envelope item (document) via `decorateAndSignPdf` → `pdfDoc.copyPagesFrom()` +3. The audit log is envelope-level (all recipients, all events across all docs) — one per envelope, not per document + +### Multi-Document Envelopes + +- V1 envelopes: single document only +- V2 envelopes: support multiple documents (envelope items) +- Each envelope item gets both cert + audit log pages appended to it +- If documents have different page sizes → need size-matched cert/audit for each + +### Reading Page Dimensions (`@libpdf/core` only) + +Use `@libpdf/core`'s `PDF` class — NOT `@cantoo/pdf-lib`: + +```ts +const pdfDoc = await PDF.load(pdfData); +const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1); +const { width, height } = lastPage; // e.g. 612, 792 for Letter +``` + +Already used this way in `seal-document.handler.ts` lines 403-410 for V2 field insertion. +"Last page" = last page of the original document, before cert/audit pages are appended. + +### Content Layout Adaptation + +Both renderers already handle variable dimensions gracefully: + +- **Width**: `render-certificate.ts:713` / `render-audit-logs.ts:588` — `Math.min(pageWidth - minimumMargin * 2, contentMaxWidth)` with `contentMaxWidth = 768`. Wider pages get more margin, narrower pages tighter margins. +- **Height**: Both renderers paginate content into pages using `groupRowsIntoPages()` which respects `pageHeight` via `maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin`. Shorter pages just mean more pages; taller pages fit more rows per page. + +### Playwright PDF Path — Out of Scope + +The `NEXT_PRIVATE_USE_PLAYWRIGHT_PDF` toggle enables a deprecated Playwright-based PDF generation path (`get-certificate-pdf.ts`, `get-audit-logs-pdf.ts`) that also hardcodes `format: 'A4'` in `page.pdf()`. This path is **not being updated** as part of this fix: + +- Both files are marked `@deprecated` +- The Konva-based path is the default and recommended path +- The Playwright path is behind a feature flag and will be removed + +No changes needed. Add a code comment noting the A4 limitation if the Playwright path is ever re-enabled. + +## Plan + +### 1. Extend `get-page-size.ts` with `@libpdf/core` utility + +Add a `getLastPageDimensions` function to the existing `packages/lib/server-only/pdf/get-page-size.ts` file. This consolidates page-size logic in one place (the file already has the legacy `@cantoo/pdf-lib` version). + +```ts +export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => { + const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1); + const width = Math.round(lastPage.width); + const height = Math.round(lastPage.height); + + if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) { + return { width: PDF_SIZE_A4_72PPI.width, height: PDF_SIZE_A4_72PPI.height }; + } + + return { width, height }; +}; +``` + +**Dimension rounding**: `Math.round()` both width and height. PDF points at 72ppi are typically whole numbers; rounding avoids spurious float-precision mismatches (e.g., 612.0 vs 612.00001) that would cause unnecessary duplicate cert/audit PDF generation. + +**Minimum page dimensions**: Enforce a minimum threshold (e.g., 300pt for both width and height). If either dimension falls below the minimum, fall back to A4 (595×842). The certificate and audit log renderers have headers, table rows, margins, and QR codes that require a minimum viable area. + +### 2. Read last page dimensions from each envelope item's PDF + +In `seal-document.handler.ts`, before generating cert/audit PDFs: + +- For each `envelopeItem`, load the PDF and read the **last page's width and height** using `getLastPageDimensions` +- Use `PDF.load()` then pass the loaded doc to the utility + +**Resealing consideration**: When `isResealing` is true, envelope items are remapped to use `initialData` (lines 152-158) before this point. Page-size extraction must operate on the same data source that `decorateAndSignPdf` will use. Since the `envelopeItems` array is already remapped by the time we read dimensions, reading from `envelopeItem.documentData` will naturally give the correct (initial) data. No special handling needed beyond ensuring the dimension read happens **after** the resealing remap. + +### 3. Generate cert/audit PDFs per unique page size + +Current flow generates one cert + one audit log doc per envelope. Change to: + +1. Collect `{ width, height }` of the last page for each envelope item +2. Deduplicate by `"${width}x${height}"` key (using the already-rounded integers) +3. For each unique size, generate cert PDF and audit log PDF with those dimensions +4. Store in a `Map` keyed by `"${width}x${height}"` + +For the common single-document case, this is one generation — same perf as today. + +### 4. Thread the correct docs into `decorateAndSignPdf` + +In the envelope item loop, look up the item's last-page dimensions in the map and pass the matching cert/audit docs. Signature of `decorateAndSignPdf` doesn't change — it still receives a single `certificateDoc` and `auditLogDoc`, just the right ones per item. + +### 5. Update standalone download routes + +`download-document-certificate.ts` and `download-document-audit-logs.ts` also hardcode A4: + +- Both routes have `documentId` which maps to a specific envelope item +- Fetch **that specific document's** PDF data, load it, read last page width + height via `getLastPageDimensions` +- Pass `{ pageWidth, pageHeight }` to the generator +- This ensures the standalone download matches the dimensions the user would see in the sealed PDF for that document + +### 6. Edge cases + +| Scenario | Behavior | +| --------------------------------------- | ------------------------------------------------------------------------------------------- | +| Mixed page sizes within one PDF | Use last page's dimensions (per spec) | +| Page dimensions below minimum threshold | Fall back to A4 (595×842) | +| Landscape pages | width/height just swap roles; renderers adapt via `Math.min()` capping. No special handling | +| Fallback if page dims unreadable | Default to A4 (595×842) | +| Resealing | Dimensions read after `initialData` remap — correct source automatically | +| Playwright PDF path enabled | Remains A4 — out of scope, deprecated | +| Single-doc envelope (most common) | One generation, same perf as today | +| Multi-doc envelope, same page sizes | Dedup key matches → one generation | +| Multi-doc envelope, different sizes | One generation per unique size | + +### 7. Tests + +- Add assertion-based E2E test (no visual regression / reference images needed) +- Seal a Letter-size (612×792) PDF through the full flow +- Load the sealed output and assert all pages (document + cert + audit) have matching width/height +- Can be added to `envelope-alignment.spec.ts` or as a new focused test + +## Implementation Steps + +1. **Extend `get-page-size.ts`** — add `getLastPageDimensions(pdfDoc: PDF): { width: number; height: number }` using `@libpdf/core`, with `Math.round()` and minimum dimension enforcement +2. **In `seal-document.handler.ts`**: + a. After the resealing remap (line ~159), load each envelope item's PDF via `PDF.load()` and collect last page `{ width, height }` using `getLastPageDimensions` + b. Deduplicate by `"${width}x${height}"` key + c. Generate cert/audit PDFs per unique size (parallel via `Promise.all`) + d. In envelope item loop, look up matching cert/audit doc by size key +3. **Fix `download-document-certificate.ts`** — load the specific document's PDF, read last page dims via `getLastPageDimensions`, pass to generator +4. **Fix `download-document-audit-logs.ts`** — same as above, using the specific `documentId`'s PDF +5. **Add E2E test** — assertion-based test with a Letter-size document verifying all page dimensions match after sealing diff --git a/assets/a4-size.pdf b/assets/a4-size.pdf new file mode 100644 index 000000000..c478b3c5a --- /dev/null +++ b/assets/a4-size.pdf @@ -0,0 +1,51 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R] +/Count 1 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260211083727Z) +/ModDate (D:20260211083727Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 595 842] +/Resources << +>> +/Parent 1 0 R +>> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000072 00000 n +0000000121 00000 n +0000000290 00000 n +trailer +<< +/Size 5 +/Root 2 0 R +/Info 3 0 R +/ID [ ] +>> +startxref +378 +%%EOF diff --git a/assets/letter-size.pdf b/assets/letter-size.pdf new file mode 100644 index 000000000..611b1c3b3 --- /dev/null +++ b/assets/letter-size.pdf @@ -0,0 +1,51 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R] +/Count 1 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260211081729Z) +/ModDate (D:20260211081729Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Parent 1 0 R +>> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000072 00000 n +0000000121 00000 n +0000000290 00000 n +trailer +<< +/Size 5 +/Root 2 0 R +/Info 3 0 R +/ID [<94A5FB5DCF5A94AD8C472C493420962C> <94A5FB5DCF5A94AD8C472C493420962C>] +>> +startxref +378 +%%EOF diff --git a/assets/tabloid-landscape.pdf b/assets/tabloid-landscape.pdf new file mode 100644 index 000000000..d40253313 --- /dev/null +++ b/assets/tabloid-landscape.pdf @@ -0,0 +1,51 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R] +/Count 1 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260211084535Z) +/ModDate (D:20260211084535Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 1224 792] +/Resources << +>> +/Parent 1 0 R +>> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000072 00000 n +0000000121 00000 n +0000000290 00000 n +trailer +<< +/Size 5 +/Root 2 0 R +/Info 3 0 R +/ID [<694452F2208AC8E3DD2D2488544F9F0C> <694452F2208AC8E3DD2D2488544F9F0C>] +>> +startxref +379 +%%EOF diff --git a/packages/app-tests/e2e/envelopes/cert-page-dimensions.spec.ts b/packages/app-tests/e2e/envelopes/cert-page-dimensions.spec.ts new file mode 100644 index 000000000..0392c2cfc --- /dev/null +++ b/packages/app-tests/e2e/envelopes/cert-page-dimensions.spec.ts @@ -0,0 +1,218 @@ +import { type APIRequestContext, type Page, expect, test } from '@playwright/test'; +import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; + +import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app'; +import { createApiToken } from '../../../lib/server-only/public-api/create-api-token'; +import { RecipientRole } from '../../../prisma/generated/types'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '../../../trpc/server/envelope-router/create-envelope.types'; +import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types'; +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +const signAndVerifyPageDimensions = async ({ + page, + request, + pdfFile, + identifier, + title, + expectedWidth, + expectedHeight, +}: { + page: Page; + request: APIRequestContext; + pdfFile: string; + identifier: string; + title: string; + expectedWidth: number; + expectedHeight: number; +}) => { + const { user, team } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const pdfBuffer = fs.readFileSync(path.join(__dirname, `../../../../assets/${pdfFile}`)); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title, + recipients: [ + { + email: user.email, + name: user.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + identifier, + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 40, + height: 10, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([pdfBuffer], identifier, { type: 'application/pdf' })); + + const createResponse = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(createResponse.ok()).toBeTruthy(); + + const { id: envelopeId }: TCreateEnvelopeResponse = await createResponse.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeId }, + include: { recipients: true }, + }); + + const distributeResponse = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeResponse.ok()).toBeTruthy(); + + // Pre-insert all fields via Prisma so we can skip the UI field interaction. + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id, inserted: false }, + }); + + for (const field of fields) { + await prisma.field.update({ + where: { id: field.id }, + data: { + inserted: true, + signature: { + create: { + recipientId: envelope.recipients[0].id, + typedSignature: 'Test Signature', + }, + }, + }, + }); + } + + const recipientToken = envelope.recipients[0].token; + const signUrl = `/sign/${recipientToken}`; + + await apiSignin({ + page, + email: user.email, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + const completedEnvelope = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + include: { + envelopeItems: { + orderBy: { order: 'asc' }, + include: { documentData: true }, + }, + }, + }); + + for (const item of completedEnvelope.envelopeItems) { + const documentUrl = getEnvelopeItemPdfUrl({ + type: 'download', + envelopeItem: item, + token: recipientToken, + version: 'signed', + }); + + const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); + const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) }); + const pdf = await loadingTask.promise; + + expect(pdf.numPages).toBeGreaterThan(1); + + for (let i = 1; i <= pdf.numPages; i++) { + const pdfPage = await pdf.getPage(i); + const viewport = pdfPage.getViewport({ scale: 1 }); + + expect(Math.round(viewport.width)).toBe(expectedWidth); + expect(Math.round(viewport.height)).toBe(expectedHeight); + } + } +}; + +test('cert and audit log pages match letter page dimensions', async ({ page, request }) => { + await signAndVerifyPageDimensions({ + page, + request, + pdfFile: 'letter-size.pdf', + identifier: 'letter-doc', + title: 'Letter Size Dimension Test', + expectedWidth: 612, + expectedHeight: 792, + }); +}); + +test('cert and audit log pages match A4 page dimensions', async ({ page, request }) => { + await signAndVerifyPageDimensions({ + page, + request, + pdfFile: 'a4-size.pdf', + identifier: 'a4-doc', + title: 'A4 Size Dimension Test', + expectedWidth: 595, + expectedHeight: 842, + }); +}); + +test('cert and audit log pages match tabloid landscape page dimensions', async ({ + page, + request, +}) => { + await signAndVerifyPageDimensions({ + page, + request, + pdfFile: 'tabloid-landscape.pdf', + identifier: 'tabloid-doc', + title: 'Tabloid Landscape Dimension Test', + expectedWidth: 1224, + expectedHeight: 792, + }); +}); diff --git a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts index c48306106..3647072c3 100644 --- a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts @@ -298,18 +298,34 @@ test('field placement visual regression', async ({ page, request }, testInfo) => * * DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND. */ -test.skip('download envelope images', async ({ page }) => { +test.skip('download envelope images', async ({ page, request }) => { const { user, team } = await seedUser(); + const { token: apiToken } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + const envelope = await seedAlignmentTestDocument({ userId: user.id, teamId: team.id, recipientName: user.name || '', recipientEmail: user.email, insertFields: true, - status: DocumentStatus.PENDING, + status: DocumentStatus.DRAFT, }); + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${apiToken}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + const token = envelope.recipients[0].token; const signUrl = `/sign/${token}`; diff --git a/packages/app-tests/visual-regression/alignment-pdf-0.png b/packages/app-tests/visual-regression/alignment-pdf-0.png index dbc4ec710..27d098da2 100644 Binary files a/packages/app-tests/visual-regression/alignment-pdf-0.png and b/packages/app-tests/visual-regression/alignment-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-1.png b/packages/app-tests/visual-regression/alignment-pdf-1.png index 257291012..88912e4b9 100644 Binary files a/packages/app-tests/visual-regression/alignment-pdf-1.png and b/packages/app-tests/visual-regression/alignment-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-2.png b/packages/app-tests/visual-regression/alignment-pdf-2.png index a63a6daa1..af4de39e7 100644 Binary files a/packages/app-tests/visual-regression/alignment-pdf-2.png and b/packages/app-tests/visual-regression/alignment-pdf-2.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-3.png b/packages/app-tests/visual-regression/alignment-pdf-3.png index b3849f9c4..c9f5b5a9a 100644 Binary files a/packages/app-tests/visual-regression/alignment-pdf-3.png and b/packages/app-tests/visual-regression/alignment-pdf-3.png differ diff --git a/packages/app-tests/visual-regression/blank-certificate.png b/packages/app-tests/visual-regression/blank-certificate.png index 3d546ca2f..a54340f17 100644 Binary files a/packages/app-tests/visual-regression/blank-certificate.png and b/packages/app-tests/visual-regression/blank-certificate.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-0.png b/packages/app-tests/visual-regression/field-meta-pdf-0.png index d14125e79..b165de37d 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-0.png and b/packages/app-tests/visual-regression/field-meta-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-1.png b/packages/app-tests/visual-regression/field-meta-pdf-1.png index a0200ed8a..6af19cb29 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-1.png and b/packages/app-tests/visual-regression/field-meta-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-2.png b/packages/app-tests/visual-regression/field-meta-pdf-2.png index a7bebf23b..665dfad5b 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-2.png and b/packages/app-tests/visual-regression/field-meta-pdf-2.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-3.png b/packages/app-tests/visual-regression/field-meta-pdf-3.png index b1652549c..548c107ce 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-3.png and b/packages/app-tests/visual-regression/field-meta-pdf-3.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-4.png b/packages/app-tests/visual-regression/field-meta-pdf-4.png index 1ae67dc1d..2164c634e 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-4.png and b/packages/app-tests/visual-regression/field-meta-pdf-4.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-5.png b/packages/app-tests/visual-regression/field-meta-pdf-5.png index 697b87e8b..82a05ed46 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-5.png and b/packages/app-tests/visual-regression/field-meta-pdf-5.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-6.png b/packages/app-tests/visual-regression/field-meta-pdf-6.png index abab3698a..729cbed94 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-6.png and b/packages/app-tests/visual-regression/field-meta-pdf-6.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-7.png b/packages/app-tests/visual-regression/field-meta-pdf-7.png index b3849f9c4..c9f5b5a9a 100644 Binary files a/packages/app-tests/visual-regression/field-meta-pdf-7.png and b/packages/app-tests/visual-regression/field-meta-pdf-7.png differ diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 2e37b3039..786de3a9c 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -15,11 +15,11 @@ import { groupBy } from 'remeda'; import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf'; import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf'; import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; +import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-size'; import { prisma } from '@documenso/prisma'; import { signPdf } from '@documenso/signing'; import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app'; -import { PDF_SIZE_A4_72PPI } from '../../../constants/pdf'; import { AppError, AppErrorCode } from '../../../errors/app-error'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; @@ -29,8 +29,10 @@ import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf'; import { getTeamSettings } from '../../../server-only/team/get-team-settings'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; -import type { TDocumentAuditLog } from '../../../types/document-audit-logs'; -import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; +import { + DOCUMENT_AUDIT_LOG_TYPE, + type TDocumentAuditLog, +} from '../../../types/document-audit-logs'; import { ZWebhookDocumentSchema, mapEnvelopeToWebhookDocumentPayload, @@ -184,69 +186,24 @@ export const run = async ({ const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED; - let certificateDoc: PDF | null = null; - let auditLogDoc: PDF | null = null; + // Pre-fetch all PDF data so we can read dimensions and pass it + // to decorateAndSignPdf without fetching again. + const prefetchedItems = await Promise.all( + envelopeItems.map(async (envelopeItem) => { + const pdfData = await getFileServerSide(envelopeItem.documentData); - if (settings.includeSigningCertificate || settings.includeAuditLog) { - const additionalAuditLogs = [ - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - { - ...envelopeCompletedAuditLog, - id: '', - createdAt: new Date(), - } as TDocumentAuditLog, - ]; + return { envelopeItem, pdfData }; + }), + ); - const certificatePayload = { - envelope: { - ...envelope, - status: finalEnvelopeStatus, - }, - recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients. - fields, - language: envelope.documentMeta.language, - envelopeOwner: { - email: envelope.user.email, - name: envelope.user.name || '', - }, - envelopeItems: envelopeItems.map((item) => item.title), - pageWidth: PDF_SIZE_A4_72PPI.width, - pageHeight: PDF_SIZE_A4_72PPI.height, - additionalAuditLogs, - }; + const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF(); - // Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation. - // This is a temporary toggle while we validate the Konva-based approach. - const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF(); - - const makeCertificatePdf = async () => - usePlaywrightPdf - ? getCertificatePdf({ - documentId, - language: envelope.documentMeta.language, - }).then(async (buffer) => PDF.load(buffer)) - : generateCertificatePdf(certificatePayload); - - const makeAuditLogPdf = async () => - usePlaywrightPdf - ? getAuditLogsPdf({ - documentId, - language: envelope.documentMeta.language, - }).then(async (buffer) => PDF.load(buffer)) - : generateAuditLogPdf(certificatePayload); - - const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([ - settings.includeSigningCertificate ? makeCertificatePdf() : null, - settings.includeAuditLog ? makeAuditLogPdf() : null, - ]); - - certificateDoc = createdCertificatePdf; - auditLogDoc = createdAuditLogPdf; - } + const needsCertificate = settings.includeSigningCertificate; + const needsAuditLog = settings.includeAuditLog; const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = []; - for (const envelopeItem of envelopeItems) { + for (const { envelopeItem, pdfData } of prefetchedItems) { const envelopeItemFields = envelope.envelopeItems.find( (item) => item.id === envelopeItem.id, )?.field; @@ -255,12 +212,70 @@ export const run = async ({ throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); } + let certificateDoc: PDF | null = null; + let auditLogDoc: PDF | null = null; + + if (needsCertificate || needsAuditLog) { + const pdfDoc = await PDF.load(pdfData); + + const { width: pageWidth, height: pageHeight } = getLastPageDimensions(pdfDoc); + + const additionalAuditLogs = [ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + { + ...envelopeCompletedAuditLog, + id: '', + createdAt: new Date(), + } as TDocumentAuditLog, + ]; + + const certificatePayload = { + envelope: { + ...envelope, + status: finalEnvelopeStatus, + }, + recipients: envelope.recipients, + fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + envelopeItems: envelopeItems.map((item) => item.title), + pageWidth, + pageHeight, + additionalAuditLogs, + }; + + const makeCertificatePdf = async () => + usePlaywrightPdf + ? getCertificatePdf({ + documentId, + language: envelope.documentMeta.language, + }).then(async (buffer) => PDF.load(buffer)) + : generateCertificatePdf(certificatePayload); + + const makeAuditLogPdf = async () => + usePlaywrightPdf + ? getAuditLogsPdf({ + documentId, + language: envelope.documentMeta.language, + }).then(async (buffer) => PDF.load(buffer)) + : generateAuditLogPdf(certificatePayload); + + [certificateDoc, auditLogDoc] = await Promise.all([ + needsCertificate ? makeCertificatePdf() : null, + needsAuditLog ? makeAuditLogPdf() : null, + ]); + } + const result = await decorateAndSignPdf({ envelope, envelopeItem, envelopeItemFields, isRejected, rejectionReason, + pdfData, certificateDoc, auditLogDoc, }); @@ -349,12 +364,13 @@ type DecorateAndSignPdfOptions = { envelopeItemFields: Field[]; isRejected: boolean; rejectionReason: string; + pdfData: Uint8Array; certificateDoc: PDF | null; auditLogDoc: PDF | null; }; /** - * Fetch, normalize, flatten and insert fields into a PDF document. + * Normalize, flatten and insert fields into a PDF document. */ const decorateAndSignPdf = async ({ envelope, @@ -362,11 +378,10 @@ const decorateAndSignPdf = async ({ envelopeItemFields, isRejected, rejectionReason, + pdfData, certificateDoc, auditLogDoc, }: DecorateAndSignPdfOptions) => { - const pdfData = await getFileServerSide(envelopeItem.documentData); - let pdfDoc = await PDF.load(pdfData); // Normalize and flatten layers that could cause issues with the signature diff --git a/packages/lib/server-only/pdf/get-page-size.ts b/packages/lib/server-only/pdf/get-page-size.ts index 21d8a1327..8c88912b0 100644 --- a/packages/lib/server-only/pdf/get-page-size.ts +++ b/packages/lib/server-only/pdf/get-page-size.ts @@ -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. @@ -16,3 +22,20 @@ export const getPageSize = (page: PDFPage) => { return cropBox; }; + +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 }; +};