Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ruby / Rails

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

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

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

Installation

# Gemfile
gem "sghtmltopdf"

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

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

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

Basics

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

Option

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

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

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

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

Options that only exist in Ruby

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

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

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

Global configuration

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

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

Fonts

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

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

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

Errors

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

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

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

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

Using it with Rails

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

The renderer

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

The options are sorted into three groups.

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

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

How asset paths are resolved

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

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

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

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

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

Delegating to a server

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

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

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

Options that cannot be set against a server

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

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

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

Receiving the PDF in chunks

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

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

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

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

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

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

Thread#kill and timeouts work

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

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

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

Streaming the response from Rails

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

class InvoicesController < ApplicationController
  include ActionController::Live

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

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

Uploading straight to S3

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

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

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

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

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

When you need to keep memory down

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

Sghtmltopdf.render(html, streaming: true)

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