sghtmltopdf README

The project's README from github.com/waka/sghtmltopdf. As one markdown file: README.md.

sghtmltopdf

Gem Version Crates.io Version docs.rs CI

An HTML-to-PDF renderer like wkhtmltopdf written in Rust that does not depend on Chromium, WebKit, or Gecko.

Documentation (English)

What is this

sghtmltopdf turns HTML into PDF without starting a browser process. It is aimed at documents that flow top to bottom with explicit breaks invoices, receipts, reports rather than at rendering arbitrary web pages.

A Japanese receipt rendered by sghtmltopdf

That receipt is examples/receipt.html plus examples/main.css, rendered with one command and no browser:

sghtmltopdf examples/receipt.html -o receipt.pdf

The result is committed as examples/receipt.pdf. The stylesheet is ordinary CSS Flexbox, tables, custom properties, counter-increment for the row numbers, border-radius, and CJK text shaped from a system font. The 1 / 1 and the document number along the bottom edge are margin boxes: @page { @bottom-center { content: counter(page) " / " counter(pages) } }.

Compared with the common headless-Chrome approach:

What is actually rendering this

The layout engine is written for this project. HTML parsing is html5ever, CSS parsing is cssparser, and selector matching is the selectors crate all from Servo, and all of them battle-tested (Firefox ships the same selector engine through Stylo). Those give a DOM and computed styles. Everything after that box generation, line breaking, table layout, pagination, PDF writing—is code in this repository.

That is the whole reason for the trade-off in both directions. CSS coverage is a subset rather than everything a browser supports, because every property is implemented here rather than inherited. And in return, pagination is what the engine is built around instead of something bolted onto a screen-oriented layout: repeating table headers across page breaks, @page margin boxes, and streaming a page out the moment its layout is final are all things a browser cannot be asked to do.

Text shaping and font handling go through the usual Rust stack. No browser engine is embedded, and this is not a fork of anything.

Future-goals: executing JavaScript, pixel-perfect parity with browsers, and full CSS coverage. See what is not supported.

Where the name comes from

I was able to ship PDF output at all because wkhtmltopdf, and wicked_pdf that made it usable from Rails, were out there as open source. The HTML templates I already knew how to write came back to me as a PDF, and that genuinely amazed me. Those two libraries had my deep respect.

wkhtmltopdf was archived in 2023.

I want the next programmer who is handed "make this print to PDF" to feel what I felt back then. This project is an attempt to carry that on, which is why the name starts with sg — Second Generation.

Usage

The command line, an HTTP server, a Ruby gem and a Rust crate all share the same engine and the same options.

# CLI
sghtmltopdf invoice.html -o invoice.pdf --page-size A4 --margin-top 20mm

# HTTP server
sghtmltopdf server --listen 127.0.0.1:8080
curl --data-binary @invoice.html 'http://127.0.0.1:8080/pdf?page-size=A4' -o invoice.pdf

# HTTP server, from the official image (Japanese fonts included, no arguments needed)
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf
# Ruby (gem "sghtmltopdf")
pdf = Sghtmltopdf.render("<h1>Invoice</h1>", page_size: "A4")

