Skip to content

WeasyPrint JavaScript support: why your charts and dynamic content come out as empty boxes

Your Django template looks perfect in the browser and the PDF comes out with a blank rectangle where the chart should be. That is not a bug and no configuration flag will fix it: WeasyPrint runs no JavaScript at all, deliberately. Here is what the documentation actually says, why the decision was made, the four ways to get a chart into the PDF anyway, and the point at which you should stop fighting it.

§ Live demo

Runs in your browser. Nothing is uploaded.

Your PDF is downloading. Want this as one API call, with the page archived too?

The report looks right in Chrome. Every number is in place, the layout is clean, the chart is exactly where the designer put it. Then you pass the same template to WeasyPrint and the PDF comes back with a blank rectangle in that spot, or nothing at all. Nobody changed the CSS, and no error was raised. This is the single most common WeasyPrint complaint, and it has one cause and no configuration fix.

The short version

WeasyPrint does not execute JavaScript, and that is deliberate rather than an oversight. It is not built on a browser engine, so there is no script runtime to run your chart library. Anything drawn after page load, whether that is Chart.js, Plotly, ApexCharts, D3, or a React component that hydrates client side, will not appear in the PDF. WeasyPrint renders the HTML as it was delivered and stops. The fix is to make the chart exist in the markup before WeasyPrint sees it, or to render through something that does run scripts.

Does WeasyPrint support JavaScript?

No. The project documentation states it directly, in a passage explaining why building WeasyPrint was a tractable project at all: in WeasyPrint "there is no user-interaction, no JavaScript, no live rendering (the document doesn't changed after it was first parsed)". The README is equally clear about the architecture behind that, noting that WeasyPrint "is based on various libraries but not on a full rendering engine like WebKit or Gecko."

Those two sentences explain the whole behavior. A browser is a rendering engine plus a JavaScript engine plus a network stack plus an event loop, and keeping all of that in sync is most of what makes browsers enormous. WeasyPrint deliberately took only the first part. It parses your HTML and CSS once, lays it out, and writes a PDF. There is no moment after parsing at which a script could run, because there is no script engine and no second pass.

This is why WeasyPrint installs in seconds, runs in a small container, and renders a page in milliseconds rather than seconds. The speed and the missing chart are the same design decision viewed from two sides.

Why is my Chart.js chart blank in WeasyPrint?

Because a Chart.js chart does not exist in your HTML. What exists is an empty <canvas> element and a script that would have painted into it. In a browser, the script runs, the canvas fills, and you see a chart. WeasyPrint parses the same markup, finds an empty canvas with no content, and renders it faithfully as an empty box sized by your CSS.

The same reasoning covers every variant of this report. Plotly and ApexCharts inject SVG into a container element on load, so WeasyPrint renders the empty container. D3 builds its visualization by manipulating the DOM after the page is parsed, so WeasyPrint renders the DOM before any of that happened. A React or Vue single page app serves a nearly empty <div id="root">, so WeasyPrint renders a nearly empty page, which is why people report getting a PDF containing only the header and footer. Content fetched by a client side request has the same problem for a slightly different reason: WeasyPrint never issues that request.

A useful diagnostic before you change anything: open the page, view source, and look at the raw HTML the server sent rather than the inspector, which shows you the DOM after scripts have run. If your chart is not in view source, WeasyPrint will not render it.

How do I render charts in a WeasyPrint PDF?

There are four routes, and three of them keep WeasyPrint.

Generate the chart as an image server side. This is the standard answer in the Python world and it works well. Draw the chart with matplotlib in your view, save it to an in-memory buffer, base64 it, and drop it into the template as a data URI. Nothing to fetch, nothing to run.

import base64, io
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(6, 3))
ax.bar(months, revenue)
buf = io.BytesIO()
fig.savefig(buf, format='svg', bbox_inches='tight')
plt.close(fig)

chart_uri = 'data:image/svg+xml;base64,' + base64.b64encode(buf.getvalue()).decode()

Save as SVG rather than PNG where you can. WeasyPrint handles SVG well, the chart stays sharp at print resolution, and the file stays small. PNG is the fallback when your chart uses a feature the SVG output mangles.

Render the chart as static SVG in your own code. If matplotlib is heavier than you want, you can emit the SVG directly. A bar chart or a sparkline is a handful of <rect> or <path> elements computed from your data, and writing that in a template tag gives you full control over the styling with none of the dependencies. Libraries like pygal exist for exactly this and output SVG by default.

Build the chart in CSS. Overlooked, and genuinely good for simple cases. A horizontal bar chart is a list of divs with percentage widths. A progress ring is one SVG circle with a computed stroke-dasharray. WeasyPrint supports flexbox, grid and custom properties, so a surprising amount of chart-shaped work is achievable with layout alone, and it prints as crisp vector output with zero extra libraries.

Pre-render the page in a real browser, then convert. If the chart genuinely must come from a JavaScript library, and it often must when the same visualization has to appear in both the web app and the PDF, then something has to run that library. That means Playwright, Puppeteer, or a hosted renderer that runs Chromium for you. At that point WeasyPrint is no longer in the pipeline, and the honest question is whether keeping two rendering paths is worth it.

