How to generate beautiful-looking PDFs

in Single Page Applications

by

Silvan Mühlemann

CTO, Mitgründer

by

Silvan Mühlemann

CTO, Mitgründer

by

Silvan Mühlemann

CTO, Mitgründer

Lesezeit

3

Minuten

veröffentlicht

27.08.2025

teilen

email icon
x icon
facebook icon
copy icon

One of the topics that I keep on running into in the past ten years is generating beautiful-looking, printable documents out of web-applications.

In the times before XMLHttpRequest life was easy: We generated an HTML page using Symfony or Zend Framework and ran it through wkthmltopdf — a headless browser — to convert it to a PDF.

Now, in 2022, most web applications we build are split between frontend and backend. In our case, the frontend is a Single Page Application built with Vue.js and TypeScript. The backend is built with Django. Communication goes via GraphQL or REST.

We recently had a case for the product realmatch360.com, a PropTech solution using big-data to give insights on a potential home-owner’s willingness to pay. It generates colorful diagrams in the browser using ChartJS.

Now, how can we allow the user to print those reports?

Firefox renders differently than Chrome 🤦‍♂️

Our initial idea was straightforward: we simply use CSS3 Media Queries (@media print) to create a print-optimized version of the rendered document and use window.print() to print the page.

Unfortunately, the output looked different in each browser: Perfect in Chrome, but not-so-nice in Firefox.

Firefox on the right does implement page break CSS rules differently

Firefox on the right does implement page break CSS rules differently

Moreover, it was not possible to control header and footer of the print version.

How can we get a consistent, good-looking output regardless of the user’s browser?

I remembered the old age: We could use the headless browser approach. This would require that the document is generated on the server. But on the server, we would have to restore the state of the page as the user sees it in the client. In our case, this would mean that we would have to pass various arguments from the client to the server, start a headless browser, e.g. Chromium, to generate the same page as the user is seeing locally.

This feels not elegant, as the page would be generated twice: Once in the user’s browser. And once on the server.

So, we came up with a creative solution: We tried to send the generated content as HTML markup of the already rendered page from the client to the server:

var htmlSource = document.documentElement.outerHTML;
axios.post('/get-pdf', {
  htmlSource
})

Simplified version of the client-side code

The server receives the HTML and converts it to a PDF using pyppeteer (the Python port of puppeteer). Here’s a simplified version of the code in JavaScript. The full production code can be found at the end of the article 👇

app.post('/get-pdf', async (req, res) => {
  const puppeteer = require('puppeteer');
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(req.body.htmlSource)
  const pdfFile = await page.pdf({format: 'A4'})
  // Save the file or send it to the client
})

Simplified version of the server-side code

This is how the process looks like:

This worked surprisingly well.

Gotchas ahead! 🚨

The Chart.js graphs were rendered as bitmap in an HTML Canvas element. It was not possible to serialize this bitmap and send it to the server.

The solution was to send the base64 encoded PNG image of the charts to the server instead of Canvas.

Second problem: Resources were not found, the reason was the relative URLs. In the context of the server, they were pointing to nowhere.

The solution was to add a <base href="//url-of-the-app.com"> in the head of the document.

Once these problems were solved, the solution was production-ready. Even things like Fonts or SVG content were rendered correctly in the PDF. 🙌🏻

If you want to try this yourself, check out this proof of concept and look at the code on GitHub.

Thanks to Alexey Matsuk for the review of the article.

Appendix

This is the code we finally ended using in production on the server side. It works reliably for two months now.

from pyppeteer import launch
from django.core.files.images import ImageFile

async def generate_pdf_file(html_source: str, options: dict) -> ImageFile:
    browser = await launch(
        options={'args': ['--no-sandbox', '--disable-setuid-sandbox']})
    page = await browser.newPage()
    await page.setJavaScriptEnabled(False)
    await page.setViewport(
        {
            'width': 1512,
            'height': 1112,
        }
    )
    await page.emulateMedia('print')
    await page.goto(
        f'data:text/html,{html_source}', options={'waitUntil': 'networkidle0'})
    pdf = await page.pdf(options=options)
    await browser.close()
    return ImageFile(io.BytesIO(pdf))

by

Silvan Mühlemann

CTO, Mitgründer

by

Silvan Mühlemann

CTO, Mitgründer

by

Silvan Mühlemann

CTO, Mitgründer

by

Silvan Mühlemann

CTO, Mitgründer

veröffentlicht

27.08.2025

teilen

email icon
x icon
facebook icon
copy icon

Aktuelle Artikel

Aktuelle Artikel

Aktuelle Artikel

Bereit

durchzustarten?

Bereit

durchzustarten?

Bereit

durchzustarten?