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.

Installation

There are three ways to install it, depending on how you plan to use it.

UsageWhat you install
Run the HTTP server as a long-lived processThe Docker image ghcr.io/waka/sghtmltopdf
Use it from Ruby or RailsThe sghtmltopdf gem
Convert from your own command lineThe sghtmltopdf binary, built from source

Plain binaries, as a tarball on GitHub Releases or through Homebrew, are not distributed. The server ships inside the image, and the FFI path ships inside the gem, so each already carries what it needs. To try the CLI locally, build from source as shown below.

Docker

docker pull ghcr.io/waka/sghtmltopdf:latest
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf

Japanese fonts (BIZ UDPGothic and BIZ UDPMincho) are bundled, so Japanese PDFs come out without supplying a font yourself. See Docker for details.

Building from source

All you need is the stable Rust toolchain. There are no C libraries or system packages to install.

git clone https://github.com/waka/sghtmltopdf.git
cd sghtmltopdf
cargo build --release

The binary is written to target/release/sghtmltopdf. Put it somewhere on your PATH, or call it where it is.

./target/release/sghtmltopdf --version

If you do not need the HTTP server mode, you can drop that feature and get a smaller binary.

cargo build --release --no-default-features --features cli

Ruby / Rails

# Gemfile
gem "sghtmltopdf"

Precompiled gems are distributed, so you do not need a Rust toolchain to install it. The supported platforms are x86_64-linux, aarch64-linux, and arm64-darwin (glibc), on Ruby 3.2 or later.

No external process is started. Conversion happens inside your own process as a native extension built with magnus and rb-sys. The GVL is released while the heavy work runs, so other Puma threads keep going.

See Ruby / Rails for how to use it.

About fonts

Without --font, the fonts installed on the system are used. For documents that contain Japanese, we recommend naming a font file explicitly, or using the Docker image, which bundles fonts.

sghtmltopdf invoice.html \
  --font NotoSansJP-Regular.ttf \
  --gothic-font NotoSansJP-Regular.ttf

See Fonts for details.

Docker

An official image with Japanese fonts bundled is published on ghcr.io. It is the easiest way to run the server mode as a long-lived process.

docker pull ghcr.io/waka/sghtmltopdf:latest

The supported platforms are linux/amd64 and linux/arm64, both glibc; musl systems such as Alpine are out of scope. The same tag serves both.

Running it as a server

Started with no arguments, it runs as the HTTP server.

docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf
curl --data-binary @invoice.html \
     'http://127.0.0.1:8080/pdf?page-size=A4' \
     -o invoice.pdf

Inside the container it listens on --listen 0.0.0.0:8080. The server mode defaults to 127.0.0.1, so that nothing is exposed by accident, but that address cannot be reached from outside the container, so the image sets the flag explicitly in its CMD.

To change the startup options, write the command out yourself, starting from server.

docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf \
    server --listen 0.0.0.0:8080 --workers 4 --max-body-size 52428800

There is no authentication and no TLS. Put a reverse proxy in front of it before exposing it to the outside.

docker compose

services:
  pdf:
    image: ghcr.io/waka/sghtmltopdf:0.1
    ports: ["8080:8080"]
    healthcheck:
      test: ["CMD", "sghtmltopdf", "--version"]
      interval: 30s

curl is not in the image, so either use --version as the health check as shown, or call GET /healthz from outside, for example from your load balancer.

Running it as a CLI

The ENTRYPOINT is the binary itself, so passing arguments runs it as the CLI.

docker run --rm -v "$PWD:/work" -w /work --user "$(id -u):$(id -g)" \
    ghcr.io/waka/sghtmltopdf invoice.html -o invoice.pdf

The container does not run as root; it runs as UID 10001. When writing a PDF into a directory on the host, match the host owner with --user, as shown above.

Bundled fonts

The image contains BIZ UDPGothic and BIZ UDPMincho, Regular and Bold, four faces in total, under the SIL Open Font License 1.1. The full licence text is at /usr/share/doc/sghtmltopdf/fonts/ inside the image.

What the CSS saysFont that is used
No font-familyBIZ UDPMincho (serif)
font-family: sans-serifBIZ UDPGothic (sans-serif)
font-family: serifBIZ UDPMincho
font-family: monospaceNo monospace font is bundled, so this falls back to BIZ UDPMincho
font-weight: boldThe real Bold face of each family, not synthesised bold

Because the fonts are fixed, the same HTML always produces the same PDF, apart from the creation timestamp inside the file. Not depending on the host’s font setup is one of the reasons to use the image.

To use a different font, mount it and pass it with --font. It takes precedence over the bundled fonts.

docker run --rm -v "$PWD:/work" -w /work --user "$(id -u):$(id -g)" \
    ghcr.io/waka/sghtmltopdf invoice.html -o invoice.pdf \
    --font fonts/YourFont-Regular.ttf --gothic-font fonts/YourFont-Regular.ttf

What is in the image

