ScreenshotNeo

BlogHTML to image & PDF

How to Improve Chart Quality in wkhtmltopdf PDFs

Fix blurry, clipped, or missing charts in wkhtmltopdf with vector output, explicit sizing, print CSS, and deterministic JavaScript waits.

By the ScreenshotNeo team30 September 20269 min read

How to Improve Chart Quality in wkhtmltopdf PDFs

Blurry charts in wkhtmltopdf usually come from a raster image that is too small, automatic shrinking, an undersized chart viewport, or exporting before JavaScript finishes drawing. The most reliable fix is to render charts as SVG, give the chart and PDF page explicit dimensions, control zoom and shrinking, wait for a deterministic ready signal, and validate the result at its intended print size.

wkhtmltopdf is an open-source command-line tool that renders HTML and PDF through the Qt WebKit engine. Its manual describes the PDF command as converting HTML pages with patched Qt WebKit. That older rendering stack is useful and predictable when its layout, loading, and scaling rules are made explicit.

1. Start with a controlled baseline

Save this minimal HTML as chart.html. The explicit SVG dimensions prevent the renderer from guessing the chart size.

The capture flow: HTML and SVG are laid out, rendered, and written to a PDF.
The capture flow: HTML and SVG are laid out, rendered, and written to a PDF.
<!doctype html>
<html>
<head>
  <meta charset='utf-8'>
  <style>
    @page { size: A4 landscape; margin: 12mm; }
    html, body { margin: 0; padding: 0; }
    .chart { width: 1050px; height: 560px; }
  </style>
</head>
<body>
  <svg class='chart' viewBox='0 0 1050 560' role='img' aria-label='Revenue by month'>
    <rect width='1050' height='560' fill='white'/>
    <!-- Replace this example with output from your chart library. -->
    <polyline points='80,450 250,360 420,390 590,250 760,290 950,130'
      fill='none' stroke='#1769aa' stroke-width='4'/>
    <g fill='#1769aa'>
      <circle cx='80' cy='450' r='6'/>
      <circle cx='250' cy='360' r='6'/>
      <circle cx='420' cy='390' r='6'/>
      <circle cx='590' cy='250' r='6'/>
      <circle cx='760' cy='290' r='6'/>
      <circle cx='950' cy='130' r='6'/>
    </g>
  </svg>
</body>
</html>

Convert it with a wide page, explicit viewport, and no smart shrinking:

wkhtmltopdf \
  --page-size A4 \
  --orientation Landscape \
  --margin-top 12mm --margin-right 12mm \
  --margin-bottom 12mm --margin-left 12mm \
  --viewport-size 1200x800 \
  --disable-smart-shrinking \
  chart.html chart.pdf

Open the PDF at 100% zoom. If labels are readable there, check a printed page as well. A screenshot viewed only at a zoomed-out level can hide clipping, thin strokes, and small type.

2. Prefer SVG for lines, labels, and markers

SVG stores chart geometry and text instead of baking everything into a small bitmap. When your chart library supports SVG output, use it for axes, labels, legends, and data marks. This is an engineering recommendation based on wkhtmltopdf accepting HTML and SVG content; individual SVG features can vary in the Qt WebKit version you use.

If a library can export only PNG or JPEG, render the source at least two times the final displayed pixel dimensions, then downsample once. For a chart displayed at 1050 by 560 CSS pixels, a 2100 by 1120 source gives the rasterizer more information. Avoid repeatedly resizing an already compressed image. Keep text as HTML or SVG when possible.

3. Make page and chart dimensions agree

Quality problems often begin as layout problems. Choose the physical page first, then calculate the usable chart area after margins. Give the chart a matching CSS width and height, and set an SVG viewBox that preserves its aspect ratio.

Goal Settings Why it helps
Wide time series --orientation Landscape or --page-width Provides horizontal room for labels and legends.
Fixed paper format --page-size A4, Letter, or another supported size Creates a known printable area.
Custom report sheet --page-width and --page-height Lets CSS pixels map to a deliberate page geometry.
Predictable CSS mapping --disable-smart-shrinking Prevents automatic shrinking from changing the pixel-to-DPI relationship.
Controlled enlargement --zoom 1.0 (default is 1) Sets the scale explicitly when a different scale is required.
More layout context --viewport-size 1200x800 Controls the viewport used while responsive CSS and charts run.