Local references (<img src>, external CSS, @font-face) stay inside the base directory (--base-url, defaulting to the input HTML's own directory); a ../ that would escape it is an error, so untrusted HTML cannot read arbitrary files. Pass --allow-path <DIR> to widen the boundary, or --disable-local-file-access to close it entirely (the HTTP server does the latter by default, and never lets a request loosen it).

The CLI flags are the ones you already know: most of them keep the same name and meaning as wkhtmltopdf (--page-size, --margin-top, --orientation, --header-html, --toc, …). Flags that will not be implemented exit 1 with the reason and an alternative instead of being silently ignored — see the option table for the full list, and migrating from wkhtmltopdf for the cases where the same name behaves differently (CSS @page wins over the CLI, the default margin is 1in, cover and TOC are options rather than positional arguments).

Option names are the CLI long options without -- and with - replaced by _, so --page-size A4 becomes page_size: "A4". The engine runs inside the same process through a native extension (magnus + rb-sys) no subprocess, no temporary files and releases the GVL while rendering, so other Puma threads keep running.

Rails

Add the gem and the Railtie wires everything up. Nothing is loaded when Rails is absent, so plain Ruby and Sinatra are unaffected.

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

A :pdf renderer is registered, in the spirit of wicked_pdf — the same keys, so an existing controller often needs no change at all:

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

View-rendering keys (template, layout, locals, …) go to render_to_string, response keys (filename, disposition, status) go to send_data, show_as_html: true returns the HTML instead of a PDF, and everything else is passed to the converter. The converter keys are flat CLI flag names, so wicked_pdf's nested margin: {top: 10} becomes margin_top: "10mm" (with the unit spelled out); migrating from wicked_pdf maps every key one by one.

PDF rendering does not go through the HTTP server, so /assets/… URLs are resolved as local files: the Railtie defaults base_url to Rails.root/public and restricts local reads to public/ and the asset pipeline load paths via allow_path. That is enough for a precompiled production app; in development the digested /assets/… path names no file on disk, so these helpers look the asset up in the pipeline instead — the CSS expanded into a <style>, the image referenced by the path the engine can read:

<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
<%= sghtmltopdf_image_tag "logo.png" %>

sghtmltopdf_stylesheet_link_tag does not copy the CSS verbatim: it points every url() at a file the engine can read and splices in every @import. The asset pipeline rewrites url() through asset_path while precompiling, which turns a @font-face source into a digested /assets/… path, or into an absolute URL once asset_host is set — neither can be fetched while rendering, and a @font-face that fails to load falls back to the engine default rather than to the next font-family.

To send pages as soon as their layout is final, pass a block and use ActionController::Live this also makes Rack::Timeout and Thread#kill effective at chunk boundaries:

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 the gem cannot run where your app runs (Windows) or you would rather not spend the app's CPU on rendering, set server_url and the same calls are delegated to a separate sghtmltopdf server process.

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

Everything else the full option reference, the HTTP API, CSS support tables, and migration guides from wkhtmltopdf and wicked_pdf lives in the documentation site (Ruby / Rails, Rust).

The Docker image (linux/amd64 and linux/arm64) bundles BIZ UDPGothic and BIZ UDPMincho, so Japanese documents render without supplying a font, and the same HTML always produces the same PDF regardless of the host's fonts. ENTRYPOINT is the binary itself: no arguments starts the server, arguments run the CLI.

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

To keep the server around next to your app, Compose is the shortest route the image needs no command, and inside the container it listens on 0.0.0.0:8080.

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 the health check uses --version; hit GET /healthz from the outside (a load balancer, say) if you want the server itself checked. Point the Ruby side at it with Sghtmltopdf.configure { |c| c.server_url = "http://pdf:8080" }, and add --font and a mounted volume to the service's command if you need fonts other than the bundled ones.

Rust

The engine is published on crates.io as sghtmltopdf, the same crate that builds the binary (API docs).

cargo add sghtmltopdf

Converter takes the same options as the command, so everything in the option reference works unchanged:

use sghtmltopdf::{with_render_stack, Converter};

let converter = Converter::from_args(["--page-size", "A4", "--margin-top", "20mm"])?;
let html = std::fs::File::open("invoice.html")?;
let pdf = with_render_stack(|| converter.render_to_vec(html))?;
std::fs::write("invoice.pdf", pdf)?;

Options are validated once by from_args, and the same Converter can render any number of documents. Errors come back as ConvertError — Usage, Input, Render or Timeout, the same classes as the command's exit codes. Rendering recurses as deep as the document, so run it on a thread with a large enough stack; with_render_stack does that for you.

To stream pages out as soon as their layout is final, pass your own Sink to render instead of collecting into memory. The engine calls write in order, once or more per completed page, and finish exactly once at the end:

use std::io::{self, Write};
use sghtmltopdf::{with_render_stack, Converter, Sink};

struct WriterSink<W: Write>(W);

impl<W: Write> Sink for WriterSink<W> {
    type Output = W;
    type Error = io::Error;

    fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
        self.0.write_all(bytes)
    }

    fn finish(mut self) -> io::Result<W> {
        self.0.flush()?;
        Ok(self.0)
    }
}

let converter = Converter::from_args(["--page-size", "A4"])?;
let html = std::fs::File::open("invoice.html")?;
let socket = std::net::TcpStream::connect("127.0.0.1:9000")?;
with_render_stack(|| converter.render(html, WriterSink(socket)))?;

FileSink (writes to a temporary file and renames it on success), StdoutSink, MemorySink and BufferedSink (hands over fixed-size parts, for S3 multipart uploads) are provided. The lower-level Engine takes EngineOptions and HTML fed in chunks (feed, then finish); prefer Converter unless you need to assemble the options yourself.

Feature Default
cli yes The sghtmltopdf binary and Converter (clap)
server yes sghtmltopdf server (tiny_http)
svg yes SVG images embedded as vectors (svg2pdf)
svg-text no <text> inside SVG images

For library use, default-features = false, features = ["cli", "svg"] drops the HTTP server and keeps everything else.

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). 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 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 the same process as your app (Puma worker, etc.) as FFI, releasing the GVL while rendering so other threads keep going. Delegation to an HTTP server only happens when server_url is configured, and that server can be a sibling process on the same host or a remote one.

Guide for developer

Layout

core/           # Rust engine + CLI + HTTP server (no Ruby dependency)
bindings/ruby/  # Ruby binding (magnus + rb-sys) and the Rails integration
docs/           # Documentation site (mdbook, ja + en)

