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
!importantand 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:
| Elements | sghtmltopdf | sghtmltopdf (streaming) | wkhtmltopdf | Headless Chrome |
|---|---|---|---|---|
| 5,000 | 26MB / 0.11s | 9MB / 0.10s | 44MB / 0.49s | 543MB / 1.32s |
| 20,000 | 80MB / 0.46s | 14MB / 0.34s | 86MB / 2.60s | 943MB / 7.45s |
| 60,000 | 230MB / 1.99s | 25MB / 1.31s | 199MB / 42.02s | 1,525MB / 105.77s |
A statement made of a single large table:
| Rows | sghtmltopdf | sghtmltopdf (streaming) | wkhtmltopdf | Headless Chrome |
|---|---|---|---|---|
| 5,000 | 49MB / 0.56s | 48MB / 0.60s | 62MB / 1.55s | 1,372MB / 5.12s |
| 20,000 | 173MB / 2.44s | 173MB / 2.36s | 163MB / 14.60s | 6,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.
| Usage | What you install |
|---|---|
| Run the HTTP server as a long-lived process | The Docker image ghcr.io/waka/sghtmltopdf |
| Use it from Ruby or Rails | The sghtmltopdf gem |
| Convert from your own command line | The 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 says | Font that is used |
|---|---|
No font-family | BIZ UDPMincho (serif) |
font-family: sans-serif | BIZ UDPGothic (sans-serif) |
font-family: serif | BIZ UDPMincho |
font-family: monospace | No monospace font is bundled, so this falls back to BIZ UDPMincho |
font-weight: bold | The 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
| Path | What it does |
|---|---|
/usr/local/bin/sghtmltopdf | The executable |
/usr/share/fonts/truetype/sghtmltopdf/*.ttf | Bundled fonts |
/usr/share/doc/sghtmltopdf/fonts/OFL-*.txt | Font licences |
/work | The 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
| Option | Default | Description |
|---|---|---|
<INPUT.HTML> | (required) | The input HTML; - means standard input |
-o, --output <PATH> | The input name with .pdf | Where to write. - means standard output. It cannot be omitted when reading from standard input |
--base-url <URL|DIR> | The directory of the input HTML | The base for resolving relative references. An http(s) URL becomes the default <base href>; a <base href> in the HTML wins |
--encoding <NAME> | Detected | The character encoding of the input. The order is BOM, then --encoding, then <meta charset>, then UTF-8 |
--streaming | Off | Process 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
| Option | Default | Description |
|---|---|---|
-s, --page-size <SIZE> | A4 | A3, 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> | Portrait | Landscape swaps width and height at the end |
-T, --margin-top <LENGTH> | 1in (96px) | Top margin |
-B, --margin-bottom <LENGTH> | 1in | Bottom margin |
-L, --margin-left <LENGTH> | 1in | Left margin |
-R, --margin-right <LENGTH> | 1in | Right margin |
Lengths take the units mm, cm, in, pt, and px. A bare number means mm, as in wkhtmltopdf.
How this relates to
@pagein CSSThese 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
| Option | Description |
|---|---|
--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--fontor@font-face. See Fonts for details.
PDF output and metadata
| Option | Default | Description |
|---|---|---|
--title <TEXT> | The <title> of the HTML | The /Title of the PDF |
--author, --subject, --keywords <TEXT> | The matching entries of the Info dictionary; specific to sghtmltopdf | |
-d, --dpi <DPI> | 96 | How many dpi a CSS px stands for. At 72, 1px equals 1pt |
--zoom <FACTOR> | 1.0 | A scale factor, multiplied into the --dpi factor |
-g, --grayscale | Off | Convert fills and strokes to greyscale, by sRGB relative luminance |
--no-pdf-compression | Off | Turn 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
| Option | Description |
|---|---|
--no-images | Do not load <img> or the CSS background-image |
--no-background | Do 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-links | Do not create PDF annotations for external http(s) links |
--disable-internal-links | Do not create PDF annotations for internal #id links |
--keep-relative-links | Write 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
| Option | Description |
|---|---|
--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-size | The header font; the footer has the same pair |
--header-line, --footer-line | Draw a rule |
--header-spacing, --footer-spacing <MM> | The gap from the body text; the margin grows by that much |
--default-header | A 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-faceinside 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.
| Option | Default | Description |
|---|---|---|
--cover <PATH> | The HTML to use as the cover. It is not counted in the page numbers and gets no header or footer | |
--toc | Off | Insert a table of contents before the body; not available in streaming mode |
--toc-header-text <TEXT> | Table of Contents | The <h1> of the table of contents |
--toc-level-indentation <WIDTH> | 1em | The indent added per level |
--toc-text-size-shrink <REAL> | 0.8 | The 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> | 0 | Shift 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
| Option | CLI default | Server default |
|---|---|---|
--enable-local-file-access, --disable-local-file-access | Allowed | Denied |
--allow <PATH> | No restriction | No restriction |
--allow-remote-assets | Denied | Denied |
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.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Usage error: an unknown option, a malformed value, or an option that is not supported |
| 2 | Input or resource error: a missing file, a font that cannot be read, or a failed fetch under abort |
| 3 | Rendering error, such as breaking one of the streaming mode restrictions |
| 4 | Time 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 available | Why |
|---|---|
counter(pages), and [topage] in headers and footers | The total page count is not known in a single pass |
--toc | Same reason; a table of contents needs the page numbers of the body |
A background colour or border on <html> or <body> itself | That 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.
| Limitation | Behaviour |
|---|---|
Finding a system font from a font-family name | The 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 character | Not 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 :empty | Never 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, andwidows
Memory and processing time
These are measured figures for converting the same HTML in both modes. Each cell shows peak memory and processing time.
| Elements | HTML size | Normal mode | --streaming |
|---|---|---|---|
| 1,000 | 46KB | 11MB / 0.02s | 8MB / 0.02s |
| 5,000 | 233KB | 26MB / 0.08s | 10MB / 0.10s |
| 20,000 | 946KB | 81MB / 0.35s | 15MB / 0.38s |
| 60,000 | 2.8MB | 228MB / 1.05s | 28MB / 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
| Option | Default | Description |
|---|---|---|
--listen <ADDR:PORT> | 127.0.0.1:8080 | The address to listen on. :0 picks a free port automatically |
--workers <N> | CPU cores | How many worker threads convert at the same time |
--max-queue <N> | Workers × 4 | How many requests may wait to be accepted; beyond that, 503 |
--max-body-size <BYTES> | 4194304 (4 MiB) | The maximum request body size |
--timeout <SECS> | 30 | Seconds granted to one request: the queue wait plus the conversion (exceeding it returns 504) |
--font <PATH> and the other font options | Cannot be changed per request | |
--enable-local-file-access, --allow <PATH>, --allow-remote-assets | All denied | Only 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 path | Description |
|---|---|
POST /pdf | Convert the HTML in the body to PDF and return it as application/pdf |
POST /pdf?stream=1 | The same, but sent with chunked transfer encoding as each page becomes final |
GET /healthz | ok |
GET /version | sghtmltopdf <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.
| Query | The 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 / =false | The 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
| Code | When |
|---|---|
| 200 | Success, with Content-Type: application/pdf |
| 400 | An unknown or forbidden query key, a malformed value, or an empty body |
| 404 | An unknown path |
| 405 | A method that is not allowed |
| 413 | The body is larger than --max-body-size |
| 500 | Rendering failed |
| 503 | The 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=1it 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:
| Factor | Unit cost | What bounds it |
|---|---|---|
| Number of elements | 472 B to 1210 B per node | Node limit (500,000) |
| Amount of text | About 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
--timeoutcovers 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--timeoutcovers 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=1the 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 | |
|---|---|
| Platforms | x86_64-linux, aarch64-linux, x86_64-linux-musl, aarch64-linux-musl, arm64-darwin |
| Ruby | 3.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 withencoding: "Shift_JIS" render_to_filewrites 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 value | Meaning |
|---|---|
page_size: "A4" | An option that takes a value |
grayscale: true | A flag that takes no value |
grayscale: false or nil | The 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.
| Key | Default | Description |
|---|---|---|
server_url | None | When set, conversion is delegated to the HTTP server mode |
server_open_timeout | 5 | Connection timeout, in seconds |
server_read_timeout | 120 | Response timeout, in seconds |
chunk_size | 65536 | Roughly 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.
| Class | When it is raised |
|---|---|
Sghtmltopdf::UsageError | A bad option: an unknown key, a malformed value, or an option that is not supported |
Sghtmltopdf::InputError | Reading the input or writing the output failed |
Sghtmltopdf::RenderError | Rendering failed |
Sghtmltopdf::TimeoutError | The run exceeded its time limit and was aborted |
Sghtmltopdf::InternalError | An unexpected failure inside the engine (a bug) |
Sghtmltopdf::ServerError | The 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.
| Group | Key |
|---|---|
| Passed to view rendering | template partial inline file plain html body layout locals formats variants handlers prefixes object collection assigns action |
| Used to build the response | disposition (inline by default), filename, status |
| Debugging | show_as_html, which returns the HTML instead of a PDF |
| Everything else | Conversion 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.
| Key | Default | Meaning |
|---|---|---|
base_url | Rails.root/public | The 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 toInputError, 500 toRenderError, and anything else toServerError
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
- Box model
- Borders, rounded corners, outlines, and shadows
- Placement: positioning, float, and transform
- Fonts and text
- Backgrounds
- Tables
- Lists
- Generated content and counters
- Page breaking (CSS Fragmentation)
- Flexbox
- Grid
- Replaced elements: images
- List of unsupported properties
Display and visibility
| Property | Supported | Notes |
|---|---|---|
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
| Property | Supported | Notes |
|---|---|---|
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 |
margin | ✅ | The one to four value shorthand. auto, for centring, and negative values are supported |
margin-top, -right, -bottom, -left | ✅ | Margin collapsing between adjacent siblings and between parent and child is supported |
padding | ✅ | The one to four value shorthand |
padding-top, -right, -bottom, -left | ✅ | Percentages resolve against the containing block’s width, as the specification says |
Borders, rounded corners, outlines, and shadows
| Property | Supported | Notes |
|---|---|---|
border | ✅ | Accepts <width> || <style> || <color> in any order with any part omitted, and applies it to all four sides |
border-top, -right, -bottom, -left | ✅ | The per-side shorthands; the value syntax is the same as border |
border-width, border-style, border-color | ✅ | The 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-*-color | ✅ | currentcolor 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-offset | ❌ | Always 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
| Property | Supported | Notes |
|---|---|---|
float | ✅ | none, left, and right. Shrink-to-fit for width: auto is supported |
clear | ✅ | none, 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 shorthand | ❌ | Use 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
absoluteinside an inline formatting context, that is in the middle of text, andabsoluteinside 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
| Property | Supported | Notes |
|---|---|---|
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 shorthand | ❌ | Use the individual longhands |
color | ✅ | Inherited. See Selectors, values, and at-rules for the colour notations that may be used |
line-height | ✅ | normal, <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-break | ✅ | normal, 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-align | ✅ | baseline, 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
| Property | Supported | Notes |
|---|---|---|
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-color | ✅ | Colours 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-position | ✅ | One 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-size | ✅ | cover, 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-mode | ❌ | Not 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
| Property | Supported | Notes |
|---|---|---|
table-layout | ✅ | auto 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-cells | ✅ | show and hide, meaningful only under border-collapse: separate. Inherited |
vertical-align on a cell | ✅ | See 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
| Property | Supported | Notes |
|---|---|---|
list-style, the shorthand | ✅ | Accepts 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-position | ✅ | outside 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
| Property | Supported | Notes |
|---|---|---|
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-reset | ✅ | none, or repeated name [<integer>] |
counter-increment | ✅ | none, or repeated name [<integer>], the value being 1 when omitted |
counter-set | ❌ | Not 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)
| Property | Supported | Notes |
|---|---|---|
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-inside | ✅ | Aliases for the break-* properties above, for moving existing wkhtmltopdf and wicked_pdf work across |
orphans, widows | ✅ | An integer of 1 or more; 2 by default |
page, named pages | ❌ | Named 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.
| Property | Supported | Notes |
|---|---|---|
flex-direction | ✅ | row, row-reverse, column, and column-reverse |
flex-wrap | ✅ | nowrap, 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-items | ✅ | flex-start (start), flex-end (end), center, baseline, and stretch |
align-content | ✅ | flex-start (start), flex-end (end), center, stretch, space-between, space-around, and space-evenly |
align-self | ✅ | auto, flex-start (start), flex-end (end), center, baseline, and stretch |
flex-grow, flex-shrink | ✅ | A 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 shorthand | ✅ | none, 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 |
order | ❌ | The taffy 0.12 series does not support it |
place-content, place-items, place-self, the shorthands | ❌ | Not implemented. Use the individual longhands |
justify-items, justify-self | — | They 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.
| Property | Supported | Notes |
|---|---|---|
grid-template-columns, grid-template-rows | ✅ | none, <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-areas | ✅ | A 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-flow | ✅ | row, column, and dense, which may be combined |
grid-row-start, -end, grid-column-start, -end | ✅ | auto, <integer>, span <integer>, <custom-ident>, and span <custom-ident> |
grid-row, grid-column, grid-area | ✅ | The shorthands, separated by /. grid-area: <name> names an area |
justify-items, justify-self | ✅ | Meaningful in Grid only; they do not apply to flex items, as noted above |
align-items, align-self, justify-content, align-content, gap | ✅ | Ranges |
grid, grid-template, the shorthands | ❌ | Use the individual longhands. These pack track and area definitions into one syntax, and that grammar is too involved to support |
display: inline-grid | ❌ | Not supported, for the same reason as inline-flex |
| subgrid, masonry | ❌ | Not implemented |
Replaced elements: images
| Property | Supported | Notes |
|---|---|---|
object-fit | ✅ | fill, contain, cover, none, and scale-down. Meaningful on <img> only |
object-position | ✅ | The 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 oftext-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 individualtranslate,rotate, andscaleproperties,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,pagefor named pages, the aural media properties such asspeak, andzoom
Selectors, values, and at-rules
- Selectors
- Pseudo-classes
- Pseudo-elements
- Values, units, and functions
- Colours
- At-rules
- Restrictions specific to streaming mode
Selectors
Matching is delegated to the selectors crate from Servo, so CSS3 selectors mostly work as they are.
| Selectors | Supported | Notes |
|---|---|---|
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-classes | Supported | Notes |
|---|---|---|
:root | ✅ | |
:first-child, :last-child, :only-child | ✅ | In 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 |
:empty | ✅ | As 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-link | ✅ | Matches 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-elements | Supported | Notes |
|---|---|---|
::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-line | ❌ | A parse error |
::marker, ::selection, ::placeholder | ❌ | A parse error |
Values, units, and functions
Lengths
| Unit | Supported |
|---|---|
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
| Function | Supported | Notes |
|---|---|---|
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
| Notation | Supported |
|---|---|
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-rules | Supported | Notes |
|---|---|---|
@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 |
@import | ✅ | Nesting 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 |
@charset | ❌ | The input is assumed to be UTF-8 |
@supports, @keyframes, @namespace, @counter-style, @layer, @container, @property | ❌ | The 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 returnsEngineError::UnsupportedInStreamingMode. Keep all<style>in<head>, so that the layout does not quietly fall apart position: absoluteandfixedare ignoredcounter(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
- The CLI options
--font, and--gothic-font,--serif-font,--mono-font @font-facein the CSS- A system font search using the names in
font-family - 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 fromlocal(),--font, and the system search, covers the whole range - Ranges
Bold and italic
| Value | Behaviour |
|---|---|
font-weight | normal, 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-style | normal, 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 formats | PNG, JPEG, WebP |
|---|---|
What src may contain | A 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 itdisplay: blockto put it on a line of its own - The
widthandheightattributes and the CSSwidthandheightare 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 abortto 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.
| Kind | Ranges |
|---|---|
| Loopback | 127.0.0.0/8, ::1 |
| Private | 10/8, 172.16/12, 192.168/16, fc00::/7 |
| Link-local | 169.254/16 (including the cloud metadata endpoint 169.254.169.254), fe80::/10 |
| CGNAT | 100.64.0.0/10 (cloud-internal load balancers and the like) |
| Other non-global | 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4, multicast, documentation |
| IPv6 special-purpose | Teredo 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 */
| Property | Accepted values |
|---|---|
break-before, break-after | auto, avoid (avoid-page and avoid-column mean the same), always (page means the same) |
break-inside | auto, 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"; }
}
sizeaccepts a page size keyword such asA4orLetter, one or two<length>values, andlandscapeorportrait@pagewins over the CLI page setup options, which act as initial values- Text can be placed in the margin boxes, the sixteen from
@top-left-cornerto@bottom-right-corner, throughcontent. Decoration such as background colours and borders is not supported there counter(page)is the current page number andcounter(pages)is the total page count
Limits of @page
sizeandmargincannot vary from page to page. Such declarations qualified with:first,:left, or:rightare parsed but not applied; those pseudo-classes only serve to vary the contents of the margin boxes- Named pages,
@page introtogether withpage: 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
widthis 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
captiongoes with the first fragment forcaption-side: topand with the last forbottom - Every fragment carries the table’s own background and borders, and
border-collapse: collapsestill 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
| Layout | Page breaks |
|---|---|
display: flex | Atomic. It is never split; if it does not fit, the whole thing moves to the next page |
display: grid | Split row band by row band, except at a boundary crossed by an item that spans rows |
display: table | Split 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
| wkhtmltopdf | sghtmltopdf | |
|---|---|---|
CLI options versus @page in CSS | The CLI wins | @page wins; the CLI provides initial values |
| Default margins | 10mm left and right | 1in (96px) on all four sides |
| Specifying a cover and a table of contents | Positional arguments, as in cover a.html toc | --cover <PATH> and --toc |
| Merging several HTML files | Supported | Not supported; the input is a single file |
| Page variables in headers and footers | Injected with JavaScript | Placeholder substitution; JavaScript is not executed |
| Fonts | System fonts | The same, and --font can pin them |
| Unsupported options | Sometimes ignored silently | Stops 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,--usernameand--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
!importantis not supported; a declaration carrying it is ignoredinherit,initial, andunsetare not supported- Viewport units such as
vwandvh, andex,ch, andlh, are not supported. Write lengths withpx,em,rem, or the absolute unitsmm,cm,in,pt,pc, andQ
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.
| Symbol | Meaning |
|---|---|
| ✅ Supported | Available under the same name with the same meaning as in wkhtmltopdf |
| ❌ Not supported | Deliberately 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
| Option | Rationale | Notes |
|---|---|---|
--collate, --no-collate | ❌ Not supported | Collation when printing; it means nothing when producing a PDF |
--cookie-jar <path> | ❌ Not supported | Authenticated fetching is out of scope |
--copies <number> | ❌ Not supported | As above; it is a printing concern |
-d, --dpi <dpi> | ✅ Supported | 96 by default |
-H, --extended-help | ❌ Not supported | Folded into --help |
-g, --grayscale | ✅ Supported | |
-h, --help | ✅ Supported | Generated by clap |
--htmldoc, --manpage, --readme, --license | ❌ Not supported | The documentation lives in docs/ and the README |
--image-dpi <integer> | ❌ Not supported | Images are never resampled |
--image-quality <integer> | ❌ Not supported | There is no JPEG decoder or encoder; the data is embedded as it is |
--log-level <level> | ✅ Supported | none, error, warn, info |
-l, --lowquality | ❌ Not supported | There is no counterpart to WebKit’s rasterisation quality setting |
-B, --margin-bottom <unitreal> | ✅ Supported | |
-L, --margin-left <unitreal> | ✅ Supported | The default differs; see below |
-R, --margin-right <unitreal> | ✅ Supported | The default differs; see below |
-T, --margin-top <unitreal> | ✅ Supported | |
-O, --orientation <orientation> | ✅ Supported | Portrait, Landscape |
--page-height <unitreal> | ✅ Supported | |
-s, --page-size <Size> | ✅ Supported | A4, A3, A5, Letter, Legal |
--page-width <unitreal> | ✅ Supported | |
--no-pdf-compression | ✅ Supported | Flate compression is currently always on |
-q, --quiet | ✅ Supported | The same as --log-level none |
--read-args-from-stdin | ❌ Not supported | Standard input is used for the HTML, so the two would clash |
--title <text> | ✅ Supported | The PDF Info dictionary; without it the <title> is used |
--use-xserver | ❌ Not supported | There 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.
| Option | Rationale | Notes |
|---|---|---|
--outline, --no-outline | ❌ Not supported | PDF bookmarks are not implemented |
--outline-depth <level> | ❌ Not supported | As above |
--dump-outline <file> | ❌ Not supported | As above |
--dump-default-toc-xsl | ❌ Not supported | XSLT is not used |
Page Options
| Option | Rationale | Notes |
|---|---|---|
--allow <path> | ✅ Supported | The directories local reads are confined to; it matters most in server mode |
--background, --no-background | ✅ Supported | |
--bypass-proxy-for <value> | ❌ Not supported | Proxies are not supported |
--cache-dir <path> | ❌ Not supported | There 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 supported | SVG cannot be drawn. Form controls are drawn with built-in shapes instead |
--cookie <name> <value> | ❌ Not supported | Authenticated fetching is out of scope |
--custom-header <name> <value>, --custom-header-propagation | ❌ Not supported | As above |
--debug-javascript, --no-debug-javascript | ❌ Not supported | JavaScript is not supported |
--default-header | ✅ Supported | A default header with the document name and page number; a shortcut for the simple header options |
--encoding <encoding> | ✅ Supported | The order is BOM, --encoding, <meta charset>, then UTF-8 |
--disable-external-links, --enable-external-links | ✅ Supported | Link annotations |
--disable-forms, --enable-forms | ❌ Not supported | Fillable PDF forms (AcroForm) are never produced |
--images, --no-images | ✅ Supported | |
--disable-internal-links, --enable-internal-links | ✅ Supported | |
-n, --disable-javascript, --enable-javascript | ❌ Not supported | Executing JavaScript is a deliberate non-goal |
--javascript-delay <msec> | ❌ Not supported | As above |
--keep-relative-links, --resolve-relative-links | ✅ Supported | How link annotation URLs are resolved, implemented together with <base href> |
--load-error-handling <handler> | ✅ Supported | abort and ignore. There is no skip, since there is only one input |
--load-media-error-handling <handler> | ✅ Supported | Failures to fetch images, fonts, and stylesheets |
--disable-local-file-access, --enable-local-file-access | ✅ Supported | Arranged alongside the existing --allow-remote-assets |
--minimum-font-size <int> | ✅ Supported | |
--exclude-from-outline, --include-in-outline | ❌ Not supported | Meaningless when there is only one input |
--page-offset <offset> | ✅ Supported | Where page numbering starts |
--password, --username | ❌ Not supported | HTTP authentication is out of scope |
--disable-plugins, --enable-plugins | ❌ Not supported | There is no plugin mechanism |
--post <name> <value>, --post-file <name> <path> | ❌ Not supported | POSTing when the input is a URL is out of scope |
--print-media-type, --no-print-media-type | ❌ Not supported | Print media is always assumed |
-p, --proxy <proxy>, --proxy-hostname-lookup | ❌ Not supported | Proxies are not supported |
--run-script <js> | ❌ Not supported | JavaScript is not supported |
--disable-smart-shrinking, --enable-smart-shrinking | ❌ Not supported | A shrinking strategy specific to WebKit |
--ssl-crt-path, --ssl-key-password, --ssl-key-path | ❌ Not supported | Client certificates are out of scope |
--stop-slow-scripts, --no-stop-slow-scripts | ❌ Not supported | JavaScript is not supported |
--disable-toc-back-links, --enable-toc-back-links | ✅ Supported | Links back from a heading to the table of contents |
--user-style-sheet <path> | ✅ Supported | CSS in the user origin |
--viewport-size <size> | ❌ Not supported | There is no notion of a viewport |
--window-status <status> | ❌ Not supported | JavaScript is not supported |
--zoom <float> | ✅ Supported |
Headers And Footer Options
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.
| Option | Rationale | Notes |
|---|---|---|
--header-left, --header-center, --header-right | ✅ Supported | Mapped onto the @page margin boxes |
--footer-left, --footer-center, --footer-right | ✅ Supported | As above |
--header-html <url>, --footer-html <url> | ✅ Supported | A 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> | ✅ Supported | mm |
--header-font-name, --header-font-size | ✅ Supported | |
--footer-font-name, --footer-font-size | ✅ Supported | |
--replace <name> <value> | ✅ Supported | Replaces [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.
| Option | Rationale | Notes |
|---|---|---|
--toc-header-text <text> | ✅ Supported | “Table of Contents” by default; the text of the <h1> |
--toc-level-indentation <width> | ✅ Supported | 1em by default; ul { padding-left } |
--toc-text-size-shrink <real> | ✅ Supported | 0.8 by default; ul ul { font-size: 80% } |
--disable-dotted-lines | ✅ Supported | Omits the border-bottom: dashed on the div |
--disable-toc-links | ✅ Supported | Omits the <a href> links from the entries to the headings |
--xsl-style-sheet <file> | ❌ Not supported | XSLT is not supported; change the appearance with --user-style-sheet |
Options unique to sghtmltopdf
| Option | What 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 -index | Names the actual font behind the generic families sans-serif, monospace, and serif |
--allow-remote-assets | Allows fetching absolute http(s) URLs |
--streaming | Processes the input in streaming mode |
--base-url <url|dir> | The base for resolving relative references, for example when reading from standard input |
--author, --subject, --keywords | The PDF Info dictionary; wkhtmltopdf only had --title |
--cover <path>, --toc | The cover and the table of contents; wkhtmltopdf used positional arguments |
The server subcommand | HTTP 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_pdf | sghtmltopdf | Notes |
|---|---|---|
pdf: "name" | The same | The file name; .pdf is appended for you |
template:, layout:, locals:, formats: | The same | Passed straight through to Rails view rendering |
disposition:, filename:, status: | The same | disposition is inline by default |
show_as_html: true | The same | For debugging; returns the HTML instead of a PDF |
page_size: "A4" | page_size: "A4" | |
page_height:, page_width: | The same | Pass 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: true | The same | |
background: false | no_background: true | |
encoding: "UTF-8" | The same | |
title: | The same | PDF metadata |
user_style_sheet: | The same | An array of paths is accepted too |
no_pdf_compression: true | The same | |
cover: "shared/cover" | cover: <file path> | A path to an HTML file, not a template name; see below |
toc: {} | toc: true | Adjust 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_status | — | JavaScript is never executed; that is a deliberate non-goal |
print_media_type | — | Print media is always assumed |
lowquality, viewport_size, disable_smart_shrinking | — | Specific to WebKit |
exe_path, wkhtmltopdf | — | No external process is used |
extra | — | A 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.
HTML for the cover, header, and footer
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_pdf | sghtmltopdf |
|---|---|
wicked_pdf_stylesheet_link_tag | sghtmltopdf_stylesheet_link_tag |
wicked_pdf_image_tag | sghtmltopdf_image_tag |
wicked_pdf_asset_path | sghtmltopdf_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
@pagein CSS: in wkhtmltopdf the CLI wins, in sghtmltopdf@pagewins and the options are initial values - Ranges
- Remote fetching: retrieving
http(s)assets is off by default. Turn it on withallow_remote_assets: trueif 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
| Feature | When |
|---|---|
| Outlines, that is bookmarks | Not 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 signatures | Not supported |
| Conformance with standards such as PDF/A and PDF/X | Not supported |
| Tagged PDF, for accessibility | Not supported |
| Attachments, and annotations other than links | Not 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-modeandtext-orientation, the logical properties such asmargin-inline, and right-to-left text throughdirection - Multi-column layout,
columnsandcolumn-count - Gradients such as
linear-gradient(), and multiple backgrounds - Animations, transitions, and
filter, since the output is static position: sticky,display: inline-flexandinline-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.