Smart shrinking is enabled by default in many builds. The manual explains that it makes the pixel/DPI ratio non-constant. Turning it off makes CSS-pixel sizing easier to reason about, but you must provide suitable page dimensions and margins. If the chart is clipped after disabling it, reduce the chart width, increase the page width, or reduce margins.

4. Wait for JavaScript charts deterministically

JavaScript is enabled by default, and the documented --javascript-delay default is 200 milliseconds. That delay is only a timing cushion. It can be too short for a slow API, web font, large dataset, or expensive chart animation.

Use a longer delay for simple pages

wkhtmltopdf \
  --javascript-delay 1500 \
  --page-size A4 --orientation Landscape \
  chart.html chart.pdf

Use window.status for repeatable exports

A status signal is better when rendering time varies. Set it only after data, fonts, and drawing are complete:

<script>
  async function drawChart() {
    const response = await fetch('/data/revenue.json');
    const data = await response.json();
    renderChart(data);             // Your chart library call
    await document.fonts.ready;    // Wait for web fonts when supported
    window.status = 'chart-ready';
  }
  drawChart().catch(() => { window.status = 'chart-error'; });
</script>
wkhtmltopdf \
  --window-status chart-ready \
  --javascript-delay 5000 \
  chart.html chart.pdf

Keep a finite delay even with a status value so a broken page does not wait forever. Disable animations in print CSS or configure the chart library to draw one final frame. A chart that is still animating can be captured with missing series or partially painted labels.

5. Use print CSS deliberately

Screen styles often reserve space for toolbars, responsive cards, or hover controls. The --print-media-type option switches from screen media to print media; screen media is the default. Put PDF-specific dimensions, colors, and visibility rules in a print block:

@media print {
  .chart-card { width: 270mm; height: 150mm; }
  .chart-toolbar, .tooltip, .interactive-legend { display: none !important; }
  .chart svg { width: 270mm; height: 150mm; }
  .chart, .chart * { animation: none !important; transition: none !important; }
  .chart { break-inside: avoid; page-break-inside: avoid; }
}
wkhtmltopdf --print-media-type chart.html chart.pdf

Do not use a print rule that sets a chart to display: none or reduces its opacity. Check both media modes if the same page is also used interactively.

6. Tune raster images and PDF quality

The current manual documents a default --dpi of 96, --image-dpi of 600, and JPEG --image-quality of 94. These settings affect raster inputs and PDF image encoding; they cannot recreate detail that was absent from the source image.

  • Keep source PNG dimensions high enough for the final physical size.
  • Avoid --lowquality when labels or thin lines matter.
  • Use --image-quality 94 or a deliberately chosen value for JPEG sources.
  • Use PNG for flat lines, transparent backgrounds, and small text; use JPEG for photographic backgrounds where compression artifacts are acceptable.
  • Do not confuse --dpi with a chart’s CSS width. DPI changes output interpretation; CSS dimensions determine layout.

Qt’s documentation notes that screen resolution produces lower-quality output than high resolution and is intended for drafts. Treat high-resolution output as a final-export setting, then inspect the actual PDF rather than relying on the option name.

7. Make every asset reachable

Missing fonts, scripts, images, and JSON data can make a chart look blurry or incomplete because the renderer falls back to a different font or captures an empty canvas. Confirm that every URL works from the machine running wkhtmltopdf. For local files, review --enable-local-file-access and any --allow paths required by your build. Capture stderr and investigate failed-resource messages.

Use absolute URLs or a controlled base URL for assets. If a chart depends on an authenticated endpoint, pass the required cookie or header through your application, or generate a self-contained HTML file with data embedded. Avoid relying on a browser session that wkhtmltopdf cannot see.

8. Complete command-line recipes

Local HTML with SVG

wkhtmltopdf \
  --enable-local-file-access \
  --allow /srv/report/assets \
  --page-size A4 \
  --orientation Landscape \
  --viewport-size 1400x900 \
  --disable-smart-shrinking \
  --print-media-type \
  --window-status chart-ready \
  /srv/report/chart.html /srv/report/chart.pdf

Remote page with a timing cushion

wkhtmltopdf \
  --page-size Letter \
  --orientation Landscape \
  --viewport-size 1366x900 \
  --javascript-delay 2000 \
  --no-stop-slow-scripts \
  'https://example.com/report' report.pdf

Python wrapper

import subprocess

cmd = [
    'wkhtmltopdf',
    '--page-size', 'A4',
    '--orientation', 'Landscape',
    '--viewport-size', '1400x900',
    '--disable-smart-shrinking',
    '--print-media-type',
    '--window-status', 'chart-ready',
    'https://example.com/report',
    'report.pdf',
]
subprocess.run(cmd, check=True, timeout=90)