bindings/ruby is excluded from the root Cargo workspace, so a plain cargo build works without a Ruby toolchain.

Rust

cargo build --release                                   # binary at target/release/sghtmltopdf
cargo test --workspace
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings

Feature flags: cli (argument parsing) and server (HTTP server) are on by default. Build the library alone with --no-default-features.

Ruby gem

Requires a Rust toolchain and libclang (for rb-sys).

cd bindings/ruby
bundle install
bundle exec rake            # compile, then run specs
bundle exec rake compile

Some specs compare gem output with the CLI byte for byte, and are skipped unless target/release/sghtmltopdf exists run cargo build --release first.

Documentation site

cargo install mdbook mdbook-mermaid mdbook-i18n-helpers

cd docs
mdbook serve                                        # Japanese (the source language)
MDBOOK_BOOK__LANGUAGE=en mdbook serve -d book/en    # English

Markdown under docs/src is written in Japanese; the English version is a gettext catalog at docs/po/en.po. After editing the Japanese text, refresh the catalog (requires the gettext package):

MDBOOK_OUTPUT='{"xgettext": {}}' mdbook build -d po
msgmerge --update po/en.po po/messages.pot

Entries whose source changed are marked fuzzy review and update those, then remove the marker. Pushing to main builds both languages and deploys to GitHub Pages.

License

MIT License (LICENSE).

sghtmltopdf (waka/sghtmltopdf)

Searching Database…

这是什么东西?sghtmltopdf 是一个基于 Rust 的 HTML 转 PDF 渲染器,是 wkhtmltopdf 和 wicked_pdf 的“第二代”继任者,不依赖 Chromium、WebKit 或 Gecko。它面向的是自上而下排版、带明确分页的结构化文档,比如发票和报表,而不是任意网页。
它是命令行工具、库,还是别的什么?它同时是这一切:一个 CLI 可执行程序、一个 HTTP 服务器、一个 Rust crate,以及一个 Ruby gem,四者共用完全相同的渲染引擎和选项集。
我该怎么开始使用它?可以直接用 CLI 可执行程序处理 HTML 文件;也可以把 gem 加到 Rails 应用的 Gemfile 里,得到一个 wicked_pdf 风格的 ':pdf' 渲染器;或者拉取 Docker 镜像——它的入口默认启动服务器,带参数时则运行 CLI。
构建或运行它需要什么?从源码构建核心引擎只需要 Rust 工具链;Ruby gem 额外需要 libclang(rb-sys 需要)来编译其原生扩展。全程不需要浏览器引擎,也不需要无头 Chromium。
它支持浏览器那样的完整 CSS 吗?不支持。CSS 覆盖范围是刻意精简的一个子集,因为每个属性都是从零实现的,而不是从浏览器引擎继承的。JavaScript 执行和与浏览器像素级一致的效果被列为未来目标,而不是现有功能。
它必须有 Ruby 或 Rails 应用才能用,还是可以独立使用?它完全可以独立使用,作为 CLI 可执行程序或 HTTP 服务器运行,完全不需要 Ruby;gem 和 Rails Railtie 只是为 Ruby/Rails 应用提供的可选便利封装,没有 Rails 时它们的任何部分都不会加载。
不受信任的 HTML 能读取我服务器上的任意文件吗?不能。本地引用(图片、外部 CSS、字体)默认被限制在一个基准目录内,任何会逃出该目录的路径(比如 '../')都会被当作错误拒绝;你可以用 --allow-path 放宽限制,也可以用 --disable-local-file-access 完全关闭本地文件访问。
实际负责排版工作的渲染引擎是什么?HTML 解析、CSS 解析和选择器匹配来自 Servo 项目久经考验的 html5ever、cssparser 和 selectors crate(选择器引擎与 Firefox 内置的是同一个)。在此之后的每一步——盒模型生成、断行、表格布局、分页和 PDF 写出——都是专门为这个项目编写的自定义代码。
它为什么比无头 Chrome 的方案更轻量?它不需要启动浏览器进程,而且 HTML 是分块读取的,每完成一页就立即写出,所以内存占用保持平稳,不会随文档大小增长——实测在一个 60,000 个元素的文档上,内存占用从 228MB 降到 28MB。
这个项目的名字是怎么来的?'sg' 代表 Second Generation(第二代),向 wkhtmltopdf 和 wicked_pdf 致敬——在 wkhtmltopdf 于 2023 年归档之前,作者一直非常依赖这两个工具。
它是如何处理分页、页眉和页脚的?CSS Fragmentation 属性(break-before、break-inside、orphans、widows)和 @page 边距盒都是直接实现的,还支持表格表头在跨页时重复出现——这些能力是面向屏幕的浏览器引擎天生不具备的。
Creator
waka
Size
29.4 MB
Stars
344
Age
2mo
Commits · 90 Days