HTML to PDF with charts (Chart.js)

A report page looks perfect in the browser, then the PDF shows bars stuck near zero, a chart cut off at the right edge, or fuzzy text. We rendered the same Chart.js page several ways and measured what came out. Below are the causes and a tested script that avoids all three problems.

The three problems, measured

What you seeCause and fix
Bars near zero, lines half-drawnThe PDF is printed while Chart.js is still animating (with defaults our bars reached about 5% of their height).
Fix: animation: false
Chart cut off at the right edgeThe canvas is sized for the 1280px screen viewport (it came out 1264px wide), then printed on an A4 page about 718px wide. Chart.js doesn't resize in time.
Fix: give the chart box a fixed size in mm that fits the page
Blurry labels and linesA canvas is a bitmap: at the default pixel ratio it lands in the PDF at 96 ppi.
Fix: devicePixelRatio: 2 (192 ppi in our test)

The settings below go in the Chart.js config and the page CSS, so they work the same in our API, in Puppeteer's page.pdf() or in a browser's Print dialog.

The chart settings

new Chart(canvas, {
  type: 'bar',
  data: { labels, datasets },
  options: {
    animation: false,          // draw the final state immediately
    devicePixelRatio: 2,       // sharp when printed
    maintainAspectRatio: false // fill the fixed-size box below
  }
});
.chart { width: 180mm; height: 85mm; break-inside: avoid; }

180mm fits A4 with the default 10mm margins (190mm usable), and Letter is wider. break-inside: avoid keeps a chart and its caption on the same page.

Node.js: a two-chart report

This builds the HTML in code, sends it to POST /v1/pdf and saves report.pdf. Node 18+, no dependencies. Save it as report.mjs and run SHOTMINT_KEY=... node report.mjs.

// report.mjs: monthly sales report with two charts, rendered to report.pdf
import { writeFile } from 'node:fs/promises';

const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
const revenue = [12400, 19100, 7300, 15800, 22050, 30120];
const signups = [210, 340, 180, 290, 410, 560];

// Every chart gets these two options: no animation, 2x canvas resolution.
const PRINT = { animation: false, devicePixelRatio: 2 };

const chart = (id, config) => `
  <figure class="chart"><canvas id="${id}"></canvas></figure>
  <script>new Chart(document.getElementById('${id}'), ${JSON.stringify(config)});</script>`;

const html = `<!doctype html><html><head><meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
  body { font-family: Arial, sans-serif; color: #111; }
  /* Fixed size in mm: the canvas is sized before print layout, so it must already fit the page. */
  .chart { width: 180mm; height: 85mm; margin: 0 0 8mm; break-inside: avoid; }
</style></head><body>
  <h1>Sales report, H1 2026</h1>
  <p>Revenue: $${revenue.reduce((a, b) => a + b, 0).toLocaleString('en-US')}</p>
  ${chart('revenue', { type: 'bar', data: { labels: months, datasets: [{ label: 'Revenue ($)', data: revenue, backgroundColor: '#4f46e5' }] }, options: { ...PRINT, maintainAspectRatio: false } })}
  ${chart('signups', { type: 'line', data: { labels: months, datasets: [{ label: 'Signups', data: signups, borderColor: '#059669' }] }, options: { ...PRINT, maintainAspectRatio: false } })}
</body></html>`;

const res = await fetch('https://shotmint.creasework.com/v1/pdf', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.SHOTMINT_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ html, format: 'A4' }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile('report.pdf', Buffer.from(await res.arrayBuffer()));
console.log('saved report.pdf, credits left:', res.headers.get('x-credits-remaining'));

Python: the same, standard library only

# report.py: same report from Python, standard library only
import json, os, urllib.request

months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
revenue = [12400, 19100, 7300, 15800, 22050, 30120]

config = {
    "type": "bar",
    "data": {"labels": months, "datasets": [{"label": "Revenue ($)", "data": revenue, "backgroundColor": "#4f46e5"}]},
    "options": {"animation": False, "devicePixelRatio": 2, "maintainAspectRatio": False},
}

html = f"""<!doctype html><html><head><meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>.chart {{ width: 180mm; height: 85mm; break-inside: avoid; }}</style></head><body>
<h1>Revenue, H1 2026</h1>
<figure class="chart"><canvas id="revenue"></canvas></figure>
<script>new Chart(document.getElementById("revenue"), {json.dumps(config)});</script>
</body></html>"""

req = urllib.request.Request(
    "https://shotmint.creasework.com/v1/pdf",
    data=json.dumps({"html": html, "format": "A4"}).encode(),
    headers={"X-API-Key": os.environ["SHOTMINT_KEY"], "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as res, open("report.pdf", "wb") as f:
    f.write(res.read())
    print("saved report.pdf, credits left:", res.headers["X-Credits-Remaining"])

Can't change the chart code? Add a delay

If the HTML comes from somewhere you don't control, let the animation finish before printing. delay waits after the page has loaded (up to 10000 ms); Chart.js's default animation lasts 1 second, so 1500 is enough. This sends an existing dashboard.html with jq:

jq -n --rawfile html dashboard.html '{html: $html, format: "A4", delay: 1500}' |
  curl -s -X POST https://shotmint.creasework.com/v1/pdf \
    -H "X-API-Key: $SHOTMINT_KEY" -H "Content-Type: application/json" \
    --data-binary @- -o dashboard.pdf

A delay fixes half-drawn charts only. It does nothing for clipping or blur, and it adds time to every render.

Rendering a live dashboard URL instead

To print a page that is already online, send url instead of html, with the same delay if its charts animate. If a login is needed, render a server-side HTML version of the report instead: the request carries no cookies.

Other chart libraries

The same three causes apply to any library that draws on a canvas: turn off its animation, raise its pixel ratio if it has one, and give the chart a fixed print-sized box. SVG charts (for example D3 output) stay sharp at any zoom, but still need animation off or a delay.

What we tested

We ran the Node, Python and jq scripts above against the live API (Chart.js 4.4.4 from jsDelivr, A4): every bar reached its full height, the charts fit the page, and the canvases were embedded at 1360×642 pixels (192 ppi), checked with pdfimages -list. With Chart.js defaults, the same page gave near-empty bars; with animation off but no fixed size, the right-hand month was cut off. One PDF costs one credit.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons