Skip to main content

PDF Export Pipeline

Source: src/app/[locale]/[id]/pdf/route.ts

The PDF export route converts a rendered activity declaration or information clause into a downloadable PDF. It reuses the same HTML generation functions as the HTML route, then pipes the output through Playwright and pdf-lib.

Prerequisites

Two runtime constraints are set at the top of the route file:

export const runtime = "nodejs"; // Edge runtime cannot run Playwright
export const dynamic = "force-dynamic"; // never cache — each PDF is generated fresh

Feature flag: the route returns 404 unless ENABLE_SERVER_PDF=true is set in the environment.


Request

GET /{locale}/{activityId}/pdf?type=activity-declaration|activity-information

type defaults to activity-information when omitted.


Full request lifecycle

Step 1 — Turnstile verification

Every PDF request must pass a Cloudflare Turnstile CAPTCHA challenge before any generation starts.

  • No token present: the route returns an HTML page with an embedded Turnstile widget. The widget POSTs back to the same URL with the token once the challenge is solved.
  • Token present: the route calls verifyTurnstileToken(request). An invalid or expired token returns 401/403.
  • Valid token: generation proceeds.

Step 2 — HTML generation

The route calls either generateActivityDeclaration or generateActivityInformation (from src/lib/html/). Both functions:

  1. Extract shortName from the request hostname via getHostnameServer().
  2. Fetch the Organization document by shortName.
  3. Find the ROPA reference in Organization.ropas[] matching the target locale.
  4. Fetch the Ropa document.
  5. Find the activity in ous[].activities[] by activityId.
  6. Load the template via reloadTemplateRO() — see template lookup.
  7. Build the context object (translated enum values, partner names, logo as base64 data URL, etc.).
  8. Compile and render the Handlebars template.
  9. Return { isError, data: htmlString, metadata: { orgShortname, orgLongName, activityId, activityName, locale } }.

Step 3 — Playwright: HTML → PDF

const browser = await playwrightChromium.launch({
args: chromium.args,
executablePath: await resolveExecutablePath(chromium),
headless: true,
});

const page = await browser.newPage();
await page.setContent(htmlString, { waitUntil: "networkidle" });

const pdfBuffer = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", right: "15mm", bottom: "20mm", left: "15mm" },
});

Executable resolution (resolveExecutablePath): checks CHROME_EXECUTABLE_PATH, then PUPPETEER_EXECUTABLE_PATH, then the bundled path from @sparticuz/chromium. Throws if none are found.

browser.close() is always called in a finally block to prevent resource leaks.

Step 4 — pdf-lib: metadata injection

const pdfDoc = await PDFDocument.load(pdfBuffer);

pdfDoc.setTitle(`${orgShortname}-${activityName} Art. 30 Declaration`);
pdfDoc.setAuthor("GDPR Labs");
pdfDoc.setSubject("GDPR Article 30 Processing Activity Declaration");
pdfDoc.setKeywords(["GDPR", "article 30", "declaration", "ROPA", orgLongName]);
pdfDoc.setCreator("Register of Processing Activities by GDPR Labs");
pdfDoc.setProducer("Register of Processing Activities by GDPR Labs");
pdfDoc.setCreationDate(new Date());
pdfDoc.setModificationDate(new Date());

const finalPdf = await pdfDoc.save();

Step 5 — Response

Content-Type: application/pdf
Content-Disposition: inline; filename="{shortName}-{decl|info}-{activityId}-{locale}.pdf"
Cache-Control: private, max-age=0, must-revalidate

Example filename: acme-decl-42-en.pdf (declaration, activity 42, English).


Error responses

ConditionStatus
ENABLE_SERVER_PDF not set404
Turnstile token missing200 (HTML challenge page)
Turnstile token invalid401 / 403
Activity not found404
HTML generation failure500
Missing Turnstile configuration500