PathWhat it does
/usr/local/bin/sghtmltopdfThe executable
/usr/share/fonts/truetype/sghtmltopdf/*.ttfBundled fonts
/usr/share/doc/sghtmltopdf/fonts/OFL-*.txtFont licences
/workThe default working directory

The base is debian:bookworm-slim with no extra system packages. Even ca-certificates is unnecessary, because the TLS root certificates are embedded in the binary.

Your first PDF

Assuming you have finished installing, this page walks from a single page through to page breaks.

1. Convert

Start with a hello.html.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      body { font-family: sans-serif; }
      h1 { border-bottom: 2px solid #333; padding-bottom: 8px; }
      .total { text-align: right; font-size: 1.2em; font-weight: bold; }
    </style>
  </head>
  <body>
    <h1>Invoice</h1>
    <p>Thank you for your continued business. Please find the charges below.</p>
    <p class="total">Total 12,000 JPY</p>
  </body>
</html>

Then convert it.

sghtmltopdf hello.html -o hello.pdf

Without -o, the output file is the input name with the extension changed to .pdf. Use - to read from standard input and write to standard output.

cat hello.html | sghtmltopdf - -o - > hello.pdf

2. Choose the paper and margins

sghtmltopdf hello.html -o hello.pdf \
  --page-size A4 --margin-top 20mm --margin-bottom 20mm

The units mm, cm, in, pt, and px are accepted, and a bare number means mm.

The same thing can be written in CSS with @page. If you write both, the CSS wins; the CLI options are treated as initial values. This is the opposite of wkhtmltopdf, so check Migrating from wkhtmltopdf when you move over.

@page {
  size: A4;
  margin: 20mm;
}

3. Break pages

“Start a new page here” is expressed with the CSS property break-before.

<style>
  .page-break { break-before: page; }
</style>

<h1>Invoice</h1>
<p>This is page 1.</p>

<div class="page-break">
  <h1>Line items</h1>
  <p>This is page 2.</p>
</div>

break-after, which breaks after the element, and break-inside: avoid, which keeps the element in one piece, are available too. So are orphans and widows, which stop a single line from being stranded at a page boundary. See Page breaks for details.

4. Add headers and footers

sghtmltopdf hello.html -o hello.pdf \
  --header-center "Invoice" \
  --footer-right "[page] / [topage]" \
  --header-line

[page] is replaced with the current page number and [topage] with the total page count. JavaScript is never executed, so these placeholders take its place. If you would rather build the header in HTML, use --header-html.

5. Pin the fonts

The examples so far use whatever fonts the system has. On a server or in CI, where you do not want the output to depend on the environment, name the font files explicitly.

sghtmltopdf hello.html -o hello.pdf \
  --font NotoSansJP-Regular.ttf \
  --gothic-font NotoSansJP-Regular.ttf

CLI reference

Every option of the sghtmltopdf command.

sghtmltopdf [OPTIONS] <INPUT.HTML>
sghtmltopdf server [OPTIONS]

The first converts a file; the second runs the HTTP server.

For how these map to wkhtmltopdf’s options, including the full list of the ones that are not supported, see the wkhtmltopdf option mapping.

Basics

# The simplest form; the system fonts are used
sghtmltopdf invoice.html -o invoice.pdf

# Without an output path, the input name with the extension changed to .pdf
sghtmltopdf invoice.html

# Read from standard input and write to standard output
cat invoice.html | sghtmltopdf - -o - > invoice.pdf

Input and output

OptionDefaultDescription
<INPUT.HTML>(required)The input HTML; - means standard input
-o, --output <PATH>The input name with .pdfWhere to write. - means standard output. It cannot be omitted when reading from standard input
--base-url <URL|DIR>The directory of the input HTMLThe base for resolving relative references. An http(s) URL becomes the default <base href>; a <base href> in the HTML wins
--encoding <NAME>DetectedThe character encoding of the input. The order is BOM, then --encoding, then <meta charset>, then UTF-8
--streamingOffProcess in streaming mode

The output is written to a temporary file and then renamed, so a failure never leaves a broken PDF behind.

Page setup

OptionDefaultDescription
-s, --page-size <SIZE>A4A3, A4, A5, Letter, or Legal, case insensitive
--page-width <LENGTH>The paper width; takes precedence over --page-size
--page-height <LENGTH>The paper height; takes precedence over --page-size
-O, --orientation <O>PortraitLandscape swaps width and height at the end
-T, --margin-top <LENGTH>1in (96px)Top margin
-B, --margin-bottom <LENGTH>1inBottom margin
-L, --margin-left <LENGTH>1inLeft margin
-R, --margin-right <LENGTH>1inRight margin

Lengths take the units mm, cm, in, pt, and px. A bare number means mm, as in wkhtmltopdf.

How this relates to @page in CSS

These options are initial values. If the CSS in the HTML says @page { size: … } or @page { margin: … }, the CSS wins, property by property. Note that this is the opposite of wkhtmltopdf.

Fonts

OptionDescription
--font <PATH>The font to use; may be given more than once. Without it, system fonts are used
--font-index <N>The face index inside a TrueType Collection (.ttc), for the preceding --font
--gothic-font <PATH> (with --gothic-font-index)The font behind font-family: sans-serif
--serif-font <PATH> (with --serif-font-index)The font behind font-family: serif
--mono-font <PATH> (with --mono-font-index)The font behind font-family: monospace

Fonts are resolved in the order --font, then @font-face, then a system search by font-family name. Only if none of those finds anything does a system sans-serif candidate become the default font.

Without --font, the output depends on the fonts of the machine it runs on. To keep output stable on a server or in CI, name the fonts with --font or @font-face. See Fonts for details.

PDF output and metadata

OptionDefaultDescription
--title <TEXT>The <title> of the HTMLThe /Title of the PDF
--author, --subject, --keywords <TEXT>The matching entries of the Info dictionary; specific to sghtmltopdf
-d, --dpi <DPI>96How many dpi a CSS px stands for. At 72, 1px equals 1pt
--zoom <FACTOR>1.0A scale factor, multiplied into the --dpi factor
-g, --grayscaleOffConvert fills and strokes to greyscale, by sRGB relative luminance
--no-pdf-compressionOffTurn off Flate compression of PDF objects; image data is unaffected

/Producer and /CreationDate are always written.

The limits of greyscale

JPEG images, which pass through as /DCTDecode, and CMYK images have no decoder here, so they stay in colour.

What gets drawn

OptionDescription
--no-imagesDo not load <img> or the CSS background-image
--no-backgroundDo not paint element backgrounds, neither colour nor image
--user-style-sheet <PATH>CSS in the user origin; may be given more than once. Stronger than the UA stylesheet, weaker than the author’s CSS
--minimum-font-size <PX>A lower bound on the computed font-size
--disable-external-linksDo not create PDF annotations for external http(s) links
--disable-internal-linksDo not create PDF annotations for internal #id links
--keep-relative-linksWrite relative external links as they are, without making them absolute
--load-media-error-handling <ignore|abort>What to do when an image, stylesheet, or font cannot be fetched; ignore by default

Headers and footers

There are two ways to do this. If both are given for the same side, --header-html wins.

1. As text

These map onto the margin boxes of @page.

sghtmltopdf report.html \
  --header-center "Quarterly report" \
  --footer-right "[page] / [topage]" \
  --header-line
OptionDescription
--header-left, --header-center, --header-right <TEXT>The three positions across the header
--footer-left, --footer-center, --footer-right <TEXT>The three positions across the footer
--header-font-name, --header-font-sizeThe header font; the footer has the same pair
--header-line, --footer-lineDraw a rule
--header-spacing, --footer-spacing <MM>The gap from the body text; the margin grows by that much
--default-headerA default header with the title and the page number
--replace <NAME=VALUE>Replace any [NAME] with a value; may be given more than once

The placeholders are [page] for the current page, [topage] for the total page count, [frompage], [title] and [doctitle], [date], [time], and any name you define with --replace.

[section], [subsection], [webpage], [sitepage], and [sitepages] are not supported.

2. As HTML

sghtmltopdf report.html --header-html header.html --footer-html footer.html

A separate HTML file is rendered into the margin area of every page. Placeholders are substituted in the HTML as text; JavaScript is not executed.

  • Anything that does not fit in the margin is clipped; the margin does not grow to accommodate it
  • No external resources are fetched. Inline <style>, text, borders, and background colours work; <img> and external CSS do not
  • @font-face inside the header or footer HTML is not loaded. Name any font used only there with --font

Cover page and table of contents

sghtmltopdf report.html --cover cover.html --toc --footer-center "[page]"

They are written in the order cover, table of contents, body.

OptionDefaultDescription
--cover <PATH>The HTML to use as the cover. It is not counted in the page numbers and gets no header or footer
--tocOffInsert a table of contents before the body; not available in streaming mode
--toc-header-text <TEXT>Table of ContentsThe <h1> of the table of contents
--toc-level-indentation <WIDTH>1emThe indent added per level
--toc-text-size-shrink <REAL>0.8The text size ratio applied per level
--disable-dotted-lines(drawn)Do not draw the dotted leader under each entry
--disable-toc-links(linked)Do not link the entries to their headings
--enable-toc-back-links(not linked)Link each heading back to the table of contents
--page-offset <N>0Shift where the page numbering starts

The HTML structure and default styling of the table of contents follow what wkhtmltopdf’s default TOC XSL produces: nested <ul> for the levels, and <div><a>heading</a><span>page number</span></div> for each entry. To change how it looks, apply CSS with --user-style-sheet; XSLT is not supported.

Headings are collected from h1 through h6. A heading without an id is given a generated destination name.

Access control

OptionCLI defaultServer default
--enable-local-file-access, --disable-local-file-accessAllowedDenied
--allow <PATH>No restrictionNo restriction
--allow-remote-assetsDeniedDenied

Given one or more --allow paths, local references are confined to those directories. This applies to <img src>, external CSS, and @font-face alike.

Ranges

References outside the base directory

Local references stay inside the base directory (--base-url, defaulting to the directory the input HTML lives in) by default. A ../ that would escape it is an error. This keeps untrusted HTML from reading arbitrary files through a reference such as <img src="../../../../etc/passwd">.

A ../ that resolves within the base directory, such as assets/../images/logo.png, keeps working as before.

Ranges

$ sghtmltopdf pages/index.html -o out.pdf
エラー: ../images/logo.png: 基準ディレクトリ(pages)の外を参照しています。
  外部のファイルを読む場合は --allow でディレクトリを明示してください

$ sghtmltopdf pages/index.html --allow . -o out.pdf

The check is lexical, so symlinks under the base directory are followed. Use --allow when you want the boundary to hold across symlinks as well (that check resolves real paths).

Limits on input size

HTML with more than roughly 500,000 nodes is rejected. Computed styles, the box tree and the layout result all grow in proportion to the node count; measured at 472 B to 1210 B per node.

Even a document of several thousand pages stays in the hundreds of thousands of nodes, so real documents practically never hit this. If you do hit it, split the document or use streaming mode. Streaming releases each processed part as it goes, so it can convert documents whose total exceeds the limit.

Memory that scales with the amount of text is not covered by this limit (three elements holding 10 MiB of text still use about 1.7 GiB). In HTTP server mode --max-body-size plays that role.

Logging and exit codes

--log-level <none|error|warn|info>, info by default, and -q or --quiet, which is the same as --log-level none.

CodeMeaning
0Success
1Usage error: an unknown option, a malformed value, or an option that is not supported
2Input or resource error: a missing file, a font that cannot be read, or a failed fetch under abort
3Rendering error, such as breaking one of the streaming mode restrictions
4Time limit exceeded (HTTP server mode’s --timeout only; the CLI has no time limit, so this never appears)

Streaming mode

With --streaming, the HTML is read in chunks, each page is written to the PDF as soon as its layout is final, and the memory that page used is released.

sghtmltopdf big.html -o big.pdf --streaming

It pays off for HTML with tens of thousands of elements (see Memory and processing time). In exchange, anything that cannot be decided without seeing the whole document becomes unavailable.

Not available (these are errors)

Passing any of these exits with code 3.

Not availableWhy
counter(pages), and [topage] in headers and footersThe total page count is not known in a single pass
--tocSame reason; a table of contents needs the page numbers of the body
A background colour or border on <html> or <body> itselfThat decoration would have to be reproduced across every page
<style> or <link rel="stylesheet"> after <body>It cannot be applied retroactively to pages that are already written

Warned about, but processing continues

These change the result, so a warning is printed rather than passing over them silently.

LimitationBehaviour
Finding a system font from a font-family nameThe default font is used instead. Naming the font with --font, --gothic-font, --serif-font, --mono-font, or @font-face resolves it.
Searching the system for a font that can draw a given characterNot done, because the whole document cannot be read ahead. Only when no font at all is given, one CJK font is loaded up front (see Fonts). Characters that cannot be drawn are reported one by one.
:last-child, :nth-last-child, :last-of-type, :nth-last-of-type, :only-child, :only-of-type, and :emptyNever match, because they cannot be decided until the parent’s child list is complete

Selectors that look backwards do not work because the style has to be settled the moment the element is read. Rewrite rules such as “remove the rule on the last row only” by adding a class and writing .last { … } instead.

Still available

The following work exactly as they do in the normal mode.

  • --cover, the cover page
  • Headers and footers, as far as [page]; [topage] is not available
  • Page setup and PDF metadata
  • --grayscale, --dpi, and --zoom
  • The options that change what is drawn, such as --no-images
  • Page breaks, including break-before, break-after, break-inside, orphans, and widows

Memory and processing time

These are measured figures for converting the same HTML in both modes. Each cell shows peak memory and processing time.

ElementsHTML sizeNormal mode--streaming
1,00046KB11MB / 0.02s8MB / 0.02s
5,000233KB26MB / 0.08s10MB / 0.10s
20,000946KB81MB / 0.35s15MB / 0.38s
60,0002.8MB228MB / 1.05s28MB / 1.07s

Peak memory in the normal mode grows roughly in proportion to the document, while with --streaming it barely grows at all; at 60,000 elements it is about one eighth. The slight growth that remains comes from the PDF cross-reference table, which holds the position of every object, and from the tally of glyphs in use, both of which are kept until the end.

Processing time is much the same in both modes. --streaming comes out slightly slower, but the difference stays within 0.03 seconds at every size, and shrinks in relative terms as the document grows (2% at 60,000 elements). You are not trading time away for the large drop in memory.

The measurements were taken as follows.

  • A release build of sghtmltopdf 0.1.0, on an Intel Core Ultra 7 258V with 16GB of memory, under WSL2 (Linux 5.15)
  • HTML consisting of nothing but the given number of <p> elements, each 60px tall, with the font named through --font
  • Peak memory is the maximum resident set size (RSS) of the process, taking the better of two runs

When to use it

  • Producing a statement of several thousand pages from a single HTML file
  • Running where memory is tightly capped, such as under a container limit or on a serverless platform

For a document of a few dozen pages, such as an invoice, the normal mode is fine. There is no reason to take on the limitations.

In server mode the same mode is selected by adding streaming to the query. Combined with ?stream=1, which returns the body chunked, input, rendering, and output all become incremental.

curl --data-binary @big.html 'http://127.0.0.1:8080/pdf?stream=1&streaming' -o out.pdf

How streaming works

flowchart TD
    A["HTML chunk"] --> B["Streaming parser"]
    B --> C["Style cascade"]
    C --> D["Layout + pagination"]
    D --> E["PDF writer"]
    E --> F["PDF output"]
    D -. "on every settled page" .-> E
    E -. "free the memory and read on" .-> B

As soon as a page boundary is final, that page is written to the PDF and the memory it used is released before reading on, which is how large documents are processed without memory use growing.

HTTP server mode

In this mode the binary stays resident and accepts conversions over HTTP. Your application no longer has to start a process, and load balancing can be left to a load balancer.

sghtmltopdf server --listen 127.0.0.1:8080 --font NotoSansJP-Regular.ttf
# → prints `listening on 127.0.0.1:8080` to standard output
curl --data-binary @invoice.html \
     'http://127.0.0.1:8080/pdf?page-size=A4&margin-top=20mm&toc' \
     -o invoice.pdf

For a long-lived process, the Docker image with Japanese fonts bundled is the easy path; with no arguments it starts as this server.

docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf

Startup options

OptionDefaultDescription
--listen <ADDR:PORT>127.0.0.1:8080The address to listen on. :0 picks a free port automatically
--workers <N>CPU coresHow many worker threads convert at the same time
--max-queue <N>Workers × 4How many requests may wait to be accepted; beyond that, 503
--max-body-size <BYTES>4194304 (4 MiB)The maximum request body size
--timeout <SECS>30Seconds granted to one request: the queue wait plus the conversion (exceeding it returns 504)
--font <PATH> and the other font optionsCannot be changed per request
--enable-local-file-access, --allow <PATH>, --allow-remote-assetsAll deniedOnly when you allow them explicitly

There is no authentication and no TLS. Put a reverse proxy in front of it before exposing it to the outside.

Endpoints

Method and pathDescription
POST /pdfConvert the HTML in the body to PDF and return it as application/pdf
POST /pdf?stream=1The same, but sent with chunked transfer encoding as each page becomes final
GET /healthzok
GET /versionsghtmltopdf <version>

Query parameters

Take any long CLI option, drop the --, and use it as a query key. Values are interpreted exactly as on the CLI, because they go through the same parser.

Only the options on the allowlist can be given. They cover things that may vary per request and touch neither the server’s filesystem nor the network: page geometry, PDF metadata, header/footer text, the look of the table of contents, and so on. Anything else returns 400.

QueryThe equivalent CLI option
?page-size=A4--page-size A4
?margin-top=20mm--margin-top 20mm
?toc--toc (no value means true)
?grayscale=1 / =true--grayscale
?grayscale=0 / =falseThe same as not passing it

Values may be percent encoded, with %XX and +.

See the CLI reference for what each option means.

Options that can only be set when the server starts

The following are not on the allowlist; passing them returns 400. Options that take a local path, the output destination, access control and logging can only be set when the server starts.

font, font-index, gothic-font, gothic-font-index, serif-font, serif-font-index,
mono-font, mono-font-index, output, cover, header-html, footer-html,
user-style-sheet, base-url, allow, enable-local-file-access,
disable-local-file-access, allow-remote-assets, log-level, quiet

Status codes

CodeWhen
200Success, with Content-Type: application/pdf
400An unknown or forbidden query key, a malformed value, or an empty body
404An unknown path
405A method that is not allowed
413The body is larger than --max-body-size
500Rendering failed
503The queue is full, beyond --max-queue
504--timeout exceeded (queue wait, or the conversion took too long)

Streaming

  • Input: the request body is not read to the end first; it is fed to the engine 64KiB at a time.
    • A large HTML file never sits in memory as a whole
  • Output: by default the PDF is buffered and returned with a Content-Length. With ?stream=1 it is sent with chunked transfer encoding as each page becomes final
curl --data-binary @big.html 'http://127.0.0.1:8080/pdf?stream=1' -o out.pdf

Combined with the engine’s own streaming mode, selected with ?streaming, input, rendering, and output all become incremental.

Estimating memory

The memory a conversion needs is roughly proportional to the size of the input. Measured on an optimized build:

FactorUnit costWhat bounds it
Number of elements472 B to 1210 B per nodeNode limit (500,000)
Amount of textAbout 185 MiB per 1 MiB of input--max-body-size

Both limits are set so that the worst case stays around 600 to 750 MiB by default. Workers convert concurrently, so the whole process needs that figure multiplied by the number of workers. With the defaults (--workers is the CPU core count) on an 8-core machine the worst case is about 6 GiB, so tune --workers or --max-body-size to your container’s memory limit.

Exceeding the node limit returns 400. Adding ?streaming releases each processed part as it goes, which makes the node limit much harder to hit.

Known limitations

  • --timeout covers the queue wait plus the conversion. Expiry is checked per fed chunk, per top-level element and per written page, so it is noticed at most one such interval late. It does not look inside a single layout call--timeout covers the queue wait plus the conversion. The conversion checks for expiry per fed chunk, per top-level element and per written page, so it notices at most one such interval late. It does not look inside a single layout call
  • Measured: with a heavy 10 MiB HTML and --timeout 2, actual responses took 2.1 to 5.2 seconds (including the time to drop the large DOM after aborting). It never returns sooner than the value you setMeasured: with a heavy 10 MiB HTML and --timeout 2, actual responses took 2.1 to 5.2 seconds (including the time to drop the large DOM after aborting). It never returns sooner than the value you set
  • With ?stream=1, a failure after the headers are sent leaves the status at 200; the pipe closes and the client receives an incomplete PDF. A bad query, an empty body, and an oversized body are all detected before the headers go out, so those still return 400 or 413
  • With ?stream=1 the input is read to the end first, because the HTTP library treats reading the body and writing the response as mutually exclusive. Streaming the input and the output at the same time is not possible

Using it from Rails

The Ruby gem has a server_url setting that delegates conversion to this server. See Ruby / Rails.

Ruby / Rails

How to use the sghtmltopdf gem. The engine itself is written in Rust and runs inside your own process as a native extension built with magnus and rb-sys. No external process is started and no temporary files are handed around.

The conversion options are exactly the same as on the CLI, so see the CLI reference for what each one means. This page covers the Ruby side: naming, Rails integration, errors, and delegating to a server.

If you are coming from wicked_pdf, read Migrating from wicked_pdf as well.

Installation

# Gemfile
gem "sghtmltopdf"

Precompiled gems are distributed, so no Rust toolchain is needed.

Supported
Platformsx86_64-linux, aarch64-linux, x86_64-linux-musl, aarch64-linux-musl, arm64-darwin
Ruby3.2 or later

Linux is covered for both glibc (Debian and Ubuntu) and musl (Alpine), and gem install picks whichever matches the environment. Windows and Intel Macs are out of scope, and the gem cannot be installed there. Delegating to a server is an option in that case.

Basics

pdf = Sghtmltopdf.render("<h1>Invoice</h1>")             # → the PDF as a byte String
Sghtmltopdf.render_to_file(html, "invoice.pdf")         # → writes to a file and returns nil
  • The returned String is ASCII-8BIT, that is, binary
  • The input HTML is passed through as bytes. The engine detects the encoding in the order BOM, encoding:, <meta charset>, then UTF-8, so a UTF-8 String can be passed as is. For Shift_JIS and the like, state it with encoding: "Shift_JIS"
  • render_to_file writes to a temporary file and renames it, so a failure part way through never leaves a broken PDF behind
  • The GVL is released during the heavy work, layout and PDF encoding, so other Puma threads keep running. It is safe to call from several threads at once

Option

Take a long CLI option, drop the --, and replace - with _ to get the key. Values are interpreted exactly as on the CLI, because they go through the same parser.

Sghtmltopdf.render(html, page_size: "A4", margin_top: "20mm", toc: true)
#                        --page-size A4   --margin-top 20mm   --toc
How to write the valueMeaning
page_size: "A4"An option that takes a value
grayscale: trueA flag that takes no value
grayscale: false or nilThe same as not passing it
allow: ["/a", "/b"]Repeating the same option
font: {path: "a.ttc", index: 1}--font a.ttc --font-index 1, keeping the order

Key names are not validated on the Ruby side. The option definitions live in one place in Rust, so an unknown key is reported by the engine as a UsageError.

Nested hashes in the style of wicked_pdf, such as margin: {top: 10}, are not accepted. A bare number means mm in wicked_pdf and wkhtmltopdf but px on this CLI, so flattening it mechanically would silently produce a different margin. Write margin_top: "10mm" instead.

Options that only exist in Ruby

These keys are interpreted by the gem and have no CLI counterpart.

KeyDefaultDescription
server_urlNoneWhen set, conversion is delegated to the HTTP server mode
server_open_timeout5Connection timeout, in seconds
server_read_timeout120Response timeout, in seconds
chunk_size65536Roughly how many bytes each call of the block receives in render; local conversion only

The Rails renderer, render pdf:, also understands disposition, filename, status, and show_as_html.

Global configuration

# for example config/initializers/sghtmltopdf.rb
Sghtmltopdf.configure do |c|
  c.page_size   = "A4"
  c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
end

Global configuration is merged first and the arguments at the call site win. Sghtmltopdf.reset_config! clears it again, mainly for tests.

Fonts

Without a font setting, the system fonts are used and the output depends on the environment. Name the fonts explicitly if you do not want to be at the mercy of what a container happens to have.

Sghtmltopdf.configure do |c|
  c.gothic_font = "/app/vendor/fonts/NotoSansJP-Regular.ttf"  # sans-serif
  c.serif_font  = "/app/vendor/fonts/NotoSerifJP-Regular.ttf" # serif
  c.mono_font   = "/app/vendor/fonts/NotoSansMono-Regular.ttf"
end

Fonts passed through the font options are exempt from allow, the restriction on local references described below.

Errors

All of them inherit from Sghtmltopdf::Error < StandardError. The messages are the same as on the CLI.

ClassWhen it is raised
Sghtmltopdf::UsageErrorA bad option: an unknown key, a malformed value, or an option that is not supported
Sghtmltopdf::InputErrorReading the input or writing the output failed
Sghtmltopdf::RenderErrorRendering failed
Sghtmltopdf::TimeoutErrorThe run exceeded its time limit and was aborted
Sghtmltopdf::InternalErrorAn unexpected failure inside the engine (a bug)
Sghtmltopdf::ServerErrorThe delegated server is unreachable or overloaded

InternalError is raised when Rust panics inside the native extension. The extension catches it and converts it into an ordinary exception, so you can rescue it like the others and the worker process keeps running. If you see one it is an engine bug, so please report it with the HTML that reproduces it.

By default a failure to fetch an image or stylesheet is ignored, with a warning, and processing continues. Pass load_media_error_handling: "abort" to stop instead.

Using it with Rails

The Railtie is only loaded when Rails is, so plain Ruby and Sinatra are unaffected.

The renderer

class InvoicesController < ApplicationController
  def show
    render pdf: "invoice",             # the file name; .pdf is appended for you
      template: "invoices/show",
      layout: "pdf",
      page_size: "A4", margin_top: "20mm"
  end
end

The options are sorted into three groups.

GroupKey
Passed to view renderingtemplate partial inline file plain html body layout locals formats variants handlers prefixes object collection assigns action
Used to build the responsedisposition (inline by default), filename, status
Debuggingshow_as_html, which returns the HTML instead of a PDF
Everything elseConversion options

If the value of pdf: is empty, the action name becomes the file name. A filename: wins over it, and .pdf is never appended twice.

How asset paths are resolved

Rendering does not go through an HTTP server, so a URL such as /assets/… is resolved as a local file. The Railtie supplies these defaults.

KeyDefaultMeaning
base_urlRails.root/publicThe base for absolute path references. With precompiled assets, a plain stylesheet_link_tag works as is
allow[Rails.root]Confines local references to the application directory

Both can be overridden with Sghtmltopdf.configure, regardless of initializer order. The allow default exists so that user input mixed into a template cannot read files outside the document; if you reference something outside the application, such as /usr/share/fonts, add it explicitly.

For cases such as development, where assets have not been written to public/ yet, there are helpers that inline the CSS into a <style> element.

<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
<%= sghtmltopdf_image_tag "logo.png" %>
<%= sghtmltopdf_asset_path "logo.png" %>   <%# nil if it cannot be found %>

Delegating to a server

With server_url set, conversion is handed to a separate process running in HTTP server mode. Use it when you do not want to spend the application’s CPU on rendering, or when you run somewhere the gem does not support, such as Windows.

Sghtmltopdf.configure do |c|
  c.server_url = "http://pdf.internal:8080"
end

pdf = Sghtmltopdf.render(html, page_size: "A4")   # delegated
  • Only one URL is accepted. Load balancing is expected to be handled by something in front, such as nginx or a Kubernetes Service
  • If the server cannot be reached, it does not fall back to local conversion; it raises ServerError. Fonts can only be set when the server starts, so falling back would silently change the output
  • The HTTP statuses map onto the error classes above: 400 to UsageError, 413 to InputError, 500 to RenderError, and anything else to ServerError

Options that cannot be set against a server

Options that take a local path, the output destination, and the access controls can only be set when the server starts; passing them raises UsageError.

font, font-index, gothic-font, gothic-font-index, serif-font,
serif-font-index, mono-font, mono-font-index,
output, cover, header-html, footer-html, user-style-sheet, base-url,
allow, enable-local-file-access, disable-local-file-access,
allow-remote-assets, log-level, quiet

The base_url and allow defaults supplied by the Railtie are dropped automatically, so simply adding server_url in a Rails app does not produce a 400. If you set them explicitly with configure, move them to the server’s startup options.

Receiving the PDF in chunks

Pass a block and it is called for each chunk, instead of the whole PDF being assembled and returned; the return value is nil. This is the hook for writing into Rack’s response.stream or feeding an S3 multipart upload.

Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }

Both locally and when delegating to a server, writing can start before the whole PDF has been assembled. Locally the chunks arrive page by page as each is finalised; with a server, its ?stream=1 response, sent with chunked transfer encoding, is passed straight through.

What is incremental, however, is only the PDF output: the HTML is parsed and laid out for the whole document first. The first chunk therefore arrives late in the conversion, and peak memory is no lower than it is without a block. To have pages finalised while the HTML is still being read, combine this with streaming mode.

chunk_size: changes roughly how many bytes each call receives; the default is 64KiB and it applies to local conversion only. A smaller value delivers more finely, but the GVL has to be reacquired each time, which slows rendering down.

Sghtmltopdf.render(html, chunk_size: 8 * 1024) { |bytes| ... }

Thread#kill and timeouts work

Calling the block is an ordinary Ruby method call, so any pending interrupt is handled at that point. As long as you pass a block, Thread#kill, Timeout.timeout, and Rack::Timeout all take effect at chunk boundaries.

Timeout.timeout(10) do
  Sghtmltopdf.render(huge_html) { |bytes| io.write(bytes) }   # can be interrupted after 10 seconds
end

Without a block, render and render_to_file never return to Ruby during the conversion and therefore cannot be stopped part way. To put a limit on a long conversion, call them with a block.

Streaming the response from Rails

The render pdf: renderer assembles the whole PDF and returns it with send_data. To send pages as they are finalised, combine it with ActionController::Live.

class InvoicesController < ApplicationController
  include ActionController::Live

  def show
    response.headers["Content-Type"] = "application/pdf"
    html = render_to_string(template: "invoices/show", layout: "pdf")
    Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
  ensure
    response.stream.close
  end
end

If it fails after part of the file has been written, the client receives a broken PDF, because the headers have already gone out and the status can no longer be changed. This is the same property as ?stream=1 in server mode.

Uploading straight to S3

The gem has no S3 support of its own, to avoid the dependency and because the code is short anyway. A multipart upload requires every part except the last to be at least 5MB, so buffer before sending.

s3 = Aws::S3::Client.new
upload = s3.create_multipart_upload(bucket: bucket, key: key, content_type: "application/pdf")
parts, buffer = [], +"".b

flush = lambda do
  part = s3.upload_part(bucket: bucket, key: key, upload_id: upload.upload_id,
    part_number: parts.size + 1, body: buffer)
  parts << {part_number: parts.size + 1, etag: part.etag}
  buffer.clear
end

begin
  Sghtmltopdf.render(html, server_url: server_url) do |bytes|
    buffer << bytes
    flush.call if buffer.bytesize >= 5 * 1024 * 1024
  end
  flush.call unless buffer.empty?
  s3.complete_multipart_upload(bucket: bucket, key: key, upload_id: upload.upload_id,
    multipart_upload: {parts: parts})
rescue StandardError
  s3.abort_multipart_upload(bucket: bucket, key: key, upload_id: upload.upload_id)
  raise
end

For a small PDF, put_object(body: Sghtmltopdf.render(html)) is enough.

When you need to keep memory down

For HTML with tens of thousands of elements, the engine’s streaming mode cuts memory use sharply; measured at 60,000 elements it goes from 228MB to 28MB. See Memory and processing time.

Sghtmltopdf.render(html, streaming: true)

In exchange, anything that cannot be decided without the whole document, such as toc, counter(pages), or a <style> after <body>, becomes unavailable. See Streaming mode for the full list.

CSS property support table

Display and visibility

PropertySupportedNotes
display⚠️block, inline, inline-block, list-item, table, table-row, table-cell, table-caption, flex, and none only. grid is supported; see the Grid section. inline-flex, inline-grid, the internal table values such as table-row-group, and flow-root are not. <thead>, <tbody>, and <tfoot> stay block in the UA stylesheet, but row collection sees through them, so tables still work
visibility⚠️visible, hidden, and collapse. collapse is treated as hidden; table row and column sizes are not recomputed. Inherited
overflow⚠️Anything other than visible, that is hidden, scroll, and auto, clips alike; they are not told apart. There is no notion of a scrollbar. overflow-x and overflow-y are not supported
opacity⚠️<number> or <percentage>, clamped to 0 through 1. Implemented with a PDF transparency group and an ExtGState. Compositing is per element; blending such as mix-blend-mode is not supported
z-index⚠️auto or <integer>. It only affects position: relative elements, whereas the specification applies it to every positioned element. It controls the paint order among siblings of the same parent only; separate stacking contexts are not supported. Absolutely positioned elements always paint above the normal flow
box-sizing⚠️content-box and border-box. The non-standard padding-box is not supported

Box model

PropertySupportedNotes
width, height⚠️auto, <length>, <percentage>, and calc(). min-content, max-content, and fit-content are not supported. A percentage height is ignored, since the containing block’s height is treated as indefinite
min-width, min-height⚠️<length>, <percentage>, and calc(); 0 by default. Keywords such as auto and min-content are not supported. A percentage min-height is ignored
max-width, max-height⚠️none, <length>, <percentage>, and calc(); none by default. When min exceeds max, min wins, as the specification says. A percentage max-height is ignored
aspect-ratio⚠️auto | <ratio> | auto <ratio>. The normal direction is width first, height derived. Height first, width derived only happens in shrink-to-fit contexts: floats, inline-block, absolute positioning, and <img>. A normal-flow block with width: auto still stretches, as the specification says. When min-* or max-* clamping breaks the ratio, nothing is recomputed
marginThe one to four value shorthand. auto, for centring, and negative values are supported
margin-top, -right, -bottom, -leftMargin collapsing between adjacent siblings and between parent and child is supported
paddingThe one to four value shorthand
padding-top, -right, -bottom, -leftPercentages resolve against the containing block’s width, as the specification says

Borders, rounded corners, outlines, and shadows

PropertySupportedNotes
borderAccepts <width> || <style> || <color> in any order with any part omitted, and applies it to all four sides
border-top, -right, -bottom, -leftThe per-side shorthands; the value syntax is the same as border
border-width, border-style, border-colorThe one to four value shorthand
border-*-width⚠️<length> only. The thin, medium, and thick keywords are not supported
border-*-style⚠️none, hidden, solid, dashed, dotted, double, groove, ridge, inset, and outset. hidden is treated as none, including when resolving table border conflicts. groove, ridge, inset, and outset are drawn with two shades derived from border-color
border-*-colorcurrentcolor by default
border-radius⚠️One to four values, plus the elliptical syntax with /. Percentages are not supported; <length> only. When the four sides do not share the same width, style, and colour, the rounding is abandoned and four straight sides are drawn instead. Combining it with groove, ridge, inset, or outset falls back in the same way
border-top-left-radius and the other three corners⚠️<length>{1,2}, the horizontal and vertical radii. The same restrictions as border-radius
outline<width> || <style> || <color>. Drawn outside the border box, with no effect on layout
outline-width, outline-style, outline-color⚠️outline-style takes the same values as border-style. The UA-defined auto is not supported
outline-offsetAlways 0
box-shadow⚠️none | <shadow>#; several may be given separated by commas, the first being frontmost. inset parses but is not drawn. The blur is approximated with four concentric rectangles

Placement: positioning, float, and transform

PropertySupportedNotes
floatnone, left, and right. Shrink-to-fit for width: auto is supported
clearnone, left, right, and both
position⚠️static, relative, absolute, and fixed. sticky is not supported, and absolute and fixed carry the restrictions listed below
top, right, bottom, left⚠️With absolute and fixed, aligning to the bottom by giving bottom alone is not supported; everything resolves from top, to avoid a circular dependency on the height. With relative they act as offsets
inset, the shorthandUse top, right, bottom, and left individually
transform⚠️translate, translateX, translateY, scale, scaleX, scaleY, rotate, skew, skewX, skewY, and matrix. The 3D functions such as translate3d, rotate3d, and perspective() are not supported. It is implemented as a PDF CTM transform, so the transformed content has no bearing on where the pages break
transform-origin⚠️The same syntax as background-position: one or two values from keywords, lengths, and percentages. 50% 50% by default. A third value for the Z axis is not supported

Known restrictions on position: absolute and fixed:

  • An absolutely positioned element is taken out of the normal flow and laid down afterwards as an overlay on a page that has already been settled
  • Only a positioned ancestor that fits on a single page, or the page area itself, can act as the containing block
  • absolute inside an inline formatting context, that is in the middle of text, and absolute inside a table cell or a flex item, are not supported
  • Splitting an absolutely positioned element across pages is not supported; it is placed on one page as best it can be
  • In streaming mode, Mode::Streaming, absolute positioning is ignored

Fonts and text

PropertySupportedNotes
font-family⚠️A comma separated list. Of the generic families, serif, sans-serif, and monospace are resolved against the system fonts; cursive and fantasy are not. What sans-serif resolves to can be pinned with the CLI’s --gothic-font
font-size⚠️<length> only, in px, em, or rem. Keywords such as smaller, larger, and medium, and percentages, are not supported
font-weight⚠️normal, bold, and 100 through 900. Numbers are reduced to two states, 600 and above counting as bold. Without a bold face, a synthetic bold is drawn by filling and stroking
font-style⚠️normal, italic, and oblique; oblique is treated as italic and no angle may be given. Without italic glyphs, a synthetic italic is produced by shearing the text matrix
font, the shorthandUse the individual longhands
colorInherited. See Selectors, values, and at-rules for the colour notations that may be used
line-heightnormal, <number>, <length>, and <percentage>
text-align⚠️left, right, center, and justify. justify distributes the slack between words on every line but the last. start and end are not supported, since direction itself is not
text-indent⚠️<length> and <percentage>. hanging and each-line are not supported
text-transform⚠️none, uppercase, lowercase, and capitalize, which changes the first letter of each word only. full-width and full-size-kana are not supported
text-decoration, text-decoration-line⚠️none, underline, and line-through, which may be combined. overline and blink are not supported, nor are the text-decoration-color, -style, and -thickness parts of the shorthand. Propagation from ancestor to descendant is simplified by treating it as an inherited property
text-shadow⚠️none | <shadow>#, each being <offset-x> <offset-y> <blur>? <color>?. PDF has no blur filter, so the blur is approximated by drawing several times at reduced alpha. Inherited
text-overflow⚠️clip and ellipsis, in effect only when overflow is something other than visible. It applies to lines that overflow horizontally; overflow of the block as a whole is not handled. A <string> value is not supported
word-breaknormal, which allows a break only where CJK characters meet, plus break-all and keep-all. The deprecated break-word is not supported
overflow-wrap, word-wrap⚠️normal, break-word, and anywhere, the last treated as break-word. It adds no break opportunities; it splits between characters only for a word that would not fit even at the start of a line
hyphens⚠️none, manual, and auto. Words break only at a soft hyphen, U+00AD, and a hyphen is shown at the end of the line when they do. There is no dictionary, so auto behaves like manual and never hyphenates automatically
text-emphasis, -style, -color, -position⚠️dot, circle, double-circle, triangle, and sesame, each filled or open, plus <string>. The keyword marks are drawn as PDF paths and so do not depend on the font having the glyph, whereas a <string> is drawn as a glyph and disappears if the font lacks it. position takes over and under only; right and left are skipped. The line grows taller to make room for the marks. text-emphasis-skip, which skips punctuation, is not supported
letter-spacing⚠️normal and <length>. Percentages are not supported
word-spacing⚠️normal and <length>
white-space⚠️normal, nowrap, and pre. pre-wrap, pre-line, and break-spaces are not supported
vertical-alignbaseline, sub, super, text-top, text-bottom, top, middle, bottom, <length>, and <percentage>. In a table cell, top, middle, bottom, and baseline are the meaningful ones
quotes⚠️none, or repeated "open" "close" pairs. Used together with open-quote and close-quote in content. Inherited

Backgrounds

PropertySupportedNotes
background, the shorthand⚠️Accepts <color>, <image>, <repeat>, <attachment>, and <position>[ / <size>] in any order. Longhands left out are reset to their initial values, as the specification says. Note that including background-clip or -origin, that is a keyword such as padding-box, is a parse error and throws the whole declaration away
background-colorColours with an alpha channel are drawn transparently through an ExtGState
background-image⚠️none | url(...) only. Gradient functions such as linear-gradient(), and several comma separated backgrounds, are not supported. By default the image is tiled at its intrinsic size
background-positionOne or two values from the keywords left, center, right, top, and bottom, plus lengths and percentages. The three and four value syntax, such as right 10px bottom 20px, is not supported
background-sizecover, contain, or one or two values of <length-percentage> | auto
background-repeat⚠️repeat, repeat-x, repeat-y, and no-repeat. The CSS3 round and space, and the two value syntax, are not supported
background-attachment⚠️scroll and fixed; since there is no scrolling, fixed is treated as scroll
background-clip, background-origin, background-blend-modeNot implemented. Backgrounds are drawn against the border box

When border-radius and background-image are used together, the image is not clipped to the rounded corners; the rounding applies to the background colour fill only.

Tables

PropertySupportedNotes
table-layoutauto and fixed. With auto, column widths come from measuring the natural width of the cell contents; measuring the natural width of a nested table or a flex container is not supported and counts as 0
border-collapse⚠️separate and collapse. collapse merges the borders visually only, simplifying the conflict resolution of CSS 2.1 §17.6.2 to “the thicker one wins, and at equal width the style order decides”. Inherited
border-spacing<length>{1,2}. Treated as 0 under border-collapse: collapse. Inherited
caption-side⚠️top and bottom. The left and right values, which are for vertical writing, are not supported
empty-cellsshow and hide, meaningful only under border-collapse: separate. Inherited
vertical-align on a cellSee Fonts and text above

Column widths from the width attribute or CSS width on <colgroup> and <col>, rowspan and colspan, and repeating <thead> across pages are all supported. rowspan="0" counts as 1.

min-width and max-width on a cell feed into the column width algorithm. Under table-layout: auto they clamp the natural width of the column, so min-width is no longer guaranteed once the table has been scaled down proportionally to fit the paper. Under table-layout: fixed they clamp the width stated on the first row’s cells, and a cell with width: auto and only a min-width uses that value as the column width.

Lists

PropertySupportedNotes
list-style, the shorthandAccepts type, position, and image in any order with any part omitted
list-style-type⚠️disc, circle, square, decimal, decimal-leading-zero, lower-roman, upper-roman, lower-alpha (lower-latin), upper-alpha (upper-latin), and none. cjk-*, hiragana, katakana, and the like are not supported. Inherited
list-style-positionoutside and inside. Inherited
list-style-image⚠️none | url(...) parses but is never drawn; it always falls back to the text marker from list-style-type

Generated content and counters

PropertySupportedNotes
content⚠️For ::before, ::after, ::first-letter, and the @page margin boxes. String literals, attr(), counter() and counters(), and open-quote, close-quote, no-open-quote, and no-close-quote can be concatenated. Inserting an image with url() is not supported. As a simplification, ::before and ::after are not generated on an element that has block children
counter-resetnone, or repeated name [<integer>]
counter-incrementnone, or repeated name [<integer>], the value being 1 when omitted
counter-setNot implemented

Page numbers through counter(page) and counter(pages) can be used inside the @page margin boxes. counter(pages) is an error in streaming mode, where the total page count is never settled.

Page breaking (CSS Fragmentation)

PropertySupportedNotes
break-before, break-after⚠️auto, avoid (avoid-page and avoid-column mean the same), and always (page means the same). left, right, recto, and verso, which control spreads, and the multi-column values are not supported
break-inside⚠️auto and avoid (avoid-page and avoid-column mean the same)
page-break-before, page-break-after, page-break-insideAliases for the break-* properties above, for moving existing wkhtmltopdf and wicked_pdf work across
orphans, widowsAn integer of 1 or more; 2 by default
page, named pagesNamed pages themselves, such as @page intro, are not supported

The HTML attribute data-page-break="before|after|avoid" is available as syntactic sugar.

Flexbox

Laying out display: flex is delegated to taffy. A flex container is atomic as far as page breaking goes: like display: table, it is never split part way through.

PropertySupportedNotes
flex-directionrow, row-reverse, column, and column-reverse
flex-wrapnowrap, wrap, and wrap-reverse
justify-content⚠️flex-start (start), flex-end (end), center, space-between, space-around, and space-evenly. The safe and unsafe overflow keywords are not supported
align-itemsflex-start (start), flex-end (end), center, baseline, and stretch
align-contentflex-start (start), flex-end (end), center, stretch, space-between, space-around, and space-evenly
align-selfauto, flex-start (start), flex-end (end), center, baseline, and stretch
flex-grow, flex-shrinkA non-negative <number>; a negative value makes the declaration invalid and it is ignored
flex-basis⚠️auto, content, and <length-percentage>. content is treated as auto
flex, the shorthandnone, and <grow> [<shrink>] [<basis>]. The specification’s defaulting rules are reproduced: the basis of flex: 1 is 0%, and the grow and shrink of flex: <width> are 1
gap, row-gap, column-gap⚠️In effect on flex containers only; it does not act as the multi-column column-gap
orderThe taffy 0.12 series does not support it
place-content, place-items, place-self, the shorthandsNot implemented. Use the individual longhands
justify-items, justify-selfThey do not apply to flex items; see below

justify-items and justify-self having no effect in flex is by design

In CSS Box Alignment, justify-items and justify-self are properties for Grid and block layout and do not apply to flex items; the design is that placing an individual item along the main axis is expressed with justify-content and margin: auto. Browsers ignore them too. Even in taffy, to which layout is delegated, only the Grid algorithm looks at them; the flexbox algorithm never does.

Parsing them here would therefore change nothing on the page, so they are deliberately left unimplemented. To push a particular flex item to one side, use margin: auto, which is supported:

.item { margin-left: auto; }            /* the equivalent of justify-self: end, pushing to the end of the main axis */
.item { margin-left: auto; margin-right: auto; }  /* the equivalent of justify-self: center */

