Want to see the full-length video right now for free?
Sign In with GitHub for Free AccessOn 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.
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.
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.
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.
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.
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.
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.
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>
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
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
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