PDF export with window.print() and Puppeteer: a 3-day debugging diary
On Lìonra, my free resume builder, PDF export relies on window.print() rather than html2canvas + jsPDF or a template-based service. That choice isn't arbitrary: html2canvas doesn't faithfully render complex CSS (custom fonts, gradients, grid backgrounds), and a template-based tool would mean duplicating every resume theme by hand.
This post documents the full journey: a mobile blank page bug, its fix, and what happened when I moved to server-side PDF generation with Puppeteer.
Part 1 : The mobile blank page
The starting code
function exportPDF() {
const preview = document.getElementById("preview");
const contentH = preview.scrollHeight;
const scale = Math.min(1123 / contentH, 1);
Array.from(preview.children).forEach(el => {
el.style.zoom = scale;
});
window.print();
}
@media print {
html, body {
width: 210mm !important;
height: 297mm !important;
overflow: hidden !important;
}
.preview {
width: 210mm !important;
height: 297mm !important;
overflow: hidden !important;
}
@page { size: A4 portrait; margin: 0; }
}
Worked perfectly on desktop Chrome and Edge. On iOS Safari and Chrome Android: a blank second page appeared every time.
Round 1 False lead: the zoom calculation
First instinct: the PDF button is reachable from any mobile tab. If the user taps it while the Editor tab is active, the preview panel has display: none, so scrollHeight returns a wrong value.
const wasPanelHidden = previewPanel.classList.contains("panel-hidden");
if (wasPanelHidden) {
previewPanel.classList.remove("panel-hidden");
preview.offsetHeight; // force reflow
}
Result: still a blank page. The fix was worth keeping (it corrected a real latent bug), but it wasn't the root cause.
Round 2 The real cause: fixed height + overflow:hidden in print
body { height: 297mm !important; overflow: hidden !important; }
This is a well-documented anti-pattern that I didn't know about. On mobile print engines (WebKit iOS, Chrome Android), a fixed height combined with overflow: hidden causes the browser to reserve an extra blank page as a safety measure, even when content doesn't actually overflow.
There's a second factor: body had min-height: 100vh from screen mode. On mobile, 100vh can far exceed 297mm. And min-height always wins over height when it's greater, not a specificity issue, just box model math.
Round 3 Radical fix, incomplete
@media print {
html, body {
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
}
.preview {
height: auto !important;
overflow: visible !important;
}
}
No more blank page. But now the resume doesn't fill the A4 sheet ,a large white band appears at the bottom.
Round 4 The actual fix
Key insight: not every element plays the same role. html, body, intermediate containers → height: auto. .preview, the only element carrying the theme background → fixed 297mm, safe because its content is already scaled down by the JS zoom.
@media print {
html, body {
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
}
.workspace, .preview-panel {
height: auto !important;
overflow: visible !important;
}
.preview {
width: 210mm !important;
height: 297mm !important;
min-height: 297mm !important;
overflow: hidden !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
@page { size: A4 portrait; margin: 0; }
}
Validated with Playwright across 3 scenarios: Editor tab active, Preview tab active, artificially bloated content. Zero blank pages injected.
Part 2 : Moving to Puppeteer for server-side generation
window.print() depends on the user's browser behavior. I wanted a consistent, server-controlled PDF. Enter Puppeteer.
The unit trap: 595px vs 794px
First mistake: using 595px for the viewport width.
- 595px = A4 in PDF points (72dpi)
- 794px = A4 in CSS pixels (96dpi, what Chromium actually uses)
Puppeteer renders in CSS pixels. Using 595px gives you a viewport 25% too narrow. All scrollHeight measurements are wrong, and the content looks cramped.
const A4_W = 794; // CSS px at 96dpi
const A4_H = 1123;
await page.setViewport({ width: A4_W, height: 4000 });
For page.pdf(), use format: "A4" or explicit mm, never px values, as Puppeteer interprets those as PDF points.
The white band problem
Even with the correct viewport, a white band appeared on the right side of the PDF. Diagnosis:
// Puppeteer-side debug
const debug = await page.evaluate(() => {
const el = document.querySelector("#_cv-root > *");
return {
offsetWidth: el.offsetWidth, // 794 ✓
computedMaxW: getComputedStyle(el).maxWidth, // "720px" ✗
computedMargin: getComputedStyle(el).margin, // "0px auto" ✗
};
});
The .preview CSS class had max-width: 720px; margin: 0 auto. Even though inline styles forced width: 794px, the max-width from the stylesheet still applied (inline styles can't override a stylesheet max-width when the inline width is already at its max).
Fix in the Puppeteer HTML:
/* Injected after ${css} in measureHtml */
.preview, .pdf-export {
width: ${A4_W}px !important;
max-width: ${A4_W}px !important;
margin: 0 !important;
}
CSS zoom leaves white bands
Once width was fixed, the zoom strategy for tall content created new white bands:
// WRONG: CSS zoom reduces content visually but not its layout footprint
document.documentElement.style.zoom = zoom;
CSS zoom scales the element visually but the element still occupies its original size in the document flow. Combined with format: "A4", Puppeteer centers the zoomed content → white bands on right and bottom.
The iterative padding reduction strategy
The real solution: instead of scaling the whole element, progressively reduce internal spacing until content fits A4_H:
let currentH = await page.evaluate(() =>
document.getElementById("_cv-root").scrollHeight
);
if (currentH > A4_H) {
let iteration = 0;
while (currentH > A4_H && iteration < 15) {
await page.evaluate(() => {
const cv = document.querySelector("#_cv-root > *");
// Reduce wrapper padding
const pt = parseFloat(getComputedStyle(cv).paddingTop);
const pb = parseFloat(getComputedStyle(cv).paddingBottom);
cv.style.paddingTop = Math.max(4, pt - 10) + "px";
cv.style.paddingBottom = Math.max(4, pb - 10) + "px";
// Reduce section margins
cv.querySelectorAll(".preview-section").forEach(s => {
const mb = parseFloat(getComputedStyle(s).marginBottom);
s.style.marginBottom = Math.max(2, mb - 2) + "px";
});
// Reduce card padding
cv.querySelectorAll(".item-card").forEach(c => {
const pt2 = parseFloat(getComputedStyle(c).paddingTop);
c.style.paddingTop = Math.max(3, pt2 - 2) + "px";
c.style.paddingBottom = Math.max(3, pt2 - 2) + "px";
});
});
currentH = await page.evaluate(() =>
document.getElementById("_cv-root").scrollHeight
);
iteration++;
}
// Zoom only as last resort if still overflowing
if (currentH > A4_H) {
const zoom = A4_H / currentH;
await page.evaluate(({ zoom, A4_W, A4_H }) => {
document.documentElement.style.zoom = zoom;
document.body.style.width = A4_W + "px";
document.body.style.height = A4_H + "px";
document.body.style.overflow = "hidden";
}, { zoom, A4_W, A4_H });
currentH = A4_H;
}
}
// Fill bottom gap with minHeight (preserves element positions)
if (currentH < A4_H) {
await page.evaluate(({ A4_H }) => {
const cv = document.querySelector("#_cv-root > *");
cv.style.minHeight = A4_H + "px";
}, { A4_H });
}
Width stays at 794px throughout. No zoom, no white bands.
Key takeaways
-
Never lock
htmlorbodyto a fixed height in@media printit causes blank pages on mobile, even when content fits. -
min-heightalways beatsheightin box model calculation, regardless of!importanton both sides. -
Only the background-bearing element needs a fixed A4 height neutral containers stay at
height: auto. - Puppeteer renders at 96dpi use 794×1123px for A4, never 595×842px.
- CSS zoom ≠ layout reduction it scales visually but not in the flow; use iterative spacing reduction instead.
-
minHeightto fill, notpaddingBottomadding padding pushes absolutely-positioned elements (like a location footer) off-screen. - Always test the overflow edge case fixing the normal case can break the "content too tall" scenario.
Building Lìonra in public. If you're working on print CSS or Puppeteer PDF generation and hitting similar issues, feel free to reach out.
Top comments (0)