Node.js wrapper

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);

await run('wkhtmltopdf', [
  '--page-size', 'A4',
  '--orientation', 'Landscape',
  '--viewport-size', '1400x900',
  '--disable-smart-shrinking',
  '--print-media-type',
  '--window-status', 'chart-ready',
  'https://example.com/report',
  'report.pdf'
], { timeout: 90_000 });

9. Diagnose common failures

Symptom Likely cause Fix
Everything is uniformly soft Small PNG/JPEG source or repeated resizing Export SVG, or increase source pixels and resize once.
Chart is tiny with large margins Smart shrinking or a narrow page width Set page dimensions, try --disable-smart-shrinking, and size the chart to the usable area.
Right side is cut off Chart exceeds page width after margins Use landscape orientation, reduce chart width, or increase custom page width.
Labels overlap Viewport or chart dimensions are too small Increase both CSS/SVG dimensions and --viewport-size; simplify tick labels.
Blank chart JavaScript or data request had not finished Use --window-status or a longer --javascript-delay; inspect stderr and network access.
Wrong font changes layout Font file cannot be loaded Use reachable font URLs, wait for document.fonts.ready, and verify local-file permissions.
Colors differ from browser Print media rules or missing color adjustment Review @media print and set print colors intentionally.
Chart splits across pages Container is taller than remaining page space Set break-inside: avoid, reduce height, or insert a page break before the chart.
Conversion hangs Page never sets the requested status or script keeps running Set success and error statuses, add a finite delay, and use an external process timeout.

10. Performance, reliability, and cost

SVG usually reduces the need for very large raster files, but complex SVGs can still consume CPU. Disable animation, reduce unnecessary points, and avoid loading data or fonts that the PDF does not use. Reuse a warmed worker process only if your deployment isolates jobs and enforces timeouts; a stuck page should never block the queue.

For reliable batch exports, pin the wkhtmltopdf build, keep a representative chart fixture, record stderr, and compare PDFs at 100% zoom. Retry transient network failures with a limit, but do not retry deterministic JavaScript errors indefinitely. Cache static assets close to the renderer. Cost is mainly your compute, storage, and network usage: larger images and longer JavaScript delays increase all three. Measure conversion time and PDF size on your own workload because the dossier provides no benchmark.

11. Validate the final PDF

  1. Open a representative page at 100% zoom.
  2. Print one page on the intended paper size.
  3. Check thin lines, tick labels, legend order, contrast, and clipped annotations.
  4. Check a chart with the most data points and the longest labels.
  5. Check a slow-data case and a missing-data case.
  6. Confirm page breaks do not divide the chart or its caption.

Or skip the browser setup

If you need a clean image or PDF from a web page without maintaining a wkhtmltopdf process, ScreenshotNeo provides a single HTTP request. Its capture pipeline accepts consent banners as a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. You can turn each cleanup step off. Clean shots are the only billable responses; bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers identify the page verdict and billing result.

Cleanup and capture happen before the final screenshot or PDF.
Cleanup and capture happen before the final screenshot or PDF.

See the ScreenshotNeo API documentation for all options, including full-page capture, CSS selectors, dark mode, device presets, retina scale, PDF paper sizes and margins, custom CSS and JavaScript, click and wait controls, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous webhooks, bulk capture, usage data, and the OpenAPI specification.

cURL

curl -G 'https://api.screenshotneo.com/v1/shot' \
  -d access_key=YOUR_API_KEY \
  --data-urlencode 'url=https://example.com/report' \
  -o report.webp

Python

import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://example.com/report'}, timeout=90)
open('report.webp', 'wb').write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/report' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

An MCP server also lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Does increasing DPI fix an SVG chart?

Usually no. SVG is already resolution-independent. Fix its layout, dimensions, and text sizing first; DPI settings mainly affect raster inputs and output interpretation.

Should I always disable smart shrinking?

No. Disable it when predictable CSS-pixel sizing matters. Keep it enabled when automatic fitting is more useful, then verify that labels and lines remain readable.

Is a fixed JavaScript delay enough?

It can work for a stable page, but window.status is more repeatable when network and chart render times vary.

Why are labels missing only in the PDF?

Common causes are print CSS hiding them, a missing font, clipping at the page edge, or capture before the chart finished drawing. Check those in that order.