There’s a Web Standard for That
At EdisonOS, we had a feature request that’s pretty standard in a lot of applications - exporting certain data as PDFs. In our case, assessment attempt reports. A student takes a practice test (a full-length test), and their tutor wants a PDF of the report to send to the student or their parents.
Since we’re a web-based application, the typical way to implement this is a server-side job queue. It gives us a lot of control and flexibility, but not without cost. We have to design HTML/CSS templates. We have to keep them in sync with our frontend and design language as they evolve. Every export job becomes expensive, as we have to use a headless browser to render it. That ain’t scalable. The headless browser deployments need to be updated and maintained regularly. And perhaps most importantly, it significantly increases our attack surface. Considering the impact AI has had on cybersecurity lately - and it’s only going to get worse from here on out - this carries extra weight than it usually does.
The alternative is to do it client-side. html2pdf.js is pretty much the standard here. But it has its own drawbacks. It snapshots the DOM and combines the resulting images into a PDF. This means searching and copying text doesn’t work. The resulting PDF won’t be as high-quality, because it’s an image. File sizes will be on the larger side. Multi-page PDFs are much more complex to implement. And most importantly, the user’s machine might lag or even crash due to the resource cost.
Every frontier model I asked kept circling back to variations of these two solutions. What none of them considered was a web standard that has existed since the 90s, is supported by all major browsers, is very efficient resource-wise, and requires very little maintenance. I shipped this feature with <300 LoC using Print CSS.
What the browser already gives us
Every browser can print a page, and every browser can “print” to a PDF. At the end of the day, the browser just has to render the same thing to a fixed viewport, with pagination.
There’s a catch though. We offer incredibly detailed statistics and data analysis of the student’s performance, and as such, a certain level of interactability is crucial for good UX. Our reports have tabs, dropdowns, scroll containers, an interactive heatmap and so on, all made for a digital screen. We also offer reports for various cohorts of students. If you just hit “Print”, you basically get screenshots of a website split across a few pages. Not good, not good at all.
So we need a separate version of the report, a document that’s actually made for paper.
Printing something that isn’t on screen
When the user clicks “Export PDF”, we render a printable version of the report through a React portal (into document.body). It uses the same components our frontend already uses, same charts and everything, just laid out like a document. Since the components are shared, we don’t have to maintain two different versions of them. Changes to our frontend are reflected in the PDF’s design straightaway.
This version is hidden off-screen. We set it to be as exactly as wide as an A4 sheet, so the charts render at the size they’ll be printed at. While printing, we hide the rest of the app. So only this version shows up in the PDF.
Waiting for the document to be ready
window.print() prints whatever’s on the screen right then. If the logo hasn’t loaded by then for whatever reason, we don’t get any logos in the PDF. So we wait for three things before printing:
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
await document.fonts.ready;
await Promise.all(
[...document.querySelectorAll<HTMLImageElement>(".print-report-document img")].map(
(image) => image.decode().catch(() => undefined),
),
);First, two animation frames. React mounting the portal doesn’t mean the browser has painted it. requestAnimationFrame runs its callback right before the next paint, so we call it twice. By the time the second callback runs, the document has been painted at least once.
Next up, fonts. If our web fonts haven’t loaded yet, the browser prints with the fallback font. document.fonts.ready waits for them.
Finally, images. The academy’s logo is a plain <img> with a presigned URL. We skip Next.js’s image optimisation here, the optimised URL can expire before the user prints. image.decode() waits until the image is loaded and ready to be drawn. If the logo fails to load, we print anyway. A missing logo shouldn’t block the export.
Now we can print:
document.title = `${assessmentTitle} — ${studentName}`;
document.body.classList.add("printing-report-document");
try {
window.print();
} finally {
document.body.classList.remove("printing-report-document");
document.title = previousTitle;
}Our print CSS only kicks in when <body> has the printing-report-document class. It hides everything except our document with display: none. Note that visibility: hidden won’t work here, the app gets hidden but still takes up space. You’ll get a few blank pages before the report starts.
We also swap document.title while printing, because that’s the file name the browser suggests in the save dialog. Very important to not suggest something like “Dashboard.pdf”. Once print() returns, we restore both.
What Print CSS can do
Print CSS can do a lot more than you’d expect! You can set the paper size and margins. Each module starts on a new page. Charts and cards don’t get split across two pages, and headings don’t get stranded at the bottom of a page. Long tables repeat the header row on every page. Try doin’ that with html2pdf.js!
The best part is I didn’t have to write much CSS at all. My teammates write good UI code, very composable. I set the general direction, and gpt-5.6 composed the rest with very little code.
After all of that, we do a bit of cleanup. Expanding dropdowns/truncated text, removing buttons, disabling animations in interactable components like charts, stuff like that.
At the end, we have a clean PDF that looks good, and takes pretty much no additional resources to render. If their machine can power the browser to render it on screen, it can render this just fine.
Sunshine and rainbows, for the most part
It’s not perfect though. The user does have to go through the browser’s print dialog. This makes bulk exports of PDFs admittedly a hassle. And unlike the Playwright route, we have to support multiple browsers’ print engines. Looking at you, WebKit! :sigh:
For our use case - a tutor downloading a report and sending it - none of that really mattered. It took about three hundred lines of CSS. Web devs tend to complicate things a lot, a habit that unfortunately LLMs have picked up from them. Most problems are simple, so are good solutions.
PS: Thanks Wikipedia for introducing me to Print CSS, I used to print a ton of articles to read when I was in school. I kinda don’t remember most of it, but hey, at least I remembered that this standard exists :P