Chart.js to PDF: render charts in HTML-to-PDF

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.

  1. 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.
  2. Animations. Chart.js animates by default, so the first frames show bars at zero height.
  3. Responsive sizing. With responsive: true, the chart sizes itself to its container, and print layout can resize the canvas after it was drawn.
  4. 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.

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.

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

This page as markdown · text