Provide pixel-perfect, offline-first, ATS-friendly PDF export using headless Chromium with minimal dependencies. This spec supports resume templates, one-page fit, and Swiss design system styling in a Next.js + Tailwind codebase.
- ATS-friendly: PDFs must be text-based (selectable text).
- Offline-first: Runs locally with no external network reliance.
- Minimal dependencies: Only
playwright+ Chromium. - Swiss styling: Maintain typography hierarchy and hard-edged framing.
- Pixel-perfect output (matches on-screen HTML/CSS).
- Text remains real → ATS extraction works.
- Template-friendly via dedicated print routes.
- Local-only once Chromium is installed.
- html2canvas + jsPDF: Rasterizes DOM → not ATS-friendly.
- jsPDF (text-based): ATS-friendly but requires rebuilding layout manually.
- Browser print: Lightweight but inconsistent across environments and harder to lock pixel-perfect output.
PDF styles must preserve Swiss design intent while remaining clean and ATS-friendly.
- Typography: Serif headings, sans body, mono metadata.
- Borders: 1px black.
- Radius: none.
- Shadows: removed in PDF.
- Backgrounds: white, no textures or grids.
Add a dedicated printable route:
/print/resumes/[id]?template=default
Render only the resume content (no chrome). This is the source that headless Chromium captures.
Add:
GET /api/v1/resumes/{id}/pdf?template=default
Backend loads the printable route in Chromium and returns PDF bytes.
Viewer and builder download buttons call the backend PDF endpoint and stream the file.
Add a template prop/class on the Resume component and create template-specific CSS overrides.
Viewer and builder download buttons call the backend PDF endpoint and stream the file.
Add a template prop/class on the Resume component and create template-specific CSS overrides.
Add:
templateprop or class (e.g.,resume-template-default).resume-bodywrapper class.
Add:
/print/resumes/[id]page to render resume only.
Add:
- Playwright-based renderer.
/api/v1/resumes/{id}/pdfendpoint.
- Launch Chromium via Playwright.
- Load local route:
http://localhost:3000/print/resumes/{id}?template=... - Wait for
.resume-printand fonts (document.fonts.ready). - Export PDF with
format: A4,print_background: true, standard margins.
- No rasterization.
- Semantic HTML headings/lists.
- No SVG text.
- Standard fonts and readable spacing.
The PDF rendering system also supports cover letter generation using the same headless Chromium approach.
- URL:
/print/cover-letter/[id]?pageSize=A4|LETTER - Selector:
.cover-letter-print - API Endpoint:
GET /api/v1/resumes/{id}/cover-letter/pdf
- Backend calls
render_resume_pdf(url, pageSize, selector=".cover-letter-print") - Playwright navigates to
/print/cover-letter/{id}?pageSize={pageSize} - Frontend print page fetches cover letter data via API (
GET /resumes?resume_id={id}) - Page renders with
.cover-letter-printclass - Playwright captures PDF
The PDF system includes intelligent page break handling to prevent layout issues.
/* Individual items stay together */
.resume-item {
break-inside: avoid;
page-break-inside: avoid;
}
/* Section headers stay with first content */
.resume-section-title,
.resume-section-title-sm {
break-after: avoid;
page-break-after: avoid;
}
/* First content after header stays with header */
.resume-section-title + .resume-items > *:first-child,
.resume-section-title + p,
.resume-section-title + ul {
break-before: avoid;
page-break-before: avoid;
}The usePagination hook in components/preview/use-pagination.ts calculates page breaks:
- Individual Items:
.resume-itemand[data-no-break]elements are kept together - Section Headers: Section titles are kept with their first content element
- Minimum Fill: Pages must be at least 50% filled before breaking to a new page
Key Algorithm:
- Finds all section titles (
.resume-section-title,.resume-section-title-sm) - For each title, locates the first content element (
.resume-item,<p>,<ul>, etc.) - Creates an "unbreakable zone" from the title top to the first content bottom
- Page breaks are moved before this zone if it would be split
An "orphaned header" occurs when a section title appears at the bottom of a page with its content starting on the next page. This is prevented by:
- CSS:
break-after: avoidon section titles - JS Pagination: Treating header + first content as a single unit
- Minimum Content: Ensuring at least the first item stays with its header
IMPORTANT: When adding new printable content types (like cover letters), you MUST add CSS visibility rules in globals.css.
The print media query hides ALL content by default:
@media print {
body * {
visibility: hidden !important;
}
}To make content visible in PDFs, add the class to the visibility whitelist:
@media print {
.resume-print,
.resume-print *,
.cover-letter-print,
.cover-letter-print * {
visibility: visible !important;
}
.resume-print,
.cover-letter-print {
width: 100% !important;
max-width: 210mm !important;
min-height: 297mm !important;
/* ... other print styles */
}
}If you forget this step, Playwright will generate blank PDFs because the content remains hidden.
When adding a new printable document type (e.g., .report-print):
- Create the print route:
/print/reports/[id]/page.tsx - Use a unique class:
className="report-print bg-white" - Add to globals.css visibility rules:
.resume-print, .resume-print *, .cover-letter-print, .cover-letter-print *, .report-print, .report-print * { visibility: visible !important; }
- Update
render_resume_pdf()call withselector=".report-print"
If you see an error like:
net::ERR_CONNECTION_REFUSED at http://localhost:3000/print/resumes/...
This means the backend cannot connect to the frontend for PDF generation. The backend uses Playwright to render the frontend's print page and capture it as a PDF.
Cause: The FRONTEND_BASE_URL in your backend .env file doesn't match where your frontend is actually running.
Fix:
-
Check which port your frontend is running on (default is 3000, but it may use 3001, 3002, etc. if port 3000 is busy)
-
Update your backend
.envfile:# Update this to match your frontend's actual URL FRONTEND_BASE_URL=http://localhost:3001 # Also update CORS to include the new port CORS_ORIGINS=["http://localhost:3001", "http://127.0.0.1:3001"]
-
Restart the backend server
Note: The backend now provides a helpful error message when this occurs, explaining exactly what URL it tried to connect to and how to fix it.
If PDFs are generated but appear blank, see the Critical CSS Requirement section above.
Headless Chromium provides pixel-perfect, ATS-friendly PDFs while keeping the app local. It adds one dependency (playwright) and a Chromium install step but avoids client-side rendering inconsistencies.