fix: match cert and audit log page dimensions to source document (#2473)
168
.agents/plans/fresh-gold-rock-cert-page-width-mismatch.md
Normal file
|
|
@ -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<string, { certificateDoc, auditLogDoc }>` 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
|
||||||
51
assets/a4-size.pdf
Normal file
|
|
@ -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 [<B051F100F1EED01A592FC6119F589603> <B051F100F1EED01A592FC6119F589603>]
|
||||||
|
>>
|
||||||
|
startxref
|
||||||
|
378
|
||||||
|
%%EOF
|
||||||
51
assets/letter-size.pdf
Normal file
|
|
@ -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
|
||||||
51
assets/tabloid-landscape.pdf
Normal file
|
|
@ -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
|
||||||
218
packages/app-tests/e2e/envelopes/cert-page-dimensions.spec.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -298,18 +298,34 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||||
*
|
*
|
||||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
* 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 { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { token: apiToken } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
const envelope = await seedAlignmentTestDocument({
|
const envelope = await seedAlignmentTestDocument({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
recipientName: user.name || '',
|
recipientName: user.name || '',
|
||||||
recipientEmail: user.email,
|
recipientEmail: user.email,
|
||||||
insertFields: true,
|
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 token = envelope.recipients[0].token;
|
||||||
|
|
||||||
const signUrl = `/sign/${token}`;
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 246 KiB |
|
|
@ -15,11 +15,11 @@ import { groupBy } from 'remeda';
|
||||||
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
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 { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-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 { prisma } from '@documenso/prisma';
|
||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
|
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 { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||||
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
||||||
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
|
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 { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
||||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||||
import type { TDocumentAuditLog } from '../../../types/document-audit-logs';
|
import {
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
DOCUMENT_AUDIT_LOG_TYPE,
|
||||||
|
type TDocumentAuditLog,
|
||||||
|
} from '../../../types/document-audit-logs';
|
||||||
import {
|
import {
|
||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
mapEnvelopeToWebhookDocumentPayload,
|
mapEnvelopeToWebhookDocumentPayload,
|
||||||
|
|
@ -184,69 +186,24 @@ export const run = async ({
|
||||||
|
|
||||||
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
||||||
|
|
||||||
let certificateDoc: PDF | null = null;
|
// Pre-fetch all PDF data so we can read dimensions and pass it
|
||||||
let auditLogDoc: PDF | null = null;
|
// 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) {
|
return { envelopeItem, pdfData };
|
||||||
const additionalAuditLogs = [
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
);
|
||||||
{
|
|
||||||
...envelopeCompletedAuditLog,
|
|
||||||
id: '',
|
|
||||||
createdAt: new Date(),
|
|
||||||
} as TDocumentAuditLog,
|
|
||||||
];
|
|
||||||
|
|
||||||
const certificatePayload = {
|
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation.
|
const needsCertificate = settings.includeSigningCertificate;
|
||||||
// This is a temporary toggle while we validate the Konva-based approach.
|
const needsAuditLog = settings.includeAuditLog;
|
||||||
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 newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||||
|
|
||||||
for (const envelopeItem of envelopeItems) {
|
for (const { envelopeItem, pdfData } of prefetchedItems) {
|
||||||
const envelopeItemFields = envelope.envelopeItems.find(
|
const envelopeItemFields = envelope.envelopeItems.find(
|
||||||
(item) => item.id === envelopeItem.id,
|
(item) => item.id === envelopeItem.id,
|
||||||
)?.field;
|
)?.field;
|
||||||
|
|
@ -255,12 +212,70 @@ export const run = async ({
|
||||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
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({
|
const result = await decorateAndSignPdf({
|
||||||
envelope,
|
envelope,
|
||||||
envelopeItem,
|
envelopeItem,
|
||||||
envelopeItemFields,
|
envelopeItemFields,
|
||||||
isRejected,
|
isRejected,
|
||||||
rejectionReason,
|
rejectionReason,
|
||||||
|
pdfData,
|
||||||
certificateDoc,
|
certificateDoc,
|
||||||
auditLogDoc,
|
auditLogDoc,
|
||||||
});
|
});
|
||||||
|
|
@ -349,12 +364,13 @@ type DecorateAndSignPdfOptions = {
|
||||||
envelopeItemFields: Field[];
|
envelopeItemFields: Field[];
|
||||||
isRejected: boolean;
|
isRejected: boolean;
|
||||||
rejectionReason: string;
|
rejectionReason: string;
|
||||||
|
pdfData: Uint8Array;
|
||||||
certificateDoc: PDF | null;
|
certificateDoc: PDF | null;
|
||||||
auditLogDoc: 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 ({
|
const decorateAndSignPdf = async ({
|
||||||
envelope,
|
envelope,
|
||||||
|
|
@ -362,11 +378,10 @@ const decorateAndSignPdf = async ({
|
||||||
envelopeItemFields,
|
envelopeItemFields,
|
||||||
isRejected,
|
isRejected,
|
||||||
rejectionReason,
|
rejectionReason,
|
||||||
|
pdfData,
|
||||||
certificateDoc,
|
certificateDoc,
|
||||||
auditLogDoc,
|
auditLogDoc,
|
||||||
}: DecorateAndSignPdfOptions) => {
|
}: DecorateAndSignPdfOptions) => {
|
||||||
const pdfData = await getFileServerSide(envelopeItem.documentData);
|
|
||||||
|
|
||||||
let pdfDoc = await PDF.load(pdfData);
|
let pdfDoc = await PDF.load(pdfData);
|
||||||
|
|
||||||
// Normalize and flatten layers that could cause issues with the signature
|
// Normalize and flatten layers that could cause issues with the signature
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { PDFPage } from '@cantoo/pdf-lib';
|
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.
|
* Gets the effective page size for PDF operations.
|
||||||
|
|
@ -16,3 +22,20 @@ export const getPageSize = (page: PDFPage) => {
|
||||||
|
|
||||||
return cropBox;
|
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 };
|
||||||
|
};
|
||||||
|
|
|
||||||