Rails: the ultimate guide to Printing Anything as PDF
Take this as an (almost) universal law:
Rails: the ultimate guide to Printing Anything as PDF
Take this as an (almost) universal law:
“Any tech project will end up one day requiring PDF export generation”. Ihcène
TL;DR, take this as a piece of advice: when asked to generate PDFs, don’t do it. Instead, print your existing HTML views into PDF. In this article, we will see how any view in your Ruby on Rails application can be exported as PDF, with very little extra effort.
Why?
When product teams request PDF generation, they often have high expectations for the final output. They may not fully understand that generating PDFs is much more complex than creating web pages. Over the years, I’ve experimented with various libraries and tools in both the Ruby and JS ecosystems for generating PDFs. While they work for simple needs — like layouts, tables, and page numbers — nothing matches the power and flexibility of CSS3 for creating polished, professional PDFs.
Additionally, there’s the matter of productivity. Creating PDFs with specialized tools often requires writing redundant code that mirrors your existing view logic.
Objective
In this article, we'll explore how to efficiently configure any view in your Rails app to be exported as a PDF, using the same HTML, CSS, and even JavaScript. You'll only need to add a few CSS classes to control what should or should not be printed.
Bring the container
It may seem unusual, but we need a browser to print the PDF, similar to when you press Cmd + P and select “Save as PDF.” This browser will run on your server, and your Rails code will communicate with it to print whatever you need.
Of course, you don’t need a full browser — just its core functionalities. A “headless” browser is ideal. In this article, we’ll use Chrome, but you could also use Firefox, Chromium, or other browsers.
While it’s possible to install Chrome directly on your host machine, I strongly advise against it due to potential performance, maintenance, and security issues. Instead, we’ll install it as a container. If your Rails app isn’t containerized, you can still use a dockerized solution. The key is to avoid installing the Chrome executable directly on your host, as it requires many system dependencies and can be difficult to update or remove.
services:
chrome:
image: browserless/chrome
ports:
- "9222:3000"
db:
image: postgres:10
ports:
- "5454:5432"
environment:
POSTGRES_USER: my-project
POSTGRES_PASSWORD: password
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
In this snippet, we install the browserless/chrome container and expose it on port 9222. The docker-compose.yml file also declares a database image, but that's not relevant to this article, so you can skip it.
Launch the container with:
docker-compose up
Connecting Rails to the Container
To make our app communicate with the Chrome container, we’ll use the Grover gem:
bundle add grover
In our controller, we’ll define a new format called pdf, in which we render our view to a string. This allows us to pass the HTML content directly to Grover. While it's possible to have Chrome load the page directly, this approach simplifies things by bypassing authentication and other complexities. Just ensure that the assets_host is correctly defined so that images and other assets are loaded properly.
Just make sure the assets_host is correctly defined so that images and other type of assets are loaded correctly.
Here’s an example of the controller code:
class ReportsController < ApplicationController
before_action :authenticate_user!
authorize_resource
def show
respond_to do |format|
format.html
format.pdf do
html_content = render_to_string(template: 'reports/show', layout: 'layouts/pdf', formats: [:html])
grover = Grover.new(html_content,
browser_ws_endpoint:,
format: 'A3',
print_background: true,
wait_for_timeout: 3000,
focus: '#loaded')
send_data grover.to_pdf, filename: 'Rapport.pdf', type: 'application/pdf', disposition: 'inline'
end
end
end
# ... other actions
private
def browser_ws_endpoint
if Rails.env.production?
PlatformSh.web_service_url
else
"ws://localhost:9222/chrome"
end
end
end
Code Explanation:
respond_toblock: Handles both HTML and PDF formats.render_to_string: Renders the view as a string, which Grover uses to generate the PDF.Grover.new: Initializes Grover with various options like the WebSocket endpoint, page format, background printing, and a timeout.send_data: Sends the generated PDF to the browser with an inline disposition, allowing users to view it directly in their browser.
Wait for the JS to Run
This view uses a dedicated PDF layout, but you can use your application’s default layout (application.html.erb) if desired. However, ensure that any necessary JavaScript has run before capturing the snapshot. If your views don’t require JavaScript, you can skip this step.
The focus: '#loaded' option passed to Grover will force the browser to wait until an element with a “loaded” ID appears in the page, sign that page loaded and executed the JavaScript code set to run at page load.
This will be achieved by this line in the Layout.
document.getElementById('loading').id = "loaded";
<!DOCTYPE html>
<html>
<head>
<title>My Project</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta charset="utf-8">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<script src="https://cdn.amcharts.com/lib/5/index.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
<%= Rails.application.assets_manifest.find_sources('application.css').first.to_s.force_encoding('UTF-8').html_safe %>
</style>
<script>
<%= Rails.application.assets_manifest.find_sources('application.js').first.to_s.force_encoding('UTF-8').html_safe %>
</script>
</head>
<body>
<script>
function initAppScripts(event) {
//... all your code initiations
document.getElementById('loading').id = "loaded";
}
document.addEventListener("DOMContentLoaded", initAppScripts);
document.addEventListener("turbolinks:load", initAppScripts);
</script>
<%= render partial: 'shared/navbar' %>
<%= render "shared/flashes" %>
<div id="loading">
</div>
<div class="body"><%= yield %></div>
</body>
</html>
For faster generation, inline the application CSS and JS files into the layout.
Some consideration when web-designing for print
Your views will undoubtedly contain elements that you don’t want to be printed, such as navigation bars and buttons. These can be easily hidden with Bootstrap’s .d-print-none class.
Additional Tips for Printing in CSS:
Page Numbering: Use the @page rule in CSS to define how the pages should be numbered. For example:
@page {
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
}
}
Content for Printing:
Use media queries like @media print to define styles specific to printing. For instance, hide unnecessary elements:
@media print {
.no-print { display: none; }
.print-only { display: block; }
}
Page Breaks:
Control page breaks with CSS to ensure content doesn’t break awkwardly across pages:
.avoid-page-break {
page-break-inside: avoid;
}
Make it work on Platform.sh
If you’re using a platform like Platform.sh or Heroku, which doesn’t allow direct container installation, you can still achieve this setup.
Platform.sh provides a Headless Chrome service that can be configured. Add it to .platform/services.yml
db:
type: postgresql:13
disk: 1024
size: AUTO
chrome-headless:
type: chrome-headless:120
size: AUTO
Define the relationship in .platform.app.yml file
relationships:
database: 'db:postgresql'
chrome-headless: 'chrome-headless:http'

