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.