Chart.js to PDF: render charts in HTML-to-PDF
Chart.js to PDF without blank canvases. Disable animation, use wait_for_expression, get sharp output, plus tips for ECharts and ApexCharts.
Quick answer. In Chart.js, set animation: false and set window.chartsReady = true once the charts have drawn. Then send the HTML with wait_for_expression so the renderer waits for that flag before printing.
curl -X POST https://html-pdf.com/v1/pdf \
-H "Authorization: Bearer $HTML_PDF_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html report.html \
'{html: $html, format: "A4", margin: "15mm",
wait_for_expression: "window.chartsReady === true"}')" \
-o report.pdf
Charts are the most common reason an HTML-to-PDF render comes out wrong. The PDF shows an empty box, a half-drawn bar chart, or a blurry image.
The cause is almost always timing. Chart.js draws on a <canvas> with JavaScript, often with an animation, and the browser prints before the drawing is done. This guide fixes that with three changes that apply to Puppeteer, Playwright, Gotenberg or an API.
Why charts come out blank or half-drawn
These are the four usual causes, so you know what to fix.
- The script loads after the page is "ready". The renderer may print as soon as the DOM loads, before Chart.js from a CDN has run.
- Animations. Chart.js animates by default, so the first frames show bars at zero height.
- Responsive sizing. With
responsive: true, the chart sizes itself to its container, and print layout can resize the canvas after it was drawn. - Resolution. A canvas is a bitmap. Drawn at screen resolution, it looks soft when zoomed in a PDF viewer.
A print-ready Chart.js page
This page gives you a working template that prints charts fully drawn and sharp.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
body { font-family: system-ui, sans-serif; margin: 0; }
.chart { break-inside: avoid; margin-bottom: 12mm; }
</style>
</head>
<body>
<h1>Monthly revenue</h1>
<div class="chart"><canvas id="revenue" width="700" height="350"></canvas></div>
<div class="chart"><canvas id="mix" width="700" height="350"></canvas></div>
<script>
const charts = [
{ id: "revenue", type: "bar",
data: { labels: ["Jan", "Feb", "Mar", "Apr"],
datasets: [{ label: "Revenue", data: [12, 19, 15, 22] }] } },
{ id: "mix", type: "doughnut",
data: { labels: ["Pro", "Team", "Free"],
datasets: [{ data: [55, 30, 15] }] } },
];
let rendered = 0;
const readyPlugin = {
id: "ready",
afterRender(chart) {
if (chart.$printed) return; // count each chart once
chart.$printed = true;
if (++rendered === charts.length) window.chartsReady = true;
},
};
for (const c of charts) {
new Chart(document.getElementById(c.id), {
type: c.type,
data: c.data,
plugins: [readyPlugin],
options: {
animation: false, // draw the final frame immediately
responsive: false, // keep the canvas at its width/height attributes
devicePixelRatio: 2, // render at 2x for crisp output
},
});
}
</script>
</body>
</html>
Here is what each piece does.
animation: falseremoves the in-between frames, so the first render is the final chart.responsive: falsewithwidthandheighton the canvas gives a fixed size that print layout can't change. 700 px is about 185 mm at 96 dpi, which fits an A4 page with normal margins.devicePixelRatio: 2draws the canvas at twice the resolution. Use 2 or 3, since higher values only make the PDF bigger.- The
afterRenderplugin setswindow.chartsReady = trueonce every chart has drawn. The renderer waits for that flag. break-inside: avoidkeeps a chart from being split across two pages. See page breaks.
If your charts load data with fetch(), create the charts after the data arrives. The plugin still sets the flag at the right time.
Wait for the flag
This section shows how to make your own renderer wait for the flag.
Puppeteer (DIY):
await page.setContent(html, { waitUntil: "networkidle0" });
await page.waitForFunction("window.chartsReady === true", { timeout: 10000 });
await page.pdf({ path: "report.pdf", format: "A4", printBackground: true });
Gotenberg (DIY): send the form field waitForExpression=window.chartsReady === true.
Avoid fixed sleeps when you can. A two-second delay is too long for fast pages and too short on a slow day. Wait for the condition instead, and use a delay only as a fallback.
Or skip the server
You can skip running Chromium yourself. Send the same HTML to html-pdf.com with wait_for_expression, and Chromium runs the scripts, waits for your flag, then prints.
curl -X POST https://html-pdf.com/v1/pdf \
-H "Authorization: Bearer $HTML_PDF_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html report.html \
'{html: $html, format: "A4", margin: "15mm",
wait_for_expression: "window.chartsReady === true"}')" \
-o report.pdf
In Python:
import os, requests
r = requests.post("https://html-pdf.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['HTML_PDF_KEY']}"},
json={
"html": open("report.html").read(),
"format": "A4",
"margin": "15mm",
"wait_for_expression": "window.chartsReady === true",
})
r.raise_for_status()
open("report.pdf", "wb").write(r.content)
Total render time is capped at 30 seconds. If the flag never becomes true, the request fails and you aren't charged.
For a dashboard that is already public, send {"url": "...", "wait_for_expression": "...", "media": "screen"} instead. This works as long as the page sets the flag itself.
ECharts and ApexCharts
The same pattern works with other chart libraries.
- ECharts: set
animation: falsein the option and set the flag in thefinishedevent withchart.on("finished", () => { window.chartsReady = true; }). Initialize withecharts.init(el, null, { renderer: "svg" })to get vector output. - ApexCharts: set
chart: { animations: { enabled: false } }. Sincechart.render()returns a promise, usechart.render().then(() => { window.chartsReady = true; }).
SVG charts don't need waits
SVG charts are the simplest case. Charts that arrive as finished SVG markup (for example generated on the server with D3, Vega or matplotlib with format="svg") are just part of the HTML.
Nothing has to run, so you don't need wait_for_expression at all. SVG also stays sharp at any zoom and keeps text selectable. If you control the pipeline, rendering charts to SVG before calling the PDF API is the most reliable option.
From an AI agent
An AI agent can do all of this for you. With the PDF MCP server, an agent can build a report page with Chart.js and call html_to_pdf with wait_for_expression. Ask it to "disable animation and set window.chartsReady when the charts render".
Pricing
You pay per page. 1 credit = 1 PDF page, and you get 100 free credits once. After that, packs go from $10 for 1,000 pages down to $0.005 per page. Credits never expire and failed renders are free.
FAQ
Why is my Chart.js chart blank in the PDF?
The PDF was printed before Chart.js finished drawing. Set animation: false, set a flag such as window.chartsReady = true after the charts render, and pass wait_for_expression so the renderer waits for it.
How do I make Chart.js charts sharp in a PDF?
Set devicePixelRatio: 2 (or 3) in the chart options so the canvas is drawn at a higher resolution. For fully vector output, render charts as SVG instead.
Does it work with ECharts and ApexCharts?
Yes. Disable animations and set the ready flag in the ECharts finished event or after ApexCharts chart.render() resolves.
Am I charged if the chart never finishes rendering?
No. If the flag never becomes true within the 30-second limit, the request fails and failed renders cost nothing.
Get a free API key · API docs · Puppeteer HTML to PDF · Templates