Let’s now implement a method responsible of getting the Web Socket endpoint of the running Chrome session
class PlatformSh
def self.web_service_url
conf = PlatformSH.config.dig('relationships', 'chrome-headless', 0)
uri = URI("#{conf['scheme']}://#{conf['host']}:#{conf['port']}/json/version")
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Get.new(uri.request_uri)
request['Host'] = 'localhost'
response = http.request(request)
result = JSON.parse(response.body)
url = result['webSocketDebuggerUrl']
url.gsub!('/localhost/', "/#{conf['host']}:#{conf['port']}/")
url
end
end
Code Explanation:
PlatformSh.web_service_url: Retrieves the WebSocket Debugger URL for the Chrome Headless service on Platform.sh.- The code uses the Platform.sh API to obtain connection details for the Chrome Headless service.
- It sends an HTTP GET request to retrieve the debugger URL, which is then adjusted to work within the Platform.sh environment.
Let’s turn this into a Custom Renderer
Now that we have a fully working solution, let’s enhance our code for better satisfaction! The previous controller code is functional but somewhat verbose. We want to eliminate the repetitive boilerplate whenever we need to generate a PDF from an action.
Refactoring to a Single-Line PDF Rendering
With the new custom renderer, we can streamline our actions significantly. Instead of duplicating code, we can use a single line for rendering PDFs.
def show
respond_to do |format|
format.html
format.pdf do
render pdf: report, format: 'A3', print_background: true
end
end
end
Step 1: Define the Custom Renderer
First, we will define our custom PDF renderer in an initializer file. Create a new file at config/initializers/renderers.rb:
ActionController::Renderers.add :pdf do |_obj, options|
filename = options[:filename] || "#{controller_name}_#{action_name}"
pdf_renderer = PdfRenderer.new(self, options)
pdf_data = pdf_renderer.render
send_data pdf_data, type: 'application/pdf', disposition: "inline; filename=#{filename}.pdf"
end
Step 2: Implement the PDF Renderer Class
Next, we need to implement the PDF renderer class in app/renderers/pdf_renderer.rb. This class will encapsulate the logic for rendering HTML to PDF.
class PdfRenderer
def initialize(controller, options = {})
@controller = controller
@options = options || {}
end
def render
html_content = controller.render_to_string(
template: options[:custom_template] || "#{controller.controller_name}/#{controller.action_name}",
layout: options[:custom_layout] || 'layouts/pdf',
formats: [:html]
)
grover = Grover.new(
html_content,
**options,
browser_ws_endpoint: Rails.application.config.x.browser_ws_endpoint.call,
format: options[:format] || 'A4',
print_background: options.fetch(:print_background, false),
wait_for_timeout: options[:wait_for_timeout] || 3000,
focus: options[:focus] || '#loaded'
)
grover.to_pdf
rescue Grover::JavaScript::Error => e
@controller.render plain: e.message
end
private
attr_reader :controller, :options
end
Step 3: Customize the Browser WebSocket Endpoint
Finally, customize the browser_ws_endpoint option by environment in the respective environment configuration files:
# config/environments/development.rb
config.x.browser_ws_endpoint = proc { 'ws://localhost:9222/chrome' }
# config/environments/production.rb
config.x.browser_ws_endpoint = proc { PlatformSh.web_service_url }
Conclusion
Printing views as PDFs in Rails is not only possible but can be elegantly achieved by leveraging headless browsers like Chrome, combined with the Grover gem. This approach allows you to reuse your existing HTML, CSS, and JavaScript, streamlining the development process while ensuring high-quality PDF output. Whether you’re working on a local environment, a containerized setup, or a platform like Platform.sh, you now have the tools and knowledge to generate PDFs efficiently.
메타데이터
- post_id
- 27fc4fa1e700
- slug
- rails-the-ultimate-guide-to-printing-anything-as-pdf-27fc4fa1e700
- url
- https://medium.com/@ihcnemed/rails-the-ultimate-guide-to-printing-anything-as-pdf-27fc4fa1e700
- canonical_url
- https://medium.com/@ihcnemed/rails-the-ultimate-guide-to-printing-anything-as-pdf-27fc4fa1e700
- author_url
- https://medium.com/@ihcnemed
- status
- ok
- fetched_at
- 2026-07-22 23:41:08