Grid

Laying out display: grid is delegated to taffy, as flexbox is. Unlike flexbox, a grid that does not fit on one page is broken between rows, the same approach as tables; no break is taken at a boundary crossed by an item that spans several rows.

PropertySupportedNotes
grid-template-columns, grid-template-rowsnone, <length>, <percentage>, fr, auto, min-content, max-content, minmax(), fit-content(), repeat(<integer>|auto-fill|auto-fit), and [name] for line names. calc() is not supported in a track size
grid-template-areasA matrix of strings. A mismatched column count or a non-rectangular area makes the value invalid and the whole declaration is ignored, as the specification says. A . is an unnamed cell
grid-auto-columns, grid-auto-rows<track-size>+
grid-auto-flowrow, column, and dense, which may be combined
grid-row-start, -end, grid-column-start, -endauto, <integer>, span <integer>, <custom-ident>, and span <custom-ident>
grid-row, grid-column, grid-areaThe shorthands, separated by /. grid-area: <name> names an area
justify-items, justify-selfMeaningful in Grid only; they do not apply to flex items, as noted above
align-items, align-self, justify-content, align-content, gapRanges
grid, grid-template, the shorthandsUse the individual longhands. These pack track and area definitions into one syntax, and that grammar is too involved to support
display: inline-gridNot supported, for the same reason as inline-flex
subgrid, masonryNot implemented

