← Back to list

Rails: the ultimate guide to Printing Anything as PDF

Take this as an (almost) universal law:

Ihcène Medjber · 2024-08-31 14:57 · 86 claps · 6.0 min read
#ruby #ruby-on-rails #pdf-download #platformsh #grover
Open on Medium ↗
Wiki topics: 🌐 · Web Development ⚖️ · Law & Justice

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_to block: 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