Video

Want to see the full-length video right now for free?

Sign In with GitHub for Free Access

Notes

On this week's episode, Chris takes us through everything we need to work with PDFs in our Rails apps: the easiest way to generate them, how to properly serve them as responses in our controllers, and even how to test them.

Different Approaches To Generating PDFs

There are many ways to generate PDFs in Ruby and Rails, but we're going to focus on two: Prawn and PDFKit. Prawn gives you more control over output but has a steeper learning curve, while PDFKit lets you use what you already know (HTML) to generate PDFs from standard Rails view and style code.

Prawn

The first tool that we'll look at is known as Prawn. It is a Ruby gem that provides a powerful DSL for generating PDFs. To see a simple example, install the prawn gem and then run the following bit of Ruby:

require "prawn"

Prawn::Document.generate("prawn_example.pdf") do
  text "Hello world!"
  stroke_circle [20, 20], 10
end

You should now have a new file called prawn_example.pdf, with a bit of text at the top and a circle at the bottom. Yay!

To see examples of just about anything you could ever want to do, check out Prawn by example, the official manual (which, of course, is generated by Prawn).

Prawn is very powerful, and if you need extremely precise control over PDF output, it's a good choice. The downside, however, is that you have to wrap your head around its rendering model, and learn its DSL for laying out documents.

wkhtmltopdf

As it turns out, however, we already know a pretty good language for laying out documents -- HTML. Wouldn't it be nice if we could write HTML and have it rendered as PDF?

Enter wkhtmltopdf (WebKit HTML to PDF), an engine that will take HTML and CSS, render it using WebKit, and output it as a PDF with surprisingly high quality and consistency.

PDFKit

wkhtmltopdf is a command-line tool, but there are several Ruby gems that wrap it up for us. The one we'll focus on is PDFKit. To see a simple example, install wkhtmltopdf on your machine, install the pdfkit gem, and then run the following bit of Ruby:

require "pdfkit"

kit = PDFKit.new(<<-HTML)
  <p>Hello world!</p>
HTML

kit.to_file("pdfkit_example.pdf")

You should now have a new file called pdfkit_simple_example.pdf with a bit of text at the top. Yay!

What's awesome about pdfkit is that we can write the HTML and CSS that we know and love to build more complicated PDFs:

require "pdfkit"

kit = PDFKit.new(<<-HTML)
  <style>
    * {
      color: red;
    }

    td {
      border: 1px solid #555;
      margin: 0;
    }

    tr:nth-child(2n) {
      background: #ccc;
    }
  </style>

  <p>Hello world!</p>

  <table>
    <tr>
      <td>Hello</td>
      <td>World</td>
      <td>-</td>
      <td>Data</td>
    </tr>
    <tr>
      <td>Hello</td>
      <td>-</td>
      <td>World</td>
      <td>Data</td>
    </tr>
    <tr>
      <td>-</td>
      <td>Hello</td>
      <td>World</td>
      <td>Data</td>
    </tr>
  </table>
HTML

kit.to_file("pdfkit_complicated_example.pdf")

You should now have a new file called pdfkit_complicated_example.pdf with a CSS-styled table of data. Yay!

We have basically all of HTML and CSS available, so we could make this look as nice as we want. PDFKit gives us a great balance between control and ease of use.

Integrating PDFKit into a Rails App

Now, let's take a look at how to actually use this in the context of a Rails app. You can follow along with the video in the associated Invoicer example application.

Foundations

To start, we'll quickly review the foundation of this app based on its models and relationships. You can check this out in the Add initial models Product, Invoice, & LineItem commit, or check out the code locally with:

$ git checkout -b foundations 3eda9c631

If you take a look at db/schema.rb, you'll see that we have three main models that we're going to be working with: Invoice, Product, and LineItem. The job of this application is to produce sales receipts.

If we navigate to the root URL, we see that we have an index of invoices and a show page for each invoice already built out. It's pretty traditional invoice behavior with a very simple data model.

Add Download class to handle PDF rendering

In the next commit we introduce a class to wrap up our PDF generation logic, as well as a layout, stylesheet, and view for our PDF. You can see all the changes in the Add Download class to handle PDF rendering commit, or check them out locally with:

$ git checkout a260c81

In addition, we also add both the PDFKit gem, and the render_anywhere gem. PDFKit is explained above, but render_anywhere is new here. Its job is to allow us to render our PDF template from our Download object.

require "render_anywhere"