Replaced elements: images

PropertySupportedNotes
object-fitfill, contain, cover, none, and scale-down. Meaningful on <img> only
object-positionThe same syntax as background-position. 50% 50% by default

<img> supports inline placement and sizing through the width and height attributes or CSS. The formats supported are PNG, JPEG, and WebP. When CSS gives only one of width and height, the other is derived from the intrinsic aspect ratio (see aspect-ratio).

List of unsupported properties

The following are parse errors and the whole declaration is ignored. They are simply unimplemented, not all deliberately ruled out forever. See the rows in the tables above as well.

  • Shorthands: font, inset, place-content, place-items, place-self, and the colour and line-style parts of text-decoration
  • Logical properties: inline-size, block-size, margin-inline, padding-block, border-inline, and all the rest
  • Writing direction: direction, unicode-bidi, writing-mode, text-orientation, text-combine-upright
  • Text details: text-decoration-color, -style, -thickness, text-underline-offset, text-emphasis-skip, tab-size, ruby-*, text-justify, line-break
  • Font details: font-variant, font-stretch, font-feature-settings, font-variation-settings, font-kerning, font-display
  • Multi-column: columns, column-count, column-width, column-rule, column-span, column-fill
  • Visual effects: filter, backdrop-filter, mix-blend-mode, background-blend-mode, clip, clip-path, mask, isolation
  • 3D and animation: perspective, transform-style, backface-visibility, the individual translate, rotate, and scale properties, transition-*, animation-*, will-change
  • Border and background extensions: border-image-*, background-clip, background-origin, outline-offset
  • Overflow: overflow-x, overflow-y, resize, scroll-*, overscroll-behavior
  • UI and interaction: cursor, pointer-events, user-select, caret-color, accent-color, appearance
  • Other: all, content-visibility, counter-set, page for named pages, the aural media properties such as speak, and zoom

