Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

About sghtmltopdf

sghtmltopdf is a converter and renderer that turns HTML directly into PDF. It is written in Rust and produces PDFs without installing a headless browser such as Chromium, WebKit, or Gecko.

You can use it in three ways, as a CLI, as an HTTP server, or as a library, and all three drive the same engine with the same options. As a library, it is currently available as a Ruby gem.

The PDF engine is built on Rust crates from the Servo project, including html5ever, Stylo, and Taffy.

Thanks to wkhtmltopdf and wicked_pdf

The first time I had to add PDF output to a web application, I was working in Ruby on Rails, and I used wkhtmltopdf together with the wicked_pdf gem that made it usable from Rails.
Until then I had vaguely assumed PDFs were hard and had kept away from them, so I still remember how impressed I was by a workflow where you check the layout as HTML and then get exactly that as a PDF.

However, wkhtmltopdf was archived in January 2023, because QtWebKit, the engine it depended on, reached end of maintenance.
These days, PDFs are usually produced with a headless browser such as Headless Chrome.

I personally liked the approach wkhtmltopdf took, so I set out to build a modernised version of it that fixes the problems I ran into while using it.
The “sg” in sghtmltopdf stands for Second Generation, a nod to wkhtmltopdf.

Problems I ran into with wkhtmltopdf (QtWebKit) and with headless browsers

Specific to wkhtmltopdf:

  • QtWebKit is old, and its CSS3 support is limited (no Flexbox, Grid, or custom properties)
  • With webfonts, the PDF is sometimes written out before the fonts have finished loading
  • When a table breaks across pages, the table header cannot be repeated on the following pages

Shared with headless browsers:

  • A separate binary has to be installed alongside the web application, which makes environments such as AWS Lambda awkward to set up
  • When a very large HTML document comes in, both the time and the memory it takes grow sharply

sghtmltopdf addresses these as follows.

  • Supports CSS3 including Flexbox, Grid, and custom properties (for what is not supported, such as !important and gradients, see the property support table)
  • Resolves webfonts (@font-face) at a deterministic point, with no asynchronous waiting. The face behind each generic family name can be pinned individually with --serif-font, --gothic-font, and --mono-font
  • Repeats the table header on every page when a table breaks across pages
  • Ships as a single executable that needs no extra runtime, or as the official Docker image. From Ruby it is called as a native extension, so it runs inside the web application process without starting a browser
  • Implements a rendering engine dedicated to PDF output, rather than swapping in another browser engine. With none of the machinery a browser needs for on-screen painting or script execution, the gap in processing time widens as the document grows (about 21 times faster than wkhtmltopdf and about 53 times faster than Headless Chrome on a 60,000 element document; see the performance comparison)
  • Offers a streaming mode that reads the HTML in chunks and writes each page out as soon as it is final, which keeps memory use far lower for documents made of paragraphs (the flush boundary is one element below <body>, so it does not help a document that is a single huge table)

Performance compared with wkhtmltopdf and Headless Chrome

These are measured figures for converting the same HTML with the same page setup. The page setup is A4 with 10mm margins, given through @page, and every engine loads the same font file through @font-face.

The engines compared are wkhtmltopdf 0.12.6.1, the last release before it was archived, and Google Chrome 151 in headless mode. Each cell shows peak memory and processing time.

These figures come from cargo run --release --example compare_engines.

A document made of paragraphs:

Elementssghtmltopdfsghtmltopdf (streaming)wkhtmltopdfHeadless Chrome
5,00026MB / 0.11s9MB / 0.10s44MB / 0.49s543MB / 1.32s
20,00080MB / 0.46s14MB / 0.34s86MB / 2.60s943MB / 7.45s
60,000230MB / 1.99s25MB / 1.31s199MB / 42.02s1,525MB / 105.77s

A statement made of a single large table:

Rowssghtmltopdfsghtmltopdf (streaming)wkhtmltopdfHeadless Chrome
5,00049MB / 0.56s48MB / 0.60s62MB / 1.55s1,372MB / 5.12s
20,000173MB / 2.44s173MB / 2.36s163MB / 14.60s6,222MB / 39.94s

The gap in processing time widens as the document grows. At 60,000 elements sghtmltopdf is about 21 times faster than wkhtmltopdf and about 53 times faster than Headless Chrome. For the 20,000 row statement the factors are about 6.0 and about 16.

Memory use is roughly on par with wkhtmltopdf. At 60,000 elements and for the 20,000 row statement sghtmltopdf uses slightly more, and it grows with document size just as wkhtmltopdf does. Streaming mode brings this down to 25MB for the paragraph document. It does not help for a statement built from one huge table, however. Pages are flushed at the boundaries of the elements directly under <body>, so a document that contains a single table cannot release memory until the end of that table has been written.

Headless Chrome is in another league, reaching 6.2GB on the 20,000-row report. It carries everything a browser needs, so sghtmltopdf, which does nothing but produce PDFs, uses memory far more sparingly.

Architecture

CLI, HTTP server mode, and the Ruby binding (native extension, or delegating to an HTTP server) are four different doors into the same option parser (cli/options.rs) and the same engine (sghtmltopdf-core). What differs is how the call comes in, and where the resulting PDF bytes are written (the Sink).

flowchart TD
    subgraph Entry["Entry points (core/src)"]
        CLI["CLI<br/>sghtmltopdf"]
        Server["HTTP server mode<br/>sghtmltopdf server<br/>(tiny_http)"]
        FFI["Ruby native extension<br/>(magnus + rb-sys)<br/>in-process FFI call"]
    end

    CallCLI["Shell / CI"] --> CLI
    CallHTTP["Any language (curl, ...)"] -->|"POST /pdf?options"| Server
    CallRuby["Ruby app / Rails<br/>(gem sghtmltopdf)"] -->|"Sghtmltopdf.render"| FFI
    CallRuby -->|"when server_url is set"| Delegate["ServerClient"]
    Delegate -->|"POST /pdf?options<br/>(HTTP, separate process/host)"| Server

    Options["Shared option parser<br/>cli/options.rs (clap)"]
    CLI --> Options
    Server --> Options
    FFI --> Options

    Engine["sghtmltopdf-core Engine<br/>parse HTML → cascade styles → layout → paginate → write PDF"]
    Options --> Engine

    Engine -->|"FileSink / StdoutSink"| OutCLI["PDF file / stdout"]
    Engine -->|"MemorySink"| OutServer["HTTP response<br/>(chunked with ?stream=1)"]
    Engine -->|"MemorySink / FileSink / CallbackSink"| OutFFI["PDF bytes / file / streamed to a Ruby block"]

The native extension does not spawn a subprocess: it runs inside your web app’s process as FFI (it releases the GVL while rendering, so other threads keep going). Only when server_url is configured is the conversion delegated over HTTP to a separate sghtmltopdf server process, which may be a sibling process on the same host or a remote one. For how the engine streams that pipeline page by page, from parsing the HTML to writing the PDF, see streaming mode.