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, x86_64-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 is 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_path: ["/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.
An HTML string can be passed directly with header_html_content: / footer_html_content: (CLI: --header-html-content / --footer-html-content), with no temporary file. header_html: / footer_html: still take a file path. Giving both a path and a string for the same side is an error. Placeholders, images and margins behave the same as with a file. The HTTP server accepts them too, but the HTML then travels in the query string, so mind URL length limits and access logs.
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_path, 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_path | public/ and the asset pipeline load paths | Confines local references to the application’s assets |
Both can be overridden with Sghtmltopdf.configure, regardless of initializer order. The allow_path 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. It is made up of public/ and config.assets.paths (the Propshaft or Sprockets load paths), which includes assets that live outside Rails.root, such as the ones a gem or an engine provides. config/ and db/ are not in it, so add those too if you reference somewhere that is not an asset, such as Rails.root.join("tmp/chart.png").
For cases such as development, where assets have not been written to public/ yet, there are helpers that put the asset itself into the document.
<%= sghtmltopdf_stylesheet_link_tag "pdf" %>
<%= sghtmltopdf_image_tag "logo.png" %>
<%= sghtmltopdf_asset_path "logo.png" %> <%# nil if it cannot be found %>
Both look the asset up in the pipeline load path to find the real file. That is why a plain image_tag does not work in development: the /assets/logo-abc123.png it emits is virtual, with both the digest and the mount point made up by the pipeline at request time, and no file of that name is on disk.
sghtmltopdf_stylesheet_link_tag expands the CSS itself into a <style>. It does not copy the file verbatim: every url() is pointed at something the engine can read, and every @import is spliced in.
That is needed because the asset pipeline rewrites every url() through asset_path while precompiling. With asset_host set it becomes an absolute HTTPS URL (under Sprockets), and without it a digested /assets/… path. Rendering a PDF does not go through an HTTP server, so neither can be fetched as it is. A @font-face that fails to load does not move on to the next font-family candidate either: it falls back to the engine’s default font, so the appearance changes in a way that is easy to miss.
url() in the CSS | Pointed at |
|---|---|
https://cdn.example.com/assets/x-<digest>.otf | The path part is mapped onto a local file |
/assets/x-<digest>.otf | public/, then the asset pipeline load path |
fonts/x.otf | The stylesheet’s own directory first, then the asset pipeline load path |
data:, a bare #fragment | Left alone |
| A URL that names no local file | Left alone (a genuine remote reference, such as a font served by a CDN) |
The host of an absolute URL is not compared against asset_host. It may be a Proc, or a string holding %d, so there is no general way to recognise one in reverse. Whether the path part exists on disk decides instead.
What comes out takes the same shape as sghtmltopdf_image_tag: a path relative to base_url when the file sits under it, an absolute path when the engine may read it elsewhere, and a data: URI when it may not.
sghtmltopdf_image_tag references the image by path: relative when it sits under base_url, and absolute when it does not. The engine reads an absolute path as a filesystem path once it fails to resolve under base_url, so a file in app/assets in development can be pointed at as it is.
A path only works where the engine is allowed to read, so a file outside allow_path (or outside base_url, when allow_path is not set) is embedded as a data: URI instead. A failed fetch is ignored by default, so an image pointed at by a path the engine cannot read would silently vanish. The same goes for delegating to another process with server_url, which may not be looking at this filesystem.
Pass inline: true to embed unconditionally.
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_path 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.