Disclosure: RenderScreenshot is our product, and it is one of the replacements on this page. wkhtmltopdf is free software. The test results below come from our own runs on 2026-10-18; the method is at the end of the test section.
wkhtmltopdf is finished. Its GitHub repository was archived on January 2, 2023, and the project's status page says its Qt 4 base has been unsupported since 2015 and its WebKit has not been updated since 2012. The same page warns against using it with untrusted HTML. If your app still prints invoices through pdfkit, WickedPDF or KnpSnappy, the question is not whether to move but what to move to, and how to do it without rewriting every call site.
This page answers both. We ran two wkhtmltopdf builds and Chrome on the same three templates, mapped every common flag to its Chrome equivalent, and wrote shims for Python, Ruby and PHP that keep your existing calls. For the other options in the market, see our HTML to PDF API roundup.
The short answer
| Your situation | Move to |
|---|---|
| A few PDFs a day, you already run Node or Python workers | Self-hosted Chrome through Puppeteer or Playwright |
| Back-office documents, you want a container and no browser code | Gotenberg (Chrome in Docker, MIT licence) |
| You do not want to run Chrome at all, or you also need screenshots | A hosted API such as RenderScreenshot's PDF API |
| Your templates use only old CSS, and the PDFs never see user input | Staying is possible, but read the security warning first |
Our wkhtmltopdf vs Puppeteer comparison goes deeper into the self-hosted route. This page focuses on the switch itself.
What we tested
We built three templates that look like real work: a 60-row A4 invoice with a repeating table header and a total box, a layout page with nine modern CSS features, and a five-chapter report that uses break-before: page and CSS page counters (@page { @bottom-center { content: counter(page) } }). Each template has hidden marker strings, so pdftotext can prove that text was not lost.
We printed them with three engines:
- wkhtmltopdf 0.12.6.1 "with patched qt", the official build from the project's packaging releases, in a Debian 12 arm64 container.
- wkhtmltopdf 0.12.6 from Debian 12's own package (
apt install wkhtmltopdf), which is built against unpatched Qt. - Chrome 148 through Puppeteer 24.43.1
page.pdf()on macOS.
Modern CSS
| Feature | Official build (patched Qt) | Debian package (unpatched Qt) | Chrome 148 |
|---|---|---|---|
display: flex with gap |
Items stacked vertically | Row laid out, gap ignored |
Correct |
| CSS grid with a spanning cell | Stacked into one column | Stacked into one column | Correct |
Custom properties (var(--brand)) |
Ignored: white text on a white box | Applied | Applied |
clamp() font size |
Ignored | Ignored | Applied |
aspect-ratio |
Ignored | Ignored | Applied |
object-fit: cover |
Image stretched | Image stretched | Image cropped |
:has(), CSS nesting, color-mix() |
Ignored | Ignored | Applied |
Every marker string was present in every PDF. That is the dangerous part: nothing fails, the text is all there, and only a person who opens the file sees that the layout is wrong. The custom-property row is the worst case. Our flex boxes used background: var(--brand) with white text, so in the official build the text was in the PDF but invisible.
Paged media and output
| Official build | Debian package | Chrome 148 | |
|---|---|---|---|
| Invoice pages (A4, defaults) | 2 | 2 | 3 |
| Report pages (cover + 5 chapters) | 3: break-before: page ignored |
6 | 6 |
CSS @page page counters printed |
0 of 6 pages | 0 of 6 pages | 5 of 6 pages (first page suppressed on purpose) |
| Invoice file size | 25,318 bytes | 25,294 bytes | 111,438 bytes |
| Warm render, median of 5 | 278 ms | 274 ms | 371 ms |
Two notes on that table. wkhtmltopdf files were four times smaller in our test, and it was not slower; speed and size are not reasons to leave it. The invoice is shorter in wkhtmltopdf because of "smart shrinking", which scales content down by default. Chrome prints at true size, so expect more pages after the switch until you tune scale and margins. The timings came from different environments (wkhtmltopdf in a Linux container, Chrome on macOS), so read them as "the same order of magnitude", not as a race.
The flags your distro build silently drops
This was the finding we did not expect. With Debian's package, this command:
wkhtmltopdf --page-size A4 --margin-bottom 20mm --print-media-type \ --disable-smart-shrinking --footer-center "Page [page] of [topage]" invoice.html out.pdf
printed The switch --footer-center, is not support using unpatched qt, and will be ignored (and the same for the other two switches), then exited with code 0. The PDF had no footer. The official build printed "Page N of 3" on all three pages. If page numbers vanished after an OS upgrade or a Docker base-image change, run wkhtmltopdf --version: only builds that say "with patched qt" support headers, footers, --print-media-type and the table of contents.
Flag map: wkhtmltopdf to Chrome
The left column is the wkhtmltopdf command-line flag (pdfkit and Snappy use the same names without dashes; WickedPDF uses snake_case keys). The right column is the RenderScreenshot PDF parameter.
| wkhtmltopdf | RenderScreenshot | Notes |
|---|---|---|
--page-size A4 |
pdf.paper: "a4" |
Our default is letter, theirs is A4. Set it. Only a0 to a6, letter, legal and tabloid are accepted |
--page-width, --page-height |
pdf.width, pdf.height |
CSS units as strings: "210mm" |
--orientation Landscape |
pdf.landscape: true |
|
-T, -R, -B, -L |
pdf.margin_top and the other sides |
Their default is 10 mm on every side; ours is 0 |
--zoom 0.8 |
pdf.scale: 0.8 |
Range 0.1 to 2.0 |
--disable-smart-shrinking |
nothing to do | Chrome never shrinks on its own; pdf.fit_one_page is the opt-in version |
--no-background |
pdf.print_background: false |
Backgrounds are on by default in both |
--print-media-type |
browser.media: "print" |
|
--footer-center "[page] of [topage]" |
pdf.footer with <span class='pageNumber'> and <span class='totalPages'> |
HTML template, styled inline |
--header-html header.html |
pdf.header (the HTML as a string) |
Images inside must be data: URIs |
[date], [title], [webpage] |
classes date, title, url |
|
--javascript-delay 500 |
wait.delay: 500 |
Better: wait.until: "networkidle" or wait.for_selector |
--window-status ready |
wait.for_selector |
Add an element when the page is ready, and wait for it |
--cookie name value |
network.cookies |
|
--custom-header Name value |
network.headers |
Sent only to the target origin |
--username, --password |
network.auth with type: "basic" |
|
--toc, --outline |
none | No generated table of contents or PDF outline |
--enable-local-file-access |
none | Send the HTML itself, and set html_base_url for its assets |
The last row is the one most migrations trip on. wkhtmltopdf reads file:// paths from your disk. A hosted renderer cannot, and it also refuses private addresses such as localhost. Inline small assets as data: URIs, or put them on a public URL and set html_base_url (content input).
Drop-in shims
Each shim keeps the library's method names and option keys, translates them with the table above, and sends one request. They keep wkhtmltopdf's A4 paper and 10 mm margins as defaults, so the first PDFs after the switch are close to what you had. We ran each shim's option translation in a unit check on 2026-10-18 (Python 3.15, Ruby 4.0.7, PHP 8.5.10); the HTTP call itself was not run for this page. Every shim raises on a non-2xx response.
Python: replaces pdfkit
"""Drop-in replacement for pdfkit.from_string / pdfkit.from_url that renders with RenderScreenshot.""" import json import os import re import urllib.request API = 'https://api.renderscreenshot.com/v1/screenshot' TOKENS = {'[page]': 'pageNumber', '[topage]': 'totalPages', '[date]': 'date', '[title]': 'title', '[webpage]': 'url'} def _template(text, size): """Turn wkhtmltopdf's '[page] of [topage]' text into a Chrome header/footer template.""" escaped = text.replace('&', '&').replace('<', '<').replace('>', '>') filled = re.sub(r'\[(page|topage|date|title|webpage)\]', lambda m: f"<span class='{TOKENS[m.group(0)]}'></span>", escaped) return f"<div style='font-size:{size}pt;width:100%;text-align:center'>{filled}</div>" def build_body(source, options=None, is_html=True): opts = {k.lstrip('-'): v for k, v in (options or {}).items()} pdf = { 'paper': str(opts.get('page-size', 'A4')).lower(), # wkhtmltopdf defaults to A4 'print_background': 'no-background' not in opts, 'margin_top': opts.get('margin-top', '10mm'), # wkhtmltopdf defaults to 10mm 'margin_right': opts.get('margin-right', '10mm'), 'margin_bottom': opts.get('margin-bottom', '10mm'), 'margin_left': opts.get('margin-left', '10mm'), } if str(opts.get('orientation', '')).lower() == 'landscape': pdf['landscape'] = True if 'zoom' in opts: pdf['scale'] = float(opts['zoom']) size = opts.get('footer-font-size') or opts.get('header-font-size') or 9 if 'footer-center' in opts: pdf['footer'] = _template(opts['footer-center'], size) pdf.setdefault('header', '<span></span>') if 'header-center' in opts: pdf['header'] = _template(opts['header-center'], size) body = {'html' if is_html else 'url': source, 'output': {'format': 'pdf'}, 'pdf': pdf} if 'print-media-type' in opts: body['browser'] = {'media': 'print'} if 'javascript-delay' in opts: body['wait'] = {'delay': int(opts['javascript-delay'])} return body def _render(body, output_path): req = urllib.request.Request(API, data=json.dumps(body).encode(), method='POST', headers={ 'Authorization': f"Bearer {os.environ['RS_API_KEY']}", 'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=90) as resp: # raises HTTPError on 4xx/5xx pdf = resp.read() if output_path: with open(output_path, 'wb') as f: f.write(pdf) return True return pdf def from_string(html, output_path=False, options=None, **_ignored): return _render(build_body(html, options, is_html=True), output_path) def from_url(url, output_path=False, options=None, **_ignored): return _render(build_body(url, options, is_html=False), output_path)
Save it as rs_pdfkit.py and change one line: import rs_pdfkit as pdfkit. Calls such as pdfkit.from_string(html, False, options={'page-size': 'Letter', 'footer-center': 'Page [page] of [topage]'}) keep working. The configuration= argument is accepted and ignored, because there is no binary to locate any more. To move to WeasyPrint or Playwright instead, see HTML to PDF in Python.
Ruby: replaces WickedPdf.new
# Replacement for WickedPdf.new.pdf_from_string / pdf_from_url that renders with RenderScreenshot. require 'json' require 'net/http' require 'cgi' class RsWickedPdf API = URI('https://api.renderscreenshot.com/v1/screenshot') TOKENS = { '[page]' => 'pageNumber', '[topage]' => 'totalPages', '[date]' => 'date', '[title]' => 'title', '[webpage]' => 'url' }.freeze def pdf_from_string(html, options = {}) render(build_body({ html: html }, options)) end def pdf_from_url(url, options = {}) render(build_body({ url: url }, options)) end def build_body(source, options) margin = options.fetch(:margin, {}) mm = ->(side) { "#{margin.fetch(side, 10)}mm" } # wicked_pdf margins are numbers in mm pdf = { paper: options.fetch(:page_size, 'A4').to_s.downcase, landscape: options[:orientation].to_s.casecmp?('landscape'), print_background: !options[:no_background], margin_top: mm.call(:top), margin_right: mm.call(:right), margin_bottom: mm.call(:bottom), margin_left: mm.call(:left) } pdf[:scale] = options[:zoom].to_f if options[:zoom] if (footer = options.dig(:footer, :center)) pdf[:footer] = template(footer, options.dig(:footer, :font_size) || 9) pdf[:header] = '<span></span>' end if (header = options.dig(:header, :center)) pdf[:header] = template(header, options.dig(:header, :font_size) || 9) end body = source.merge(output: { format: 'pdf' }, pdf: pdf) body[:browser] = { media: 'print' } if options[:print_media_type] body[:wait] = { delay: options[:javascript_delay].to_i } if options[:javascript_delay] body end private def template(text, size) filled = CGI.escapeHTML(text).gsub(/\[(page|topage|date|title|webpage)\]/) do |token| "<span class='#{TOKENS[token]}'></span>" end "<div style='font-size:#{size}pt;width:100%;text-align:center'>#{filled}</div>" end def render(body) request = Net::HTTP::Post.new(API, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json') request.body = body.to_json response = Net::HTTP.start(API.host, API.port, use_ssl: true, read_timeout: 90) { |http| http.request(request) } raise "RenderScreenshot #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) response.body end end
In a Rails app, replace WickedPdf.new.pdf_from_string(render_to_string(...), options) with RsWickedPdf.new.pdf_from_string(...). Controller actions that use render pdf: go through WickedPDF's own renderer, so change those to render the template to a string first, then call the shim. The RenderScreenshot Ruby SDK is the other route if you prefer a builder over raw JSON. Rails HTML to PDF compares Grover and Prawn as well.
PHP: replaces Knp\Snappy\Pdf
<?php // Replacement for Knp\Snappy\Pdf::getOutputFromHtml() / getOutput() that renders with RenderScreenshot. final class RsSnappyPdf { private const API = 'https://api.renderscreenshot.com/v1/screenshot'; private const TOKENS = ['page' => 'pageNumber', 'topage' => 'totalPages', 'date' => 'date', 'title' => 'title', 'webpage' => 'url']; private array $defaults = []; public function setOption(string $name, mixed $value): void { $this->defaults[$name] = $value; } public function getOutputFromHtml(string $html, array $options = []): string { return $this->render($this->buildBody(['html' => $html], $options)); } public function getOutput(string $url, array $options = []): string { return $this->render($this->buildBody(['url' => $url], $options)); } public function buildBody(array $source, array $options): array { $o = array_merge($this->defaults, $options); $pdf = [ 'paper' => strtolower($o['page-size'] ?? 'A4'), // wkhtmltopdf default 'landscape' => strcasecmp($o['orientation'] ?? '', 'landscape') === 0, 'print_background' => empty($o['no-background']), 'margin_top' => $o['margin-top'] ?? '10mm', // wkhtmltopdf default 'margin_right' => $o['margin-right'] ?? '10mm', 'margin_bottom' => $o['margin-bottom'] ?? '10mm', 'margin_left' => $o['margin-left'] ?? '10mm', ]; if (isset($o['zoom'])) { $pdf['scale'] = (float) $o['zoom']; } if (isset($o['footer-center'])) { $pdf['footer'] = $this->template($o['footer-center'], $o['footer-font-size'] ?? 9); $pdf['header'] = '<span></span>'; } if (isset($o['header-center'])) { $pdf['header'] = $this->template($o['header-center'], $o['header-font-size'] ?? 9); } $body = $source + ['output' => ['format' => 'pdf'], 'pdf' => $pdf]; if (!empty($o['print-media-type'])) { $body['browser'] = ['media' => 'print']; } if (isset($o['javascript-delay'])) { $body['wait'] = ['delay' => (int) $o['javascript-delay']]; } return $body; } private function template(string $text, int|string $size): string { $filled = preg_replace_callback('/\[(page|topage|date|title|webpage)\]/', fn ($m) => "<span class='" . self::TOKENS[$m[1]] . "'></span>", htmlspecialchars($text, ENT_QUOTES)); return "<div style='font-size:{$size}pt;width:100%;text-align:center'>{$filled}</div>"; } private function render(array $body): string { $ch = curl_init(self::API); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('RS_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR), ]); $pdf = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($pdf === false || $status !== 200) { throw new RuntimeException("RenderScreenshot {$status}: " . ($pdf ?: curl_error($ch))); } return $pdf; } }
In Laravel, bind RsSnappyPdf where you resolved Knp\Snappy\Pdf (for example in a service provider). Code that calls generateFromHtml($html, $path) to write a file needs one extra line: file_put_contents($path, $snappy->getOutputFromHtml($html)). Laravel HTML to PDF compares this route with dompdf and Browsershot.
Roll it out without a big bang
- Render both for a week. Keep wkhtmltopdf as the source of truth, and send the same HTML through the shim in a background job. Store both files.
- Compare text, then pages. Run
pdftotexton both and diff the words; any missing word is a template bug. Then compare page counts. Big jumps point at smart shrinking (tunepdf.scale) or at a font that is not installed. - Fix the CSS you no longer need. Old
-webkit-boxhacks and table-based layouts that you added for wkhtmltopdf can now be flexbox or grid. - Switch per document type. Move receipts first, then invoices, then the long reports with headers and footers.
- Remove the binary. Delete the package from your images, and with it the security warning about untrusted HTML.
Cost at three volumes
wkhtmltopdf has no licence cost; you pay in servers and maintenance. RenderScreenshot charges one credit per successful PDF of any length, with no overage (prices from our pricing page, October 2026):
| PDFs per month | RenderScreenshot |
|---|---|
| 1,000 | Starter, $10 a month |
| 10,000 | Growth, $40 a month |
| 100,000 | Above our largest plan (60,000 credits); not a self-serve fit today |
At 100,000 PDFs a month, a self-hosted Chrome or Gotenberg container is the cheaper path, and the flag map above applies to it just as well.
When to keep wkhtmltopdf
- Air-gapped servers with fixed templates. If the HTML is yours, never contains user input, uses no modern CSS and never leaves a private network, the risk is contained.
- You need
--tocor a PDF outline. Chrome's print engine does not generate either. A print engine such as Prince does. - File size matters more than fidelity. Our wkhtmltopdf files were about four times smaller. Check whether that still holds for your documents after image compression.
For everything else, get a free API key and run the shim next to your current setup for a week. The PDF API overview lists the header, footer and print-CSS rules that will save you most of the tuning.
Last tested 2026-10-18: wkhtmltopdf 0.12.6.1 (with patched qt) from the wkhtmltopdf packaging releases and wkhtmltopdf 0.12.6-2+b1 from Debian 12, both arm64 in Docker on an Apple M1 Pro; Chrome 148.0.7778.97 through Puppeteer 24.43.1 on Node 22.18.0, macOS. One cold and five warm runs per template.