class Download
  include RenderAnywhere

  def initialize(invoice)
    @invoice = invoice
  end

  def to_pdf
    kit = PDFKit.new(as_html)
    kit.to_file("tmp/invoice.pdf")
  end

  def filename
    "Invoice #{invoice.number}.pdf"
  end

  private

  attr_reader :invoice

  def as_html
    render template: "invoices/pdf",
      layout: "invoice_pdf",
      locals: { invoice: invoice }
  end
end

The view, layout, and stylesheet are very familiar; in fact, they are simply copies of the existing views used to render the invoice show page. The one interesting bit is the inlining of the stylesheet into the HTML page. This is not necessary, but it simplifies the PDF generation as wkhtmltopdf now does not need to fetch any external resources to render the PDF.

<style>
  <%= Rails.application.assets.find_asset("invoice.pdf").to_s %>
</style>

Add DownloadsController for sending PDFs

Our next commit introduces the needed code to render the PDF via a Rails controller. You can see all the changes in the Add DownloadsController for sending PDFs commit, or check it out locally with:

$ git checkout 63bcdb0

The changes are thankfully very simple. We first add a "Download" link to the invoice page. Two interesting aspects here are the use of the format: "pdf" option in the path helper, and the addition of target: "_blank" which is an HTML option that will cause the link to open in a new tab.

We add a singular nested resource to our routes for the download action, nested within our invoice. Often these sorts of routes will be added as additional member actions within a controller, for instance adding a download action to the InvoicesController, but in the spirit of REST, we want to break this out and declare that a Download is a distinct resource, and not overload the InvoicesController.

Lastly, we add the DownloadsController which has the single show action which only responds with PDF via the respond_to block. We use Rails' send_file method to actually send the PDF, passing the needed options to provide a filename and specify how to download.

class DownloadsController < ApplicationController
  def show
    respond_to do |format|
      format.pdf { send_invoice_pdf }
    end
  end

  private

  def invoice
    Invoice.find(params[:invoice_id])
  end

  def download
    Download.new(invoice)
  end

  def send_invoice_pdf
    send_file download.to_pdf, download_attributes
  end

  def download_attributes
    {
      filename: download.filename,
      type: "application/pdf",
      disposition: "inline"
    }
  end
end

Render PDF sample as HTML in dev mode

Our next commit adds a bit of support code to allow for more rapid iteration while working in development mode. In production, we only want to use our PDF view to generate the PDF, but in development we'll be able to iterate and tweak our design and layout of the PDF much more quickly if we can render it as a normal HTML view.

You can see all of the changes in the Render PDF sample as HTML in dev mode commit, or check it out locally with:

$ git checkout 31a4233

We enable this by adding a development environment specific format handler in the DownloadsController to render the HTML view. In addition, we expose the render attributes in our Download objects so both the PDF generation and the development-only HTML rendering will use the same rendering settings and data.

 def show
   respond_to do |format|
     format.pdf { send_invoice_pdf }
+
+    if Rails.env.development?
+      format.html { render_sample_html }
+    end
   end
 end

Our next commit adds a feature spec for our download code that allows us to make a number of assertions about the download file, and the contents therein. You can see all of the changes in the Add feature spec with PDF related assertions commit, or check them out locally with:

$ git checkout c4e5ace

To start, we'll add the pdf-reader gem which allows us to read in the PDF and make assertions about the content of the document. We end up with only the text content, so we're not able to make the same level of detailed assertions we can with say Capybara's page DSL, but it is certainly better than not testing at all.

In addition, we can wrap up the headers of the response generated by our app to allow us make a number of assertions about the download file itself.

describe "User downalods PDF" do
  scenario "for an invoice with normal data" do
    product = create(:product, item_number: 'abc-123')
    invoice = create(:invoice)
    line_item = create(:line_item, product: product, invoice: invoice)

    visit invoice_path(invoice)
    click_link "Download PDF"

    expect(content_type).to eq("application/pdf")
    expect(content_disposition).to include("inline")
    expect(download_filename).to include(invoice.number)
    expect(pdf_body).to have_content(product.description)
  end

  # ...
end

Deploying to Heroku

Lastly, we have a commit that adds support for running this PDF generating app on Heroku. Since we rely on an external command, namely wkhtmltopdf, we need it to be present on our server in order for the app to run. Thankfully, we can add a single gem which provides a Heroku friendly version of wkhtmltopdf, and with that we're set.

# Gemfile

group :staging, :production do
  gem "wkhtmltopdf-heroku"
end