I build a reader-mode Chrome extension called READR. One click turns any cluttered article into a clean reading view, and a Pro feature exports that view as a PDF. The PDF export worked beautifully in testing — and then produced completely blank PDFs on Wikipedia. Every time. No error, no warning, just white pages.
Here's the whole debugging arc, because the real cause turned out to be something I'd never have guessed from the symptom, and the lesson generalises to anyone doing client-side “HTML → PDF” or “HTML → image” in the browser.
The setup
The first version of PDF export used the popular html2canvas + jsPDF combo (via html2pdf.js). The idea is simple: rasterize the reader view into an image, then drop that image into a PDF.
// v1 — the approach that failed
const opt = {
filename: 'article.pdf',
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4' },
};
await html2pdf().set(opt).from(readerElement).save();
On my own test pages, this produced a gorgeous PDF. Ship it.
The symptom
Users exporting real articles — Wikipedia especially — got a blank page. The file downloaded, it was a valid PDF, it just had nothing on it.
My first theories were all wrong, and all plausible:
- Positioning. Maybe the off-screen render target had the wrong coordinates. I tried
position: fixed, thenabsoluteoff-screen, then on-screen behind an overlay. No change. - Scroll offset. html2canvas is sensitive to scroll position; maybe a long, scrolled page threw off the capture. Reset scroll, passed
scrollY: 0. No change. - Image CORS. Cross-origin images can taint a canvas. Set
useCORS: true, waited for images to load. No change.
I burned hours here. Every fix was reasonable. None worked.
The diagnostic that actually cracked it
Instead of guessing, I opened the blank PDF and inspected its internals:
pages: 1
producer: jsPDF
XObject images: False
That last line is the whole story. html2pdf always embeds the canvas as an image object (an “XObject”) in the PDF. There was no image object at all. So the problem wasn't that the image was mis-positioned, or white, or tainted — it was that html2canvas had handed back an empty canvas, and jsPDF dutifully wrote a page with nothing on it.
A blank rasterised PDF with no embedded image = the canvas was empty, not the layout wrong. That reframed everything.
Then the pattern clicked: every page that failed was Wikipedia, GitHub, or a big news site. Every page that worked was a small blog or my own site. The failing sites all had one thing in common — a strict Content-Security-Policy.
Why html2canvas dies under a strict CSP
Here's the part I didn't understand until this bug. html2canvas does not take a screenshot. The browser gives extensions no “screenshot this element” primitive. Instead, html2canvas re-implements a rendering engine in JavaScript: it clones the target into a sandboxed context, reads the computed styles of every node, and paints them onto a <canvas> by hand.
That clone-and-render step is what a strict CSP blocks. On a page like Wikipedia, the policy prevents the machinery html2canvas needs, and rather than throwing, it fails quietly and returns a blank canvas. You get a valid, empty result.
The uncomfortable conclusion: client-side page rasterization is fundamentally fragile on arbitrary websites. You don't control the CSP of the pages your users visit, and a large share of the most-visited sites on the internet ship strict ones. No amount of positioning or CORS tuning fixes a rendering step the page won't allow.
The fix: stop rasterizing
The insight was that I didn't need a picture of the article — I had the article's text and structure already, because my extension extracts it with Readability. So instead of screenshotting the DOM, I generated the PDF directly from the content with jsPDF's text API. No canvas, no clone, no CSP dependency.
// v2 — CSP-independent, works everywhere
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const margin = 54;
const contentWidth = doc.internal.pageSize.getWidth() - margin * 2;
const pageHeight = doc.internal.pageSize.getHeight();
let y = margin;
function writeBlock(text, { size, bold }) {
doc.setFont('times', bold ? 'bold' : 'normal');
doc.setFontSize(size);
for (const line of doc.splitTextToSize(text, contentWidth)) {
if (y > pageHeight - margin) { doc.addPage(); y = margin; }
doc.text(line, margin, y);
y += size * 1.35;
}
y += size * 0.6;
}
for (const el of articleBody.children) {
const tag = el.tagName.toLowerCase();
if (tag === 'h1' || tag === 'h2') writeBlock(el.textContent, { size: 16, bold: true });
else if (tag === 'h3') writeBlock(el.textContent, { size: 13, bold: true });
else writeBlock(el.textContent, { size: 11.5 });
}
doc.save('article.pdf');
This:
- Works on every site, because it never touches the page's rendering pipeline or CSP.
- Produces selectable, searchable text instead of a flat image.
- Makes tiny files.
The trade-off is honest: because I'm rendering structured text rather than a pixel-perfect snapshot, complex visual layouts (Wikipedia's infoboxes, intricate tables) flatten into simpler text. For a reading PDF, that's the right call — and it's the price of working reliably everywhere.
A couple of implementation notes if you go this route: jsPDF's text rendering is synchronous, so on a long article it can freeze the tab — yield to the event loop periodically (await new Promise(r => setTimeout(r)) every N blocks). And for images, you can still embed them best-effort by re-loading each with crossOrigin = 'anonymous', drawing to a canvas, and calling toDataURL() — sites that send permissive CORS headers work, others you skip.
The transferable lessons
- html2canvas is the wrong tool if your code has to run on arbitrary sites. It's great for content you control; it's a liability across the open web, because strict CSPs silently break it.
- Inspect the artifact, not just the symptom. “Blank PDF” was ambiguous. “PDF with no embedded image object” pointed straight at an empty canvas and saved me from another day of positioning theories.
- A false lead that's individually plausible is the most expensive kind. Positioning, scroll, and CORS were all real html2canvas gotchas — which is exactly why I kept chasing them past the point of usefulness. The unlock was stepping back to find what the failing cases had in common (CSP), not debugging any single case harder.
- If you already have the data, don't screenshot the DOM to get it back. I was rasterizing a page to recover text I'd already extracted. Rendering from the source is simpler, faster, more robust, and more accessible.
READR — Reader Mode & PDF Export
A private, local reader mode for Chrome. Clean reading view + PDF export that works on every site. No accounts, no servers.
Install free →