Ruby's HTML-to-PDF options split by the rendering engine: wkhtmltopdf (WebKit, good CSS support) via Wicked PDF, and headless Chrome (full modern CSS) via Grover. Wicked PDF is the Rails standard and integrates cleanly with ActionView templates. Grover supports CSS Grid and modern layouts that wkhtmltopdf chokes on. Both need system binaries — if you can't install them, the API is the right call.
Method 1: Wicked PDF (wkhtmltopdf + Rails integration)
Wicked PDF is the standard Rails HTML-to-PDF gem. It wraps wkhtmltopdf and integrates with ActionController for rendering view templates as PDFs.
# Gemfile
gem 'wicked_pdf'
gem 'wkhtmltopdf-binary' # includes wkhtmltopdf binary
bundle install
bundle exec rails generate wicked_pdf # creates initializer# In a Rails controller
class ReportsController < ApplicationController
def show
@report = Report.find(params[:id])
respond_to do |format|
format.html
format.pdf do
render pdf: "report-#{@report.id}",
template: 'reports/show',
layout: 'pdf', # separate layout without nav/footer chrome
formats: [:html],
page_size: 'A4',
margin: { top: 15, bottom: 15, left: 15, right: 15 },
footer: { content: render_to_string('shared/pdf_footer') },
disable_javascript: true # security: don't run JS in templates
end
end
end
end
# Standalone (outside Rails)
require 'wicked_pdf'
WickedPdf.config = { wkhtmltopdf: '/usr/local/bin/wkhtmltopdf' }
html = 'Invoice
'
pdf_bytes = WickedPdf.new.pdf_from_string(html,
page_size: 'A4',
margin: { top: 20, bottom: 20, left: 15, right: 15 }
)
File.binwrite('output.pdf', pdf_bytes)
The wkhtmltopdf-binary gem includes a pre-compiled binary for most platforms. If you need a newer version or a different platform, install wkhtmltopdf from wkhtmltopdf.org and configure the path in the Wicked PDF initializer.
Method 2: Grover (headless Chromium, full CSS support)
Grover uses Puppeteer under the hood to render HTML with headless Chromium. Supports CSS Grid, flexbox, CSS variables — anything a modern browser handles.
# Gemfile
gem 'grover'
bundle install
# Install Puppeteer
npm install puppeteer # installs bundled Chromium
# Or: npm install puppeteer-core and configure existing Chromerequire 'grover'
def html_to_pdf(html, out_path, options = {})
default_options = {
format: 'A4',
margin: { top: '15mm', bottom: '15mm', left: '15mm', right: '15mm' },
print_background: true,
display_header_footer: false,
wait_until: 'networkidle0' # wait for network requests to finish
}
merged = default_options.merge(options)
grover = Grover.new(html, **merged)
pdf_bytes = grover.to_pdf
File.binwrite(out_path, pdf_bytes)
end
html = File.read('report.html')
html_to_pdf(html, 'output.pdf', format: 'Letter')
puts 'Done'
# Convert a URL (useful for dashboards, live pages)
grover = Grover.new('https://example.com', format: 'A4')
pdf_bytes = grover.to_pdf
File.binwrite('snapshot.pdf', pdf_bytes)
Grover is best for complex dashboards, reports with charts, or any HTML using modern CSS. It's heavier than Wicked PDF — Chromium needs ~200MB RAM minimum. On memory-constrained servers, Wicked PDF is more practical.
Method 3: ChangeThisFile API (Net::HTTP, no binary installs)
The API converts HTML to PDF server-side. Source auto-detected from filename. Free tier: 1,000 conversions/month, no card required.
require 'net/http'
require 'uri'
require 'securerandom'
require 'tempfile'
API_KEY = 'ctf_sk_your_key_here'
def html_to_pdf_api(html_content, out_path)
uri = URI('https://changethisfile.com/v1/convert')
boundary = "CTF#{SecureRandom.hex(8)}"
# Write HTML to temp file
Tempfile.create(['ctf_input', '.html']) do |tmp|
tmp.write(html_content)
tmp.flush
file_data = File.binread(tmp.path)
body = [
"--#{boundary}\r\n",
'Content-Disposition: form-data; name="file"; filename="document.html"\r\n',
"Content-Type: text/html\r\n\r\n",
file_data, "\r\n",
"--#{boundary}\r\n",
"Content-Disposition: form-data; name=\"target\"\r\n\r\n",
"pdf\r\n",
"--#{boundary}--\r\n"
].join
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{API_KEY}"
req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) { |h| h.request(req) }
raise "API error #{resp.code}" unless resp.code == '200'
File.binwrite(out_path, resp.body)
end
end
html = 'Report
Generated content.
'
html_to_pdf_api(html, './output/report.pdf')
puts 'Done'
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| Wicked PDF | Rails apps, ActionView templates, low memory servers | wkhtmltopdf binary required; CSS support limited to WebKit subset |
| Grover | Modern CSS (grid, flexbox), dashboards, complex layouts | Requires Node.js + Chromium (~200MB RAM minimum) |
| ChangeThisFile API | No binary installs, PaaS, quick scripts | Network call; 25MB limit on free tier |
Production tips
- Use a separate PDF layout in Rails. Create app/views/layouts/pdf.html.erb without the application chrome (nav, footer, scripts). Reference it with layout: 'pdf' in the respond_to block.
- Disable JavaScript in wkhtmltopdf templates. Rendering user-submitted HTML with JS enabled creates XSS/SSRF risk. Pass disable_javascript: true in Wicked PDF options.
- Grover needs sandbox disabled in Docker. Running Chromium as root in a container requires --no-sandbox. Add it to launch_args: in Grover config: Grover.configuration.options = { launch_args: ['--no-sandbox'] }.
- Set explicit page dimensions for non-A4 output. Wicked PDF and Grover both default to A4. Pass page_width and page_height (in Grover) for custom sizes like US Letter or legal.
- Background jobs for large documents. PDF generation blocks a request thread. Use Sidekiq or GoodJob to generate PDFs asynchronously and return a download URL.
Wicked PDF is the right default for Rails apps — it's mature, well-documented, and the binary gem makes setup simple. Grover is worth it for modern CSS-heavy layouts. For environments where binary installs are off the table, the API handles it. Free tier: 1,000 conversions/month.