Selectors, values, and at-rules

Selectors

Matching is delegated to the selectors crate from Servo, so CSS3 selectors mostly work as they are.

SelectorsSupportedNotes
Type (p) and universal (*)
Class (.foo) and ID (#foo)
Attribute ([a], [a=v], [a^=v], [a$=v], [a*=v], [a~=v], [a|=v])The case-insensitive i flag works too
Descendant (whitespace) and child (>)
Adjacent sibling (+) and general sibling (~)
Selector lists (,)
Namespaces (ns|E)@namespace itself is not supported

Pseudo-classes

Pseudo-classesSupportedNotes
:root
:first-child, :last-child, :only-childIn streaming mode :last-child never matches; see below
:nth-child(), :nth-last-child()As above; :nth-last-child() never matches in streaming mode
:first-of-type, :last-of-type, :only-of-type, :nth-of-type(), :nth-last-of-type()As above
:emptyAs above
:not()
:is(), :where(), :has()A parse error; the whole selector is ignored
:hover, :active, :focus, :focus-within, :focus-visible, :target, :enabled, :disabled, :checked, :visited⚠️They parse but never match, since a static PDF has no interaction state for them to describe
:link, :any-linkMatches an <a> that has an href

A selector containing an unsupported pseudo-class, such as :is(), causes the whole rule to be dropped. Ones that parse, such as :hover, survive as rules but never match.

Pseudo-elements

Pseudo-elementsSupportedNotes
::before, ::after⚠️Generated text through content only. It is drawn with the computed style of the host element and has no box style of its own, so margin, padding, display, and the like do not apply. Nothing is generated on an element that has block children
::first-letter⚠️Only font-family, font-size, font-weight, font-style, color, text-decoration-line, and text-transform can be overridden; float and the box model properties are not supported
::first-lineA parse error
::marker, ::selection, ::placeholderA parse error

Values, units, and functions

Lengths

UnitSupported
px, em, rem
mm, cm, in, pt, pc, Q
%, on properties that take a percentage
A bare 0
ex, ch, vw, vh, vmin, vmax, lh

Absolute units are read with one inch as 96px, so 10mm is 37.795px. Physical dimensions can be written directly for print, which means @page { size: 210mm 297mm; margin: 15mm; } works as written.

As an exception, size in @page also accepts page size keywords such as a4 and letter, and landscape or portrait.

Angles, used only by transform

deg, rad, grad, and turn are supported, as is a bare 0 ✅.

Function

FunctionSupportedNotes
calc()⚠️+, -, *, /, and nested parentheses. The terms may be lengths, whether absolute units or em and rem, percentages, and numbers. It works on any property that takes a length or a percentage
min(), max(), clamp()
var()⚠️Custom properties (--foo) are resolved by text substitution before parsing. Fallbacks such as var(--x, 10px) and references between custom properties work. Unlike the specification, this does not follow the cascade or inheritance; it is a simple resolution in which the last declaration in the document wins
url()In background-image, list-style-image, the src of @font-face, and @import. Relative URLs are resolved against <base href> or the location of the input
attr()⚠️Only inside content
counter(), counters()Inside content. The style in the second argument comes from the list-style-type values
Gradients such as linear-gradient()
env(), image-set(), element()

Colours

NotationSupported
Named colours, the CSS colour keywords such as red
#rgb, #rgba, #rrggbb, #rrggbbaa
rgb(), rgba(), both comma separated and space separated
hsl(), hsla(), hwb()
lab(), lch(), oklab(), oklch()✅ (converted to sRGB for drawing)
currentcolor, transparent
color(), such as color(display-p3 ...)
color-mix(), and the relative colour syntax rgb(from ...)

Colours with alpha are drawn through a PDF ExtGState, for both fills and backgrounds.

At-rules

At-rulesSupportedNotes
@media⚠️Only the media type is evaluated. A screen block, and any negation other than not screen, is ignored entirely, while print, all, and a missing type are applied. Feature queries such as (min-width: ...) are skipped without being evaluated, so the contents apply as long as the type matches
@page⚠️size, taking a keyword, one or two <length> values, or landscape and portrait, plus the margin properties. The pseudo-classes :first, :left, and :right are supported on their own; named pages such as @page intro, :blank, and compound pseudo-classes such as :first:left are not
The margin boxes inside @page⚠️All sixteen of them, @top-left-corner, @top-left, @top-center, @top-right, and so on. Only text from content is drawn; decoration such as background colours and borders is not supported. Page numbers can be printed with counter(page) and counter(pages), though counter(pages) is unavailable in streaming mode
@font-face⚠️The font-family, src, unicode-range, font-weight, and font-style descriptors are supported, including local() and url() with format() or tech() inside src. Other descriptors such as font-display are ignored. Font files may be TTF or OTF only; WOFF and WOFF2 are not supported
@importNesting is supported to a depth of 16, beyond which the offending import alone is ignored, and cycles are detected. A media condition such as @import url(...) screen; is not evaluated; the file is always imported
@charsetThe input is assumed to be UTF-8
@supports, @keyframes, @namespace, @counter-style, @layer, @container, @propertyThe whole block is ignored

Restrictions specific to streaming mode

In Mode::Batch the whole DOM is available, so none of this applies. The following holds only in Mode::Streaming.

  • Selectors that look backwards never match: :last-child, :last-of-type, :nth-last-child(), :nth-last-of-type(), and :empty. They cannot be decided until the parent’s child list is complete
  • A <style> tag after <body> starts is an error and returns EngineError::UnsupportedInStreamingMode. Keep all <style> in <head>, so that the layout does not quietly fall apart
  • position: absolute and fixed are ignored
  • counter(pages), the total page count, is unavailable

Fonts

A PDF embeds its fonts in the document. Unlike a browser, it cannot fall back to whatever the reader happens to have installed, so which font is used is decided at conversion time*

The order in which fonts are chosen

  1. The CLI options --font, and --gothic-font, --serif-font, --mono-font
  2. @font-face in the CSS
  3. A system font search using the names in font-family
  4. A system search for a font that can draw the characters in the document

Only if none of these finds anything does a system sans-serif candidate become the default font.

The fourth step is the safety net for documents where the names give no clue, such as Japanese text with no font-family anywhere. If the document contains characters that none of the fonts gathered in steps 1 to 3 can draw, a system font that has them, for example Noto Sans CJK JP for Japanese, is found and added. The search runs per weight and style, so a document that mixes bold and regular text gets both faces added, which keeps regular text from being drawn with the bold face. If characters still cannot be drawn, a warning is printed before they turn into empty boxes.

警告: 文字 "ไ" を描画できるフォントがありません(豆腐になります)。
  --font/--gothic-font か @font-face でフォントを明示してください

On a server or in CI, name the fonts with --font. Without it the output depends on the fonts installed on that machine, and that is how the same HTML ends up looking different on a developer’s laptop and in production.

Generic family names

serif, sans-serif, and monospace are resolved against the system fonts. cursive and fantasy are not resolved.

For Japanese, leaving this to the environment changes the typeface of the body text, so the CLI lets you pin it explicitly.

sghtmltopdf invoice.html \
  --gothic-font NotoSansJP-Regular.ttf \   # font-family: sans-serif の実体
  --serif-font  NotoSerifJP-Regular.ttf \  # font-family: serif の実体
  --mono-font   NotoSansMono-Regular.ttf   # font-family: monospace の実体

For a TrueType Collection (.ttc), give the face index with --font-index, which applies to the --font option just before it.

@font-face

@font-face {
  font-family: "MyFont";
  src: url("fonts/MyFont-Regular.ttf");
  font-weight: 400;
  font-style: normal;
}

body { font-family: "MyFont", sans-serif; }

The descriptors that are honoured are font-family, src, unicode-range, font-weight, and font-style. Within src, both local() and url() with format() or tech() are accepted. Other descriptors, such as font-display, are ignored.

Only TTF and OTF files are supported. WOFF and WOFF2 are not, so pointing at a webfont as served on the web is an error. Use the original TTF or OTF.

There is nothing to wait for. The document.fonts.ready dance that headless Chrome needs is unnecessary here, and a PDF is never produced with fonts still unresolved.

unicode-range

Ranges

@font-face {
  font-family: "Mixed";
  src: url("fonts/Latin.ttf");
  unicode-range: U+0-24F, U+1E00-1EFF;
}
@font-face {
  font-family: "Mixed";
  src: url("fonts/JP.ttf");            Ranges
}
  • Ranges
  • Ranges
  • A font declared without unicode-range, including those from local(), --font, and the system search, covers the whole range
  • Ranges

Bold and italic

ValueBehaviour
font-weightnormal, bold, or 100 to 900. Numbers are reduced to two states, with 600 and above counting as bold. Without a bold face, synthetic bold is drawn by adding a stroke around the fill
font-stylenormal, italic, or oblique, with oblique treated as italic. Without italic shapes, synthetic italic is produced by shearing the text matrix

The font shorthand is not supported. Write the longhands, such as font-size and font-family, individually.

Subsetting

Only the glyphs that are actually used get embedded. Even if you name a complete Japanese font, the PDF only grows by the characters that appear in the document.

A note on streaming mode

In streaming mode the whole document is never held at once, so the system font searches in steps 3 and 4 above do not happen; a warning is printed and the default font is used. Naming the fonts with the --font options or @font-face gives you the fonts you intended even when streaming.

As an exception, when no font at all is given, one font that can draw CJK is loaded up front alongside the Latin default. That is why a Japanese document converted in streaming mode with no options does not come out as empty boxes. Scripts other than CJK still produce a warning.

Images

Images can be embedded with <img> and with the CSS background-image.

Supported formatsPNG, JPEG, WebP
What src may containA local relative or absolute path, an http(s) URL, or a data: URI

SVG and GIF are not supported.

<img>

<img src="logo.png" width="120">
<img src="https://example.com/chart.png" alt="Sales over time">
<img src="data:image/png;base64,iVBORw0…">
  • An <img> sits on the line as an inline replaced element. Give it display: block to put it on a line of its own
  • The width and height attributes and the CSS width and height are both honoured. With neither, the intrinsic size is used; with only one, the other is derived while keeping the aspect ratio
  • An image that cannot be fetched or decoded leaves just that element empty; it does not stop the document from being produced. Pass --load-media-error-handling abort to stop instead
  • However many times the same image is used, it is fetched, decoded, and embedded once

object-fit and object-position

These control how the image is fitted into the box you give it.

img.thumb {
  width: 120px;
  height: 80px;
  object-fit: cover;          /* fill | contain | cover | none | scale-down */
  object-position: 50% 50%;
}

Background images

.watermark {
  background-image: url("stamp.png");
  background-position: center;
  background-size: contain;
  background-repeat: no-repeat;
}

Only url() is accepted in background-image. Gradient functions such as linear-gradient() and comma separated multiple backgrounds are not supported. By default the image is tiled at its intrinsic size.

When border-radius is combined with a background image, the image is not clipped to the rounded corners; the radius applies only to the background colour.

Fetching remote images

This is off by default. Turn it on explicitly with --allow-remote-assets.

sghtmltopdf report.html --allow-remote-assets

Even when enabled, requests to destinations that are not globally reachable are always blocked. The rule is to allow only global unicast; the following are rejected.

KindRanges
Loopback127.0.0.0/8, ::1
Private10/8, 172.16/12, 192.168/16, fc00::/7
Link-local169.254/16 (including the cloud metadata endpoint 169.254.169.254), fe80::/10
CGNAT100.64.0.0/10 (cloud-internal load balancers and the like)
Other non-global0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4, multicast, documentation
IPv6 special-purposeTeredo 2001::/32, 2001:db8::/32, ORCHIDv2 2001:20::/28, 100::/64

IPv6 forms that embed an IPv4 address (IPv4-mapped ::ffff:a.b.c.d, IPv4-compatible ::a.b.c.d, NAT64 64:ff9b::/96, 6to4 2002::/16) are judged by the embedded IPv4 address. Letting them through would allow the IPv4 filter to be bypassed.

The check is applied to the result of name resolution, so DNS rebinding and redirect-based bypasses are prevented by the same mechanism.

Private

Ranges

sghtmltopdf untrusted.html --allow /var/app/assets

JPEG is embedded as is

JPEG images are not decoded. Only their dimensions are read, and the data goes into the PDF unchanged, as DCTDecode. Nothing is re-encoded, so quality is preserved and conversion is faster.

The trade-off is that --grayscale leaves JPEG and CMYK images in colour, since there is no decoder for them. If you need them in greyscale, convert the images before rendering.

PNG and WebP are fully decoded, and an alpha channel is carried through as transparency.

Loading no images at all

sghtmltopdf invoice.html --no-images

This stops both <img> and the CSS background-image from being loaded.

Page breaks

sghtmltopdf controls page breaks through the CSS Fragmentation properties.

Explicit page breaks

.chapter { break-before: page; }   /* break before this element */
.summary { break-after: page; }    /* break after this element */
.card    { break-inside: avoid; }  /* do not split this element across a page boundary */
PropertyAccepted values
break-before, break-afterauto, avoid (avoid-page and avoid-column mean the same), always (page means the same)
break-insideauto, avoid (as above)

The older page-break-before, page-break-after, and page-break-inside are accepted as aliases, so stylesheets written for wkhtmltopdf or wicked_pdf carry over unchanged.

The spread-related values left, right, recto, and verso, and the multi-column values, are not supported.

Without writing CSS

The same thing can be written as an HTML attribute.

<div data-page-break="before">…</div>
<div data-page-break="after">…</div>
<div data-page-break="avoid">…</div>

These count as low-priority hints, so a rule in a stylesheet can override any of them individually.

Keeping paragraphs from being split badly

p {
  orphans: 3;   /* leave at least three lines at the bottom of the page */
  widows: 3;    /* carry at least three lines to the top of the next page */
}

Both take an integer of 1 or more and default to 2. When the requirement cannot be met, the whole paragraph moves to the next page.

@page: paper and page margins

@page {
  size: A4;
  margin: 20mm;

  @top-center    { content: "Invoice"; }
  @bottom-center { content: counter(page) " / " counter(pages); }
}

@page :first {
  @bottom-center { content: "Cover"; }
}
  • size accepts a page size keyword such as A4 or Letter, one or two <length> values, and landscape or portrait
  • @page wins over the CLI page setup options, which act as initial values
  • Text can be placed in the margin boxes, the sixteen from @top-left-corner to @bottom-right-corner, through content. Decoration such as background colours and borders is not supported there
  • counter(page) is the current page number and counter(pages) is the total page count

Limits of @page

  • size and margin cannot vary from page to page. Such declarations qualified with :first, :left, or :right are parsed but not applied; those pseudo-classes only serve to vary the contents of the margin boxes
  • Named pages, @page intro together with page: intro, are not supported
  • The margin box geometry is simplified. The four corners are fixed to where the vertical and horizontal margins meet, and the other twelve divide each edge into three equal parts; a width is ignored
  • counter(pages) is unavailable in streaming mode, because the total page count is not known in a single pass

CLI options such as --header-center are mapped internally onto these @page margin boxes. If you write both, the CSS wins.

Breaking tables across pages

A table that does not fit on one page is split row by row and flows across pages.

  • The rows of <thead> are repeated at the top of every page after the first. Multi-row headers work too, and nothing is repeated for a table that fits on one page
  • <tfoot> moves to the end of the table regardless of where it appears in the source, but it is not repeated at the bottom of every page; it appears once, on the last page
  • The caption goes with the first fragment for caption-side: top and with the last for bottom
  • Every fragment carries the table’s own background and borders, and border-collapse: collapse still merges borders within each page

As a known limitation, a cell whose rowspan crosses a break belongs to the fragment of its starting row and its lower part runs off the page without being clipped. Row-level break-inside: avoid and the equivalent of orphans and widows are not supported either.

How Flexbox and Grid are handled

LayoutPage breaks
display: flexAtomic. It is never split; if it does not fit, the whole thing moves to the next page
display: gridSplit row band by row band, except at a boundary crossed by an item that spans rows
display: tableSplit row by row, as above

When laying out large cards, a flex container can jump to the next page in one piece. Use Grid or a table where you want it split.

Common patterns

Keep a heading from being stranded at the bottom of a page:

h2, h3 {
  break-after: avoid;   /* do not break right after a heading */
  break-inside: avoid;
}

Keep a single line item from splitting across two pages:

.line-item { break-inside: avoid; }

Always start a new page for each chapter:

section.chapter + section.chapter { break-before: page; }

Note

In streaming mode, selectors that look backwards, such as :last-child, never match. The adjacent sibling combinator + used above does work.

Migrating from wkhtmltopdf

Most option names are the same as in wkhtmltopdf. A few of them behave differently despite the shared name, so it is worth going through this page.

The wkhtmltopdf option mapping covers every option. If you use it from Rails through wicked_pdf, see Migrating from wicked_pdf.

Where the behaviour differs

wkhtmltopdfsghtmltopdf
CLI options versus @page in CSSThe CLI wins@page wins; the CLI provides initial values
Default margins10mm left and right1in (96px) on all four sides
Specifying a cover and a table of contentsPositional arguments, as in cover a.html toc--cover <PATH> and --toc
Merging several HTML filesSupportedNot supported; the input is a single file
Page variables in headers and footersInjected with JavaScriptPlaceholder substitution; JavaScript is not executed
FontsSystem fontsThe same, and --font can pin them
Unsupported optionsSometimes ignored silentlyStops with exit code 1 and explains why

@page wins

This is the difference people trip over most. If the CSS says @page { margin: 0 }, then --margin-top 20mm is ignored.

@page { size: A4; margin: 20mm; }   /* this one wins */

The CLI options act as initial values for whatever the CSS does not set. To drive it from the CLI, remove the corresponding property from @page in the HTML.

Default margins

wkhtmltopdf uses 10mm on the left and right, sghtmltopdf 1 inch (96px, or 25.4mm) on all four sides. Converting without saying anything therefore changes the margins, so state them to keep the existing look.

sghtmltopdf in.html -o out.pdf \
  --margin-top 10mm --margin-bottom 10mm --margin-left 10mm --margin-right 10mm

Cover page and table of contents

wkhtmltopdf cover cover.html toc page.html out.pdf          # wkhtmltopdf
sghtmltopdf --cover cover.html --toc page.html -o out.pdf   # sghtmltopdf

The table of contents is styled to match what wkhtmltopdf’s default TOC XSL produces. XSLT is not supported, so change it with CSS through --user-style-sheet.

Page numbers in headers and footers

wkhtmltopdf appended a query such as ?page=1&topage=5 to the --header-html URL and let JavaScript on that page insert the values. Since JavaScript is not executed here, sghtmltopdf substitutes placeholders as text instead.

sghtmltopdf report.html --footer-center "[page] / [topage]"

When the header is built in HTML, [page] is likewise substituted in the HTML as text.

Unsupported options are never ignored silently

Passing one exits with exit 1, explaining why and what to use instead. This is to keep you from moving over and never noticing that an option has no effect.

The main ones are these.

  • JavaScript: --enable-javascript, --javascript-delay, --run-script, --window-status, --debug-javascript, --stop-slow-scripts. Executing JavaScript is a deliberate non-goal
  • PDF outlines: --outline, --outline-depth, --dump-outline
  • XSLT: --xsl-style-sheet, --dump-default-toc-xsl. The table of contents uses a built-in template plus CSS instead
  • Re-encoding images: --image-quality, --image-dpi
  • Networking: --proxy, --cookie, --custom-header, --username and --password, --ssl-*
  • WebKit specific: --disable-smart-shrinking, --viewport-size, --lowquality, --print-media-type. Print media is always assumed
  • PDF forms: --enable-forms

Adjustments you may need in the HTML and CSS

Ranges

  1. !important is not supported; a declaration carrying it is ignored
  2. inherit, initial, and unset are not supported
  3. Viewport units such as vw and vh, and ex, ch, and lh, are not supported. Write lengths with px, em, rem, or the absolute units mm, cm, in, pt, pc, and Q

See Selectors, values, and at-rules for details.

Checking that the move worked

Start by converting with --log-level info, the default, and check that no warnings appear. Unsupported options stop the run with exit code 1, so once a conversion succeeds every option has been understood. After that, compare the output by eye, looking at the margins, where the pages break, and the fonts.

wkhtmltopdf option mapping

This covers every option listed by --extended-help in wkhtmltopdf 0.12.6, that is the official manual at https://wkhtmltopdf.org/usage/wkhtmltopdf.txt. The sections and their order follow that manual.

SymbolMeaning
✅ SupportedAvailable under the same name with the same meaning as in wkhtmltopdf
❌ Not supportedDeliberately not implemented. Passing one prints what to use instead and exits with code 1; it is never ignored silently

The behavioural differences people trip over when moving across are collected in Migrating from wkhtmltopdf.

Differences in the command line itself

wkhtmltopdf specifies the cover and the table of contents as positional arguments.

wkhtmltopdf cover cover.html toc page.html out.pdf          # wkhtmltopdf
sghtmltopdf --cover cover.html --toc page.html -o out.pdf   # sghtmltopdf

sghtmltopdf takes a single HTML file as input and uses the --cover <path> and --toc options instead. Merging several HTML files, which wkhtmltopdf does by listing them as positional arguments, is not supported.

For the same reason --exclude-from-outline and --include-in-outline, which exclude individual input pages from the outline, are out of scope.

Global Options

OptionRationaleNotes
--collate, --no-collate❌ Not supportedCollation when printing; it means nothing when producing a PDF
--cookie-jar <path>❌ Not supportedAuthenticated fetching is out of scope
--copies <number>❌ Not supportedAs above; it is a printing concern
-d, --dpi <dpi>✅ Supported96 by default
-H, --extended-help❌ Not supportedFolded into --help
-g, --grayscale✅ Supported
-h, --help✅ SupportedGenerated by clap
--htmldoc, --manpage, --readme, --license❌ Not supportedThe documentation lives in docs/ and the README
--image-dpi <integer>❌ Not supportedImages are never resampled
--image-quality <integer>❌ Not supportedThere is no JPEG decoder or encoder; the data is embedded as it is
--log-level <level>✅ Supportednone, error, warn, info
-l, --lowquality❌ Not supportedThere is no counterpart to WebKit’s rasterisation quality setting
-B, --margin-bottom <unitreal>✅ Supported
-L, --margin-left <unitreal>✅ SupportedThe default differs; see below
-R, --margin-right <unitreal>✅ SupportedThe default differs; see below
-T, --margin-top <unitreal>✅ Supported
-O, --orientation <orientation>✅ SupportedPortrait, Landscape
--page-height <unitreal>✅ Supported
-s, --page-size <Size>✅ SupportedA4, A3, A5, Letter, Legal
--page-width <unitreal>✅ Supported
--no-pdf-compression✅ SupportedFlate compression is currently always on
-q, --quiet✅ SupportedThe same as --log-level none
--read-args-from-stdin❌ Not supportedStandard input is used for the HTML, so the two would clash
--title <text>✅ SupportedThe PDF Info dictionary; without it the <title> is used
--use-xserver❌ Not supportedThere is no dependency on an X server
-V, --version✅ Supported

Default margins: wkhtmltopdf uses 10mm left and right and leaves top and bottom unset, while sghtmltopdf currently defaults to 96px, that is 1in or 25.4mm, on all four sides. The default is not being changed, so that existing output stays as it is. State --margin-* explicitly when you move across.

Outline Options

PDF outlines, that is bookmarks, are not supported at all, so nothing in this section is. A table of contents inside the document can be built with --toc.

OptionRationaleNotes
--outline, --no-outline❌ Not supportedPDF bookmarks are not implemented
--outline-depth <level>❌ Not supportedAs above
--dump-outline <file>❌ Not supportedAs above
--dump-default-toc-xsl❌ Not supportedXSLT is not used

Page Options

OptionRationaleNotes
--allow <path>✅ SupportedThe directories local reads are confined to; it matters most in server mode
--background, --no-background✅ Supported
--bypass-proxy-for <value>❌ Not supportedProxies are not supported
--cache-dir <path>❌ Not supportedThere is no fetch cache; one can be added if it turns out to be needed
--checkbox-checked-svg, --checkbox-svg, --radiobutton-checked-svg, --radiobutton-svg❌ Not supportedSVG cannot be drawn. Form controls are drawn with built-in shapes instead
--cookie <name> <value>❌ Not supportedAuthenticated fetching is out of scope
--custom-header <name> <value>, --custom-header-propagation❌ Not supportedAs above
--debug-javascript, --no-debug-javascript❌ Not supportedJavaScript is not supported
--default-header✅ SupportedA default header with the document name and page number; a shortcut for the simple header options
--encoding <encoding>✅ SupportedThe order is BOM, --encoding, <meta charset>, then UTF-8
--disable-external-links, --enable-external-links✅ SupportedLink annotations
--disable-forms, --enable-forms❌ Not supportedFillable PDF forms (AcroForm) are never produced
--images, --no-images✅ Supported
--disable-internal-links, --enable-internal-links✅ Supported
-n, --disable-javascript, --enable-javascript❌ Not supportedExecuting JavaScript is a deliberate non-goal
--javascript-delay <msec>❌ Not supportedAs above
--keep-relative-links, --resolve-relative-links✅ SupportedHow link annotation URLs are resolved, implemented together with <base href>
--load-error-handling <handler>✅ Supportedabort and ignore. There is no skip, since there is only one input
--load-media-error-handling <handler>✅ SupportedFailures to fetch images, fonts, and stylesheets
--disable-local-file-access, --enable-local-file-access✅ SupportedArranged alongside the existing --allow-remote-assets
--minimum-font-size <int>✅ Supported
--exclude-from-outline, --include-in-outline❌ Not supportedMeaningless when there is only one input
--page-offset <offset>✅ SupportedWhere page numbering starts
--password, --username❌ Not supportedHTTP authentication is out of scope
--disable-plugins, --enable-plugins❌ Not supportedThere is no plugin mechanism
--post <name> <value>, --post-file <name> <path>❌ Not supportedPOSTing when the input is a URL is out of scope
--print-media-type, --no-print-media-type❌ Not supportedPrint media is always assumed
-p, --proxy <proxy>, --proxy-hostname-lookup❌ Not supportedProxies are not supported
--run-script <js>❌ Not supportedJavaScript is not supported
--disable-smart-shrinking, --enable-smart-shrinking❌ Not supportedA shrinking strategy specific to WebKit
--ssl-crt-path, --ssl-key-password, --ssl-key-path❌ Not supportedClient certificates are out of scope
--stop-slow-scripts, --no-stop-slow-scripts❌ Not supportedJavaScript is not supported
--disable-toc-back-links, --enable-toc-back-links✅ SupportedLinks back from a heading to the table of contents
--user-style-sheet <path>✅ SupportedCSS in the user origin
--viewport-size <size>❌ Not supportedThere is no notion of a viewport
--window-status <status>❌ Not supportedJavaScript is not supported
--zoom <float>✅ Supported

Everything in this section is supported. Since JavaScript is not executed, the page variables that wkhtmltopdf passed as a query such as ?page=1&topage=5 on the --header-html URL, to be inserted by JavaScript, are handled by substituting placeholders as text.

OptionRationaleNotes
--header-left, --header-center, --header-right✅ SupportedMapped onto the @page margin boxes
--footer-left, --footer-center, --footer-right✅ SupportedAs above
--header-html <url>, --footer-html <url>✅ SupportedA separate HTML file is rendered into the margin area
--header-line, --no-header-line✅ Supported
--footer-line, --no-footer-line✅ Supported
--header-spacing <real>, --footer-spacing <real>✅ Supportedmm
--header-font-name, --header-font-size✅ Supported
--footer-font-name, --footer-font-size✅ Supported
--replace <name> <value>✅ SupportedReplaces [name] in the header or footer, mapping directly onto the placeholder scheme here

Of wkhtmltopdf’s built-in placeholders, the ones that work are [page], [frompage], [topage], [date], [time], and [title] with [doctitle]. [section] and [subsection], which name the nearest heading, and [webpage], [sitepage], and [sitepages], which are for multiple inputs, are not supported. Any other name can be defined with --replace.

TOC Options

The HTML structure and default styling of the generated table of contents follow what wkhtmltopdf’s default TOC XSL produces: nested <ul> for the levels, and <li><div><a>heading</a><span>page number</span></div></li> for each entry.

OptionRationaleNotes
--toc-header-text <text>✅ Supported“Table of Contents” by default; the text of the <h1>
--toc-level-indentation <width>✅ Supported1em by default; ul { padding-left }
--toc-text-size-shrink <real>✅ Supported0.8 by default; ul ul { font-size: 80% }
--disable-dotted-lines✅ SupportedOmits the border-bottom: dashed on the div
--disable-toc-links✅ SupportedOmits the <a href> links from the entries to the headings
--xsl-style-sheet <file>❌ Not supportedXSLT is not supported; change the appearance with --user-style-sheet

Options unique to sghtmltopdf

OptionWhat it does
--font <path>, --font-index <N>Names the fonts explicitly; optional and repeatable. Without it the system fonts are used
--gothic-font, --mono-font, --serif-font, each with an -indexNames the actual font behind the generic families sans-serif, monospace, and serif
--allow-remote-assetsAllows fetching absolute http(s) URLs
--streamingProcesses the input in streaming mode
--base-url <url|dir>The base for resolving relative references, for example when reading from standard input
--author, --subject, --keywordsThe PDF Info dictionary; wkhtmltopdf only had --title
--cover <path>, --tocThe cover and the table of contents; wkhtmltopdf used positional arguments
The server subcommandHTTP server mode

Migrating from wicked_pdf

A mapping and a set of notes for moving a Rails application from wicked_pdf, and wkhtmltopdf behind it, to the sghtmltopdf gem.

For the options themselves, see the wkhtmltopdf option mapping and Migrating from wkhtmltopdf. This page covers only what differs as seen from Rails and Ruby.

The smallest possible change

# Gemfile
- gem "wicked_pdf"
- gem "wkhtmltopdf-binary"
+ gem "sghtmltopdf"

Your controllers should keep working as they are.

def show
  respond_to do |format|
    format.pdf { render pdf: "invoice", template: "invoices/show", layout: "pdf" }
  end
end

No external process is started any more, so wicked_pdf’s exe_path, which pointed at the wkhtmltopdf binary, is no longer needed.

Where the configuration goes

# config/initializers/sghtmltopdf.rb
Sghtmltopdf.configure do |c|
  c.page_size   = "A4"
  c.margin_top  = "20mm"
  c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
end

This is the counterpart of wicked_pdf’s WickedPdf.config = {...}. The global configuration is merged first and the arguments to render win.

How the option names map

wicked_pdf takes nested hashes such as margin: {top: 10}, while sghtmltopdf takes a flat hash whose keys are the CLI flag names, with _ standing for -, so page_size: is --page-size. The option definitions live in one place in Rust, and the Ruby side keeps no whitelist of its own.

wicked_pdfsghtmltopdfNotes
pdf: "name"The sameThe file name; .pdf is appended for you
template:, layout:, locals:, formats:The samePassed straight through to Rails view rendering
disposition:, filename:, status:The samedisposition is inline by default
show_as_html: trueThe sameFor debugging; returns the HTML instead of a PDF
page_size: "A4"page_size: "A4"
page_height:, page_width:The samePass a string with a unit, such as "210mm"
orientation: "Landscape"The same
margin: {top: 10, bottom: 10}margin_top: "10mm", margin_bottom: "10mm"A bare number means mm in wicked_pdf, so state the unit
dpi:, zoom:The same
grayscale: trueThe same
background: falseno_background: true
encoding: "UTF-8"The same
title:The samePDF metadata
user_style_sheet:The sameAn array of paths is accepted too
no_pdf_compression: trueThe same
cover: "shared/cover"cover: <file path>A path to an HTML file, not a template name; see below
toc: {}toc: trueAdjust the appearance with toc_header_text: and friends
header: {left:, center:, right:}header_left:, header_center:, header_right:
header: {html: {template: "..."}}header_html: <file path>As above
header: {line: true, spacing: 5, font_name:, font_size:}header_line: true, header_spacing: 5, header_font_name:, header_font_size:The footer works the same way
outline: {}PDF outlines are not supported
disable_javascript, javascript_delay, window_statusJavaScript is never executed; that is a deliberate non-goal
print_media_typePrint media is always assumed
lowquality, viewport_size, disable_smart_shrinkingSpecific to WebKit
exe_path, wkhtmltopdfNo external process is used
extraA raw command line string is not accepted; use the individual keys

Passing a key that is not supported raises Sghtmltopdf::UsageError at render time, with the reason; nothing is ignored silently.

wicked_pdf takes a Rails template name and renders it internally, whereas --cover, --header-html, and --footer-html take file paths, so that they join the same path as the CLI. To use a Rails template, render it yourself and write it to a temporary file.

def show
  header = Tempfile.new(["header", ".html"])
  header.write(render_to_string(template: "invoices/header", layout: false))
  header.flush

  render pdf: "invoice", template: "invoices/show", header_html: header.path
ensure
  header&.close!
end

View helpers

wicked_pdfsghtmltopdf
wicked_pdf_stylesheet_link_tagsghtmltopdf_stylesheet_link_tag
wicked_pdf_image_tagsghtmltopdf_image_tag
wicked_pdf_asset_pathsghtmltopdf_asset_path, which returns nil if nothing is found
wicked_pdf_javascript_include_tag— (unnecessary, since JavaScript is not executed)
wicked_pdf_asset_base64— (unnecessary, since local files can be read directly)

A plain stylesheet_link_tag or image_tag works as it is, as long as the assets have been precompiled into public/. Rendering does not go through an HTTP server, so a URL such as /assets/… is resolved as a local file against --base-url, which defaults to Rails.root/public under Rails.

Where the assets are not in public/ yet, as in development, use sghtmltopdf_stylesheet_link_tag, which inlines the CSS into a <style> element.

<%= sghtmltopdf_stylesheet_link_tag "pdf" %>

Differences in the defaults

  • Margins: wkhtmltopdf uses 10mm left and right, sghtmltopdf 1in (96px) on all four sides. State margin_* explicitly to keep the same look
  • CLI options versus @page in CSS: in wkhtmltopdf the CLI wins, in sghtmltopdf @page wins and the options are initial values
  • Ranges
  • Remote fetching: retrieving http(s) assets is off by default. Turn it on with allow_remote_assets: true if you need it

Fonts

wkhtmltopdf depends on the system font configuration, whereas sghtmltopdf lets you name fonts with gothic_font, serif_font, and mono_font. For Japanese output it is safer to state them, so that you are not at the mercy of what a container happens to have.

Sghtmltopdf.configure do |c|
  c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
end

Moving the work to another process, which wicked_pdf could not do

wicked_pdf starts a wkhtmltopdf process for every request, while the sghtmltopdf gem converts inside your application process; the GVL is released during the heavy work, so other Puma threads keep running. If you still do not want to spend the application’s CPU on it, server_url delegates to a separate sghtmltopdf server process.

Sghtmltopdf.configure { |c| c.server_url = "http://pdf.internal:8080" }

Only one URL is accepted, on the assumption that load balancing happens in front of it, in nginx or a Kubernetes Service. If the server cannot be reached you get Sghtmltopdf::ServerError; it does not fall back to converting locally.

In server mode, options that take a local path, such as base_url, allow, and the font settings, cannot be given per request; they are only settable when the server starts. The defaults supplied by the Railtie are dropped automatically, but anything you set explicitly with configure produces a 400 and a UsageError, so move those to the server’s startup options.

Not there yet

  • Merging PDFs and PDF outlines are not supported

Streaming the output, on the other hand, is something wicked_pdf never had; pass a block to render. Combined with ActionController::Live, pages can be written to the response as they are finalised. See Ruby / Rails

What is not supported

For individual CSS properties see the property support table, and for wkhtmltopdf’s options see the option mapping.

JavaScript is not executed

<script> elements are skipped.

This rules out the following.

  • Building the DOM with JavaScript and then producing a PDF, such as rendering a single-page application as it stands
  • Including a chart drawn on the client with something like Chart.js
  • Injecting page numbers or headers with JavaScript; use the placeholders instead

Ranges

The input is a single HTML file

Several HTML files cannot be listed and merged into one PDF; there is no equivalent of wkhtmltopdf’s positional arguments. A cover page is given with --cover and a table of contents with --toc.

Merging, splitting, and extracting pages from existing PDFs are out of scope as well.

PDF features

FeatureWhen
Outlines, that is bookmarksNot supported. A table of contents inside the document can be built with --toc
Fillable forms (AcroForm)Not supported. Elements such as <input> are drawn for appearance only
Encryption, passwords, and digital signaturesNot supported
Conformance with standards such as PDF/A and PDF/XNot supported
Tagged PDF, for accessibilityNot supported
Attachments, and annotations other than linksNot supported; only link annotations are

Links written as <a href> become PDF annotations, both for external URLs and for #id targets within the document.

Image and font formats

  • Images may be PNG, JPEG, or WebP only; SVG and GIF are not supported
  • Fonts may be TTF or OTF only; WOFF and WOFF2 are not supported
  • Even with --grayscale, JPEG and CMYK images stay in colour

The main CSS limitations

Taken feature by feature, the following are not supported.

  • Vertical writing with writing-mode and text-orientation, the logical properties such as margin-inline, and right-to-left text through direction
  • Multi-column layout, columns and column-count
  • Gradients such as linear-gradient(), and multiple backgrounds
  • Animations, transitions, and filter, since the output is static
  • position: sticky, display: inline-flex and inline-grid, and subgrid
  • :is(), :where(), :has(), ::first-line, and ::marker

Limitations specific to streaming mode

With --streaming, the total page count, meaning counter(pages) and [topage], and the table of contents, --toc, become unavailable. See Streaming mode for the details.

Looking ahead

Embedding a JavaScript engine remains something to consider if the need arises. Items above that are not deliberate non-goals, such as PDF outlines, may well be supported later.