Replacing wkhtmltopdf in a .NET application
wkhtmltopdf is a command line tool and a C library built on the WebKit engine of Qt 4, frozen around 2012. The .NET libraries built on it, such as DinkToPdf, Rotativa, TuesPechkin and Pechkin, render with the same engine. Its last release, 0.12.6, dates from 2020 and the project has announced that it is no longer maintained. Flexbox and grid layouts, ECMAScript 6 code, WOFF 2 fonts and current CSS render incorrectly or not at all. EvoPdf Next renders with a current Chromium engine inside your .NET process: no external executable, no output file, the PDF comes back as bytes. This page maps every wkhtmltopdf option to its EvoPdf Next equivalent, including the options of the libraries built on it, and shows where the behavior differs, so the documents you produce today come out the same, or better, after the move.
From a process to a method call
A wkhtmltopdf integration starts a process, waits for it, reads the output file and deletes it. In EvoPdf Next the same conversion is a method call with the options set on the converter object. The two examples produce the same A4 document with 10 mm margins from a URL.
wkhtmltopdf --page-size A4 \ --margin-top 10mm --margin-bottom 10mm \ --margin-left 10mm --margin-right 10mm \ https://www.example.com out.pdf
var converter = new HtmlToPdfConverter(); converter.PdfDocumentOptions.LeftMargin = 28; // 10 mm in points converter.PdfDocumentOptions.RightMargin = 28; converter.PdfDocumentOptions.TopMargin = 28; converter.PdfDocumentOptions.BottomMargin = 28; // the page is A4 by default byte[] pdf = converter.ConvertUrl("https://www.example.com");
ConvertHtml(html, baseUrl), where the base URL resolves relative image and CSS paths, the equivalent of running wkhtmltopdf on a file in that folder. A local file is converted with ConvertUrl and a file:// URL. Several pages in one document, the wkhtmltopdf call with more than one input, are covered in Merge Multiple HTML to PDF.From DinkToPdf, Rotativa or TuesPechkin
DinkToPdf, TuesPechkin and Pechkin call the wkhtmltopdf library from .NET; Rotativa runs wkhtmltopdf.exe on a Razor view. Their settings are the wkhtmltopdf options under other names, so the option map on this page applies to them: PaperSize and Orientation are the page layout, the margins are the page margins in points and the header and footer settings become HTML with page number variables.
var converter = new SynchronizedConverter( new PdfTools()); var doc = new HtmlToPdfDocument { GlobalSettings = { PaperSize = PaperKind.A4, Orientation = Orientation.Portrait }, Objects = { new ObjectSettings { HtmlContent = html, HeaderSettings = { Right = "Page [page] of [toPage]" } } } }; byte[] pdf = converter.Convert(doc);
var converter = new HtmlToPdfConverter(); // A4 portrait is the default layout converter.FitBrowserWindowToPage(PdfPageSize.A4); var header = converter.PdfDocumentOptions.PdfHtmlHeader; header.Html = "<div style=\\"text-align:right\\">" + "Page {page_number} of {total_pages}</div>"; byte[] pdf = converter.ConvertHtml(html, baseUrl);
public IActionResult Invoice(int id) { var model = LoadInvoice(id); return new ViewAsPdf("Invoice", model) { PageSize = Size.A4, PageOrientation = Orientation.Portrait, CustomSwitches = "--footer-center \\"[page] / [toPage]\\"" }; }
public IActionResult Invoice(int id) { // the view, converted from its URL string url = Url.Action("InvoiceView", "Orders", new { id }, Request.Scheme); var converter = new HtmlToPdfConverter(); var footer = converter.PdfDocumentOptions.PdfHtmlFooter; footer.Html = "<div style=\\"text-align:center\\">" + "{page_number} / {total_pages}</div>"; byte[] pdf = converter.ConvertUrl(url); return File(pdf, "application/pdf"); }
HttpRequestCookies, as shown in the demo page Convert the Current Page.Page size, margins and smart shrinking
wkhtmltopdf always produces a fixed page size, A4 by default, with margins of 10 mm and smart shrinking on. The EvoPdf Next default is the same shape: an A4 page with the 1024 pixel browser window scaled to fit it, margins 0. Set the margins and, for another paper size, call FitBrowserWindowToPage with it.
What smart shrinking did
With smart shrinking on, the wkhtmltopdf default, the page is laid out wider than the paper and scaled down to fit, so text comes out smaller than in the browser. The usual remedies were --zoom 1.3 or --disable-smart-shrinking, which lays the HTML out at the paper width at 96 pixels per inch, 1:1.
What the zoom does in EvoPdf Next
It works like the print zoom of a browser: the page is laid out at the paper width divided by the zoom and drawn scaled by the zoom, so a lower zoom gives a wider layout and smaller text. Content wider than the layout is shrunk by at most 1.5 times and cut at the right edge beyond that, where wkhtmltopdf shrank without limit. The values for other pages and margins are in HTML to PDF Page Setup and Scaling.
| wkhtmltopdf | EvoPdf Next | Result on A4 |
|---|---|---|
--disable-smart-shrinking | LayoutAtPageWidth(PdfPageSize.A4) | The HTML is laid out at 793 pixels (the paper width) and drawn 1:1 |
| smart shrinking on (default) | FitBrowserWindowToPage(PdfPageSize.A4) | A 1024 pixel desktop layout drawn at 77.47 percent |
--zoom 1.3 with smart shrinking on | LayoutAtPageWidth(PdfPageSize.A4) | The two settings cancel each other in wkhtmltopdf; the 1:1 layout is the equivalent |
--zoom 1.5 without smart shrinking | LayoutAtPageWidth(PdfPageSize.A4), then HtmlViewerZoom = 150 and HtmlViewerWidth = 529 | Laid out at 529 pixels and drawn at 150 percent |
--viewport-size 1024x768 | HtmlViewerWidth = 1024, HtmlViewerHeight = 768 | The browser window in which the page loads and runs its scripts |
--page-width 200mm --page-height 300mm | PdfPageSize = new PdfPageSize(567, 850) | A custom page size, in points |
--orientation Landscape | PdfPageOrientation = PdfPageOrientation.Landscape | |
--dpi, --image-dpi, --image-quality, --lowquality | none | Text and vector graphics are always exact and images keep their original data, so there is nothing to resample |
Option map
The remaining wkhtmltopdf options and their EvoPdf Next equivalents. Options not listed either have no meaning in a library (--copies, --collate, --quiet, --read-args-from-stdin) or are covered by the Chromium engine without a setting.
| wkhtmltopdf | EvoPdf Next | Notes |
|---|---|---|
--print-media-type | MediaType = "print" | Screen is the default in both. Select Media Type for Screen or Print |
--no-background | PdfDocumentOptions.PrintBackgrounds = false | Backgrounds are printed by default in both |
--grayscale | none | Apply a CSS filter to the page or convert the colors in the PDF afterwards |
--javascript-delay 2000 | ConversionDelay = 2 | Seconds in EvoPdf Next, milliseconds in wkhtmltopdf |
--window-status done | TriggeringMode = TriggeringMode.Manual and evoPdfConverter.startConversion() called by the page | The page decides when it is ready. Select Conversion Triggering Mode |
--run-script | ScriptToExecuteAfterLoad | JavaScript run in the page after it loads and before it is printed |
--disable-javascript, -n | JavaScriptEnabled = false | |
--no-stop-slow-scripts | NavigationTimeout | The only limit is the navigation timeout, 120 seconds by default |
--cookie name value | HttpRequestCookies.Add(name, value) | Add Cookies to HTML Page Request |
--custom-header name value | HttpRequestHeaders.Add(name, value) | Add HTTP Headers to HTML Page Request |
--post name value | HttpPostFields.Add(name, value) | Access HTML Pages with GET and POST |
--username, --password | AuthenticationOptions.Username, AuthenticationOptions.Password | Convert HTML Pages with Authentication |
--enable-local-file-access, --allow | LocalFilesEnabled = true | Local files referenced by the page are blocked by default in both |
--encoding utf-8 | none | HTML strings are .NET strings; a page loaded from a URL takes its encoding from the headers and meta tags |
--user-style-sheet file.css | ScriptToExecuteAfterLoad adding a <style> or <link> element | Add it at the end of the document head so that it wins over the page CSS |
--minimum-font-size | none | Font sizes come from the CSS; a zoom above 100 enlarges the whole page |
--title | PdfDocumentInfo.Title | The HTML title is used when the property is not set |
--outline, --outline-depth | PdfDocumentOptions.GenerateDocumentOutline = true | Auto Create Hierarchical Bookmarks |
toc object, --xsl-style-sheet | PdfDocumentOptions.TableOfContents with Title, Style and CreateInline | The style is CSS instead of XSL. Auto Create Table of Contents |
cover page.html | the cover converted first and merged | Merge Multiple HTML to PDF |
--enable-forms | PdfDocumentOptions.GeneratePdfFormFields = true | Create PDF Forms from HTML Forms |
--enable-internal-links, --enable-external-links | always on | Convert Internal Links from HTML to PDF |
--page-offset | PdfHtmlHeader.PageNumberOffset | See headers and footers below |
--proxy, --ssl-*, --load-error-handling | system proxy settings; TLS validation is the Chromium one | Certificate errors are reported as conversion errors |
page-break-* rules in a user style sheet | the same CSS in the page | Insert Page Breaks in PDF Using CSS |
thead repeated on every page | PdfDocumentOptions.RepeatTableHeaderFooter = true | Repeat HTML Table Header and Footer in PDF |
Headers and footers without the query string script
wkhtmltopdf has text headers (--header-left, --header-center, --header-right) and HTML headers loaded from a file with --header-html. The variables [page], [topage], [date] and [title] are replaced in text headers and passed to HTML headers as query string parameters, which a script in the header reads and writes into the elements. EvoPdf Next has one kind of header, an HTML template, in which {page_number} and {total_pages} are written directly. Lines and spacing are CSS; a text header becomes a table with three cells.
<html><head><script> function subst() { var vars = {}; var query = document.location.search.substring(1).split('&'); for (var i in query) { var pair = query[i].split('='); vars[pair[0]] = decodeURIComponent(pair[1]); } document.getElementById('page').textContent = vars['page']; document.getElementById('topage').textContent = vars['topage']; } </script></head> <body onload="subst()"> <table style="width:100%;font:10pt Arial;border-bottom:1px solid #999"><tr> <td>Quarterly report</td> <td style="text-align:right">Page <span id="page"></span> of <span id="topage"></span></td> </tr></table> </body></html>
converter.PdfDocumentOptions.PdfHtmlHeader.Html = """ <table style="width:100%;font:10pt Arial;border-bottom:1px solid #999"><tr> <td>Quarterly report</td> <td style="text-align:right">Page {page_number} of {total_pages}</td> </tr></table> """; converter.PdfDocumentOptions.PdfHtmlHeader.HtmlBaseUrl = "https://www.example.com"; converter.PdfDocumentOptions.PdfHtmlHeader.Margins.Bottom = 14; // --header-spacing 5
| wkhtmltopdf | EvoPdf Next |
|---|---|
--header-html header.html | PdfDocumentOptions.PdfHtmlHeader.HtmlSourceUrl, or PdfHtmlHeader.Html with HtmlBaseUrl; setting one of them enables the header |
[page], [topage] read from the query string by a script | {page_number}, {total_pages} written in the header HTML |
[date], [time], [title] | the value written in the header HTML by the .NET code that builds it |
[section], [subsection] | none; a different header per section is done by converting the sections separately and merging them |
--header-left, --header-center, --header-right, --header-font-size, --header-font-name | an HTML table with three cells and its CSS |
--header-spacing 5 (mm) | PdfHtmlHeader.Margins.Bottom = 14; the header height is measured from its content or fixed with PdfHtmlHeader.Height |
--header-line | a border-bottom rule in the header CSS |
--page-offset 3 | PdfHtmlHeader.PageNumberOffset |
| header on every page | the same, with ShowInFirstPage, ShowInOddPages and ShowInEvenPages to hide it on some pages |
Rendering differences to expect
Most pages look better after the move without any change, because the engine is a current Chromium. A few differences are worth knowing before comparing the documents.
Current CSS and JavaScript
- Flexbox, grid,
position: sticky, CSS variables, ECMAScript 6 and later, WOFF 2 fonts and SVG filters work as in Chrome - Workarounds added for wkhtmltopdf, such as tables in place of flexbox,
-webkit-prefixed properties or polyfills, can be removed
Responsive pages
- Pages that read
window.innerWidthor use media queries see the browser window width, 1024 pixels by default - With smart shrinking, wkhtmltopdf laid such pages out at a different width, so a responsive page can change layout after the move; the zoom settings above give the desktop layout on a standard page
Page size and fonts
- The default page in EvoPdf Next is A4 with margins 0; set the margins for every conversion moved from wkhtmltopdf
- Fonts are the ones installed on the server or loaded by the page; a missing font shows as a substitution in both, but the substitute can differ
Images and process model
- Images loaded lazily, with
loading="lazy"or by script, are loaded before printing whenLoadLazyImagesis true, the default; wkhtmltopdf left them empty - The rendering engine runs in its own process tree, started on first use and stopped when idle; nothing to install and no zombie processes to clean up
Migration checklist
Code
- Replace the process start with a converter object and
ConvertUrlorConvertHtml; the PDF comes back as bytes, no output file is needed - Set the margins in points (10 mm is 28 points) and call
FitBrowserWindowToPageorLayoutAtPageWidthwith the page size and the orientation - Decide the text size:
LayoutAtPageWidthfor the--disable-smart-shrinkinglook,FitBrowserWindowToPagefor the smart shrinking look
Options and templates
- Move
--print-media-type,--no-background,--javascript-delay, cookies, headers and credentials to the properties in the option map - Rewrite the header and footer HTML with {page_number} and {total_pages} in place of the query string script; replace
--header-spacingwith the template margins - Remove the workarounds added for the old engine and compare a few documents side by side
Convert your wkhtmltopdf pages on the Next engine
The demo application runs without a license key, with a stamp on the output, so you can compare the two results before changing anything in production.