Generate Clean, Testable PDF Reports in Ruby/Rails with Prawn

I generally hate PDF’s. The file format is complex and designed to mimic physical paper documents, which really has little to do with the web. But unfortunately, PDF’s are still very common and often expected, particularly when working on businesses applications. I have a legacy ruby-on-rails application with a number of PDF reports and I recently took the time to refactor them in a clean and testable manner. Here’s how I went about that process:

The Report Requirements

For my project, most of my PDF reports consisted of primarily large tables of data along with a few other random pieces of data and information. Here is a screenshot of one of the simpler ones:

Prawn

The reports I’m working on have been done with the Prawn library from the beginning. This is the only direct PDF generation ruby library that I’m aware of. The early days of Prawn were a bit shaky and it didn’t support a number of features you’d expect, but the more recent versions are quite robust. And their self-documenting manual is generated using the library itself and is quite useful.

Why not use an HTML-to-PDF Library?

There are several libraries available that let you write your PDF’s in HTML and CSS, including PDFKit and wicked_pdf. Both of these use wkhtmltopdf behind the scenes. While this may suit your needs, I found that creating reports in this manner made it more difficult to manage the layout of the document, particularly when there was more than a single page.

Keep it Dry

The first thing I noticed was how much was shared between the reports. Each had either identical or very similar headers and footers, along with a number of often-repeated design paradigms that could be easily encapsulated into helper methods and constants. So I created a parent class that all my PDF reports would inherit from. It looked something like this:


class PdfReport < Prawn::Document

  # Often-Used Constants
  TABLE_ROW_COLORS = ["FFFFFF","DDDDDD"]
  TABLE_FONT_SIZE = 9
  TABLE_BORDER_STYLE = :grid

  def initialize(default_prawn_options={})
    super(default_prawn_options)
    font_size 10
  end

  def header(title=nil)
    image "#{Rails.root}/public/logo.png", height: 30
    text "My Organization", size: 18, style: :bold, align: :center
    if title
      text title, size: 14, style: :bold_italic, align: :center
    end
  end

  def footer
    # ...
  end

  # ... More helpers
end

PDF Report Classes

Then I built my actual reports, each of which is its own class that inherits from the above PdfReport. I broke up each section of the pdf into its own private method in order to make the code easy to follow.


class EventSummaryReportPdf < PdfReport
  TABLE_WIDTHS = [20, 100, 30, 60]
  TABLE_HEADERS = ["ID", "Name", "Date", "User"]

  def initialize(events=[])
    super()
    @events = events

    header 'Event Summary Report'
    display_event_table
    footer
  end

  private

  def display_event_table
    if table_data.empty?
      text "No Events Found"
    else
      table table_data,
        headers: TABLE_HEADERS,
        column_widths: TABLE_WIDTHS,
        row_colors: TABLE_ROW_COLORS,
        font_size: TABLE_FONT_SIZE
    end
  end

  def table_data
    @table_data ||= @events.map { |e| [e.id, e.name, e.created_at.strftime("%m/%d/%y"), e.created_by.try(:full_name)] }
  end

end

This PDF report could then be generated by the following:

# events = [...]
pdf = EventSummaryReportPdf.new(events)
pdf.render_file "/tmp/my_report.pdf"

Integration with Rails

As Ryan Bates suggested in his excellent screencast, I created a separate app/pdfs folder where I placed all my reports. Then in my controller, I would have the following action method:

def summary_report
  events = Event.all
  pdf = EventSummaryReportPdf.new(events)
  respond_to do |format|
    format.pdf { send_data pdf.render, filename: 'summary_report.pdf', type: 'application/pdf', disposition: 'inline' }
  end
end

Testing

What’s great about this solution is how well it lends itself to testing. By using the pdf-reader gem, we can convert the renderred PDF into a string and assert that the proper content is included. So a couple example tests (using Rspec) might look something like this:

describe EventSummaryReportPdf do
  context 'Given an array containing a single event' do
    let(:events) { [{id: 10, name: "Company Meeting", created_at: 1.day.ago, created_by: {full_name: 'John Doe'}] }
    context 'The rendered pdf content' do
      let(:pdf) { EventSummaryReportPdf.new(events) }
      let(:pdf_content) { PDF::Reader.new(StringIO.new(pdf.render)).page(1).to_s }

      it 'contains the name of the event' do
        expect(pdf_content).to include('Company Meeting')
      end
      it 'contains the full name of the user' do
        expect(pdf_content).to include('John Doe')
      end
    end
  end
end

Conclusion

This makes the process of creating PDF documents a little less painful. For those of you who are HTML and CSS wizards, be prepared to get really frustrated by how hard it is to lay out your documents in code. But just be mindful of anything that can be encapsulated into a helper method for usage later.

This covers one type of PDF report generation that is often more appropriate for generating variable-length reports. In my next blog post, I’m going to go over the process of pre-filling PDF form documents with data from your application.