The sharpest chart in a PDF is a vector one: an SVG placed in the document, which the PDF stores as shapes and real text. If the chart has to be an image, make it large enough for print: width in inches × 300 pixels for print quality, 150 for documents that will only be read on screen. A 6-inch-wide chart needs a PNG 1,800 pixels wide.
The common way to get it wrong: printing a page with a canvas-based chart (Chart.js and most dashboard libraries) to PDF. Chromium flattens the canvas into a bitmap at the screen’s pixel ratio, which in headless printing is 96 pixels per inch: soft on screen and worse on paper.
Units: points, pixels and inches
Three units meet in a PDF pipeline:
- PDF point: 1/72 inch. It is PDF’s default unit of length (ISO 32000-1, section 8.3.2.3).
- CSS pixel: 1/96 inch. CSS defines
1in = 96px(CSS Values and Units). - Image pixel: no fixed size. A PNG’s width in pixels divided by its printed width in inches gives its pixels per inch (ppi).
So an A4 page is 595 × 842 points, a chart that is 600 CSS pixels wide prints 6.25 inches wide, and a 1,200-pixel PNG placed at that size prints at 192 ppi.
Option 1: keep the chart vector
When Chromium prints HTML to PDF, an inline <svg> stays vector: rectangles become PDF paths and labels become selectable text in an embedded font. We checked this by printing the page below with Playwright and inspecting the PDF with Poppler’s tools: pdfimages -list found no images at all, pdffonts listed the fonts as embedded subsets, and pdftotext extracted the axis labels as text.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { size: A4; margin: 20mm; }
body { font-family: Helvetica, Arial, sans-serif; color: #17181d; }
figure { margin: 0; break-inside: avoid; }
.chart { width: 160mm; height: auto; display: block; }
</style>
</head>
<body>
<h1>Monthly report</h1>
<figure>
<svg class="chart" viewBox="0 0 640 320" role="img" aria-label="Signups per month, April to September 2026">
<rect width="640" height="320" fill="#ffffff"/>
<g fill="#2b3cd8">
<rect x="60" y="180" width="70" height="100"/>
<rect x="150" y="160" width="70" height="120"/>
<rect x="240" y="140" width="70" height="140"/>
<rect x="330" y="120" width="70" height="160"/>
<rect x="420" y="90" width="70" height="190"/>
<rect x="510" y="60" width="70" height="220"/>
</g>
<line x1="40" y1="280" x2="600" y2="280" stroke="#17181d"/>
<g font-size="16" text-anchor="middle" fill="#17181d">
<text x="95" y="304">Apr</text><text x="185" y="304">May</text><text x="275" y="304">Jun</text>
<text x="365" y="304">Jul</text><text x="455" y="304">Aug</text><text x="545" y="304">Sep</text>
</g>
</svg>
<figcaption>Signups per month, April to September 2026.</figcaption>
</figure>
</body>
</html>
// make-pdf.mjs (npm install playwright, then npx playwright install chromium)
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(new URL('./report.html', import.meta.url).href);
await page.pdf({ path: 'report.pdf', preferCSSPageSize: true, printBackground: true });
await browser.close();
$ pdfinfo report.pdf | grep 'Page size'
Page size: 594.96 x 841.92 pts (A4)
$ pdfimages -list report.pdf
page num type width height color comp bpc enc interp object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
$ pdffonts report.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+Helvetica-Bold CID TrueType Identity-H yes yes yes 4 0
BAAAAA+Helvetica CID TrueType Identity-H yes yes yes 6 0
CAAAAA+Helvetica CID TrueType Identity-H yes yes yes 7 0
The settings that matter, from Playwright’s page.pdf() documentation:
preferCSSPageSize: trueuses the@page { size }from your CSS (MDN). Without it,formatdecides, and it “defaults to ‘Letter’”, which surprises anyone outside North America.printBackground: true: backgrounds are left out by default (“Defaults to false”). A chart that relies on a CSS background color loses it otherwise. Drawing the background inside the SVG, as above, avoids depending on this.- Print CSS.
page.pdf()renders with print media, so@media printrules apply, and “by default, page.pdf() generates a pdf with modified colors for printing”; add-webkit-print-color-adjust: exact(or the standardprint-color-adjust) to keep exact colors. - PDF generation needs headless Chromium: in other browsers, Playwright 1.64 stops with “PDF generation is only supported for Headless Chromium”. Puppeteer’s
page.pdf()works the same way.
Fonts in vector charts
Chromium embeds the fonts a PDF uses, as subsets, so the document looks the same everywhere. What matters is which font the chart was drawn with:
- Inline SVG (in the page’s HTML, as above) uses the page’s fonts, including web fonts. Wait for them to load (
await page.evaluate(() => document.fonts.ready)) before callingpage.pdf(). - SVG as an image (
<img src="chart.svg">) can’t load external resources, including web fonts referenced by a stylesheet (MDN). Inline the SVG instead, embed the font in it, or convert text to paths.
Option 2: a PNG at the right size
When the chart only exists as a bitmap (a screenshot, a chart rendered on a server, an image from another system), size it for the page:
pixels = printed width in inches × ppi
| Printed size | 150 ppi (screen) | 300 ppi (print) |
|---|---|---|
| 3 × 2 in, half width | 450 × 300 | 900 × 600 |
| 6 × 3.5 in, a report figure | 900 × 525 | 1,800 × 1,050 |
| 6.69 in wide, A4 text width with 20 mm margins | 1,004 wide | 2,008 wide |
| 6.5 in wide, US Letter text width with 1 in margins | 975 wide | 1,950 wide |
Then place the image at its printed size, not at its pixel size. In HTML, set the size in physical units: <img src="chart.png" style="width: 6in; height: 3.5in">. We checked this case too: a 1,800 × 1,050 PNG placed at 6 × 3.5 inches came out of Chromium at exactly 300 ppi, as pdfimages -list reported.
Use PNG, not JPEG: JPEG compression smears the thin lines and small text that charts are made of.
The canvas trap
Canvas-based charts (Chart.js, ECharts in canvas mode, most dashboard libraries) are bitmaps drawn at the screen’s resolution. Print the page to PDF and the canvas is embedded as an image at its pixel size. At a pixel ratio of 1, the default in headless Chromium, that is 96 ppi: a 6 × 3.5 inch Chart.js chart (with maintainAspectRatio: false, so it fills the box) came out as a 576 × 336 image in our test. That is soft on screen when zoomed and visibly blurry on paper.
Two fixes:
- Raise the canvas resolution before printing. Chart.js’s
devicePixelRatiooption scales the canvas; withdevicePixelRatio: 3the same chart came out as 1,728 × 1,008 pixels, 288 ppi. Also turn off animation (animation: false) so the PDF doesn’t capture a half-drawn chart. See Chart.js to PNG. - Use an SVG renderer. Some libraries can draw to SVG instead of canvas; or export the chart as an SVG file and place that.
Other ways to make the PDF
HTML printed by Chromium is one pipeline. LaTeX, Typst, report libraries and office suites each have their own way to place a figure, and the same two rules apply to all of them: a vector format where the tool accepts one, and a bitmap sized for print where it doesn’t. Whatever the tool, check the result: open the PDF, zoom to 400% on the chart, and see whether the edges stay crisp.
Checklist
- Prefer SVG, inlined in the HTML, so the chart stays vector with real, embedded fonts.
- If it must be a bitmap, size it as printed inches × 300 and place it at its printed size.
- Never print a canvas chart at its default resolution; raise
devicePixelRatioor switch to SVG. - Set the page size in CSS and use
preferCSSPageSize; setprintBackgroundif anything depends on backgrounds. - Wait for fonts and turn off animation before generating the PDF.
Making the chart
Chart Link exports any chart it makes as SVG, ready to inline or place in a document, or as a PNG. It runs in your browser, so your data stays on your machine. For charts in other places, see charts in email and charts in a GitHub README.
Sources
All sources were checked on October 14, 2026. The PDF results were produced with Playwright 1.64.1 and Chromium 156, and inspected with Poppler 26.04.
- ISO 32000-1:2008 (PDF 1.7), section 8.3.2.3: default user space unit of 1/72 inch.
- CSS Values and Units Module Level 4: absolute lengths, W3C:
1in = 2.54cm = 96px. page.pdf(), Playwright documentation, andPage.pdf(), Puppeteer documentation.- MDN:
@pagesize,print-color-adjustand SVG as an image. - Device pixel ratio, Chart.js documentation.