Whichever route you pick, note what they have in common: the chart has to be settled before the template renders, not after. In practice that moves the work into your view function, where the aggregates that feed the chart come out of a warehouse query rather than out of a browser. That is usually a cleaner architecture anyway, because the PDF and the emailed summary and the API response all end up reading from the same computed numbers.

What else does WeasyPrint not support?

JavaScript is the headline, but a few smaller gaps catch people out mid-project, and all of them are documented in the CSS support reference rather than hidden.

  • Box shadow is not supported. Cards that rely on a soft shadow for separation come out flat. Borders and background tints are the print-friendly substitute, and arguably the better choice for a document anyway.
  • Transforms are 2D only. 3D transformations do not render, so anything using perspective or rotate3d needs a flat equivalent.
  • Interactive pseudo-classes match nothing. The documentation notes that :hover, :active, :focus, :target and :visited are "accepted as valid but never match anything". Sensible for a static document, but if you styled a state through one of them, that styling silently vanishes rather than erroring.
  • System dependencies are real. WeasyPrint needs Pango and the system font libraries present. On a standard Linux image this is a non-event. On Windows, on Alpine, and on some minimal containers it is the reason the install fails, and it is worth checking before you commit to the library.

What it does support is better than most people assume, which is why it remains the best pure Python option: flexbox, grid, custom properties, paged media with running headers and page counters, and Selectors Level 3 and 4. For a server rendered invoice or statement template, the output is genuinely excellent.

Why are my images and CSS missing in WeasyPrint?

This one gets blamed on JavaScript and is usually a different problem entirely: relative URLs with no base to resolve against. If you hand WeasyPrint an HTML string, it has no idea where that string came from, so /static/css/report.css and /media/logo.png resolve to nothing and you get an unstyled document with broken images and no error message.

from django.template.loader import render_to_string
from weasyprint import HTML

html = render_to_string('reports/monthly.html', context)
pdf = HTML(string=html, base_url=request.build_absolute_uri()).write_pdf()

Passing base_url fixes it. The other frequent cause is fonts: a web font that loads fine in the browser may not be reachable from the process running WeasyPrint, in which case it falls back silently and your careful typography turns into the default serif. Absolute URLs to the font files, or installing the font on the machine, both solve it.

WeasyPrint vs wkhtmltopdf: which one handles JavaScript?

wkhtmltopdf does run JavaScript, because unlike WeasyPrint it is built on a browser engine, a fork of WebKit. That is the reason a lot of teams reached for it, usually through the Python pdfkit wrapper, whenever a chart refused to render.

It is no longer a good place to land. The wkhtmltopdf GitHub repository carries the notice "This repository was archived by the owner on Jan 2, 2023. It is now read-only." Existing installs keep working, and nothing broke this morning. But the WebKit fork underneath is frozen while the CSS your designers write keeps moving, so modern grid layouts, newer color syntax and recent font features are the things that start failing, and no upstream release is coming to fix them. There are also no upstream security patches for a component whose entire job is loading web content.

So the comparison is not really WeasyPrint against wkhtmltopdf. It is WeasyPrint, which is actively maintained but runs no scripts, against a current Chromium, which runs everything but has to be operated by somebody. Our wkhtmltopdf alternative page walks through what a migration off it actually involves.

When should you stop fighting it and move?

Stay on WeasyPrint if your document is server rendered HTML with no scripts, your charts can be generated as images or SVG, and the CSS gaps above do not touch your design. That describes most invoices, statements and structured reports, and for those WeasyPrint is fast, free, actively maintained, and better than the alternatives.

Move when the same visualization has to appear in both your web app and your PDF and you are tired of maintaining two versions of it, or when the template is a client rendered app you do not want to rebuild server side, or when the design keeps hitting features WeasyPrint has not implemented. The tell is when your workaround code, the matplotlib duplication and the CSS substitutions, grows larger than the template it supports.

At that point you need Chromium, and the only remaining decision is where it runs. Playwright for Python puts it in your own process, which is fine if a few hundred megabytes of browser binaries and a browser lifecycle are welcome in your stack, and awkward on serverless where AWS Lambda caps an unzipped deployment package including layers at 250 MB. A hosted renderer runs it elsewhere and reduces the whole thing to one requests call, which is the version that survives a Lambda deploy unchanged.

Either way the chart renders, because a real browser is finally running the script that draws it. If you are weighing that choice properly, the full Python PDF generation comparison puts WeasyPrint, ReportLab, fpdf2, pdfkit, Playwright and the API route side by side on what each installs and what each can render, and the Django HTML to PDF page covers the template and static file specifics if that is your framework.

Written by the team building Sitepdf, an HTML to PDF API that archives every page it renders. The in-browser converter is free to try; early access locks the launch pricing.

§ Early access

Get on the early-access list

The API opens to the list first, in order. Early access locks the planned launch rates for 12 months. No card required, launching soon.

Render + archive, one API