soli-pdf

Rails & Rack

soli-pdf has no Rails integration to configure: Soli::PDF.render returns a String, and Rails already knows how to send, store and attach one. This page collects the patterns that work well.

#Where things go

app/
├── pdf/
│   ├── invoice.template.json     # layouts, versioned with the code
│   └── fonts/                    # optional extra fonts
├── models/invoice.rb             # #to_pdf_data builds the data Hash
└── controllers/invoices_controller.rb
config/initializers/soli_pdf.rb   # optional: binary path, template cache

Keep templates in app/pdf/ next to the code that feeds them. They change together.

#Load templates once

Parse each template once, at boot, and freeze it. A frozen Hash is safe to share between threads.

# config/initializers/soli_pdf.rb
module PdfTemplates
  def self.load(name)
    JSON.parse(Rails.root.join('app/pdf', "#{name}.template.json").read).freeze
  end

  INVOICE = load('invoice')
  QUOTE   = load('quote')
end

In development you may prefer to re-read the file on each request, so template edits show up without a restart. Passing the file's content as a String is enough:

template = Rails.env.development? ? Rails.root.join('app/pdf/invoice.template.json').read : PdfTemplates::INVOICE

#Build the data on the model

The template prints strings, so the model decides how numbers and dates look. Use Rails' own helpers:

class Invoice < ApplicationRecord
  include ActionView::Helpers::NumberHelper

  def to_pdf_data
    {
      'company'  => { 'name' => 'Atelier Nord', 'email' => 'studio@ateliernord.fr' },
      'customer' => { 'name' => customer.name, 'address' => customer.address_lines.join("\n") },
      'invoice'  => { 'number' => number, 'issued' => I18n.l(issued_on, format: :long),
                      'total' => money(total) },
      'items'    => lines.map { |line| { 'name' => line.label, 'qty' => line.quantity.to_s,
                                         'amount' => money(line.total) } }
    }
  end

  private

  def money(amount) = number_to_currency(amount, unit: '€', format: '%n %u')
end

Keep the Hash keys as strings (symbols work too, since JSON.generate converts them). Chart values are the exception: charts need numbers, not formatted strings.

#Send it from a controller

class InvoicesController < ApplicationController
  # GET /invoices/:id.pdf
  def show
    @invoice = Invoice.find(params[:id])

    respond_to do |format|
      format.html
      format.pdf do
        pdf = Soli::PDF.render(template: PdfTemplates::INVOICE,
                               data: @invoice.to_pdf_data,
                               title: "Invoice #{@invoice.number}")
        send_data pdf, filename: "invoice-#{@invoice.number}.pdf",
                       type: 'application/pdf',
                       disposition: params[:download] ? 'attachment' : 'inline'
      end
    end
  end
end

disposition: 'inline' opens the PDF in the browser's viewer, and 'attachment' downloads it. The pdf MIME type is registered by Rails, so /invoices/42.pdf routes to format.pdf without extra setup.

#Render in a background job

A single render takes a fraction of a second, including the process start. That is fine inside a request. For documents you e-mail or archive, render them in a job, then store the result:

class InvoicePdfJob < ApplicationJob
  queue_as :default

  def perform(invoice)
    pdf = Soli::PDF.render(template: PdfTemplates::INVOICE, data: invoice.to_pdf_data,
                           title: "Invoice #{invoice.number}")

    invoice.pdf.attach(io: StringIO.new(pdf),
                       filename: "invoice-#{invoice.number}.pdf",
                       content_type: 'application/pdf')
  end
end

With Active Storage (has_one_attached :pdf), the document is rendered once and served from storage afterwards. That matters for invoices, which must not change after they are issued.

#Attach it to an e-mail

class InvoiceMailer < ApplicationMailer
  def issued(invoice)
    attachments["invoice-#{invoice.number}.pdf"] = {
      mime_type: 'application/pdf',
      content: invoice.pdf.download
    }
    mail(to: invoice.customer.email, subject: "Invoice #{invoice.number}")
  end
end

#Sinatra and plain Rack

require 'sinatra'
require 'soli/pdf'

TEMPLATE = JSON.parse(File.read('invoice.template.json')).freeze

get '/invoices/:number.pdf' do
  pdf = Soli::PDF.render(template: TEMPLATE, data: load_invoice_data(params[:number]))
  content_type 'application/pdf'
  attachment "invoice-#{params[:number]}.pdf" if params[:download]
  pdf
end

A Rack app returns the bytes as the body:

run lambda { |env|
  pdf = Soli::PDF.render(template: TEMPLATE, data: { 'invoice' => { 'number' => 'F-42' } })
  [200, { 'content-type' => 'application/pdf', 'content-length' => pdf.bytesize.to_s }, [pdf]]
}

#Testing

#Stub the renderer in unit tests

Most tests only need to know that a PDF was requested with the right data. Stub the call:

# RSpec
allow(Soli::PDF).to receive(:render).and_return('%PDF-stub')

get invoice_path(invoice, format: :pdf)

expect(Soli::PDF).to have_received(:render)
  .with(hash_including(data: hash_including('invoice' => hash_including('number' => invoice.number))))
expect(response.media_type).to eq('application/pdf')

#Render for real in a few integration tests

Keep a few tests that run the real renderer and fail on warnings. They catch broken templates and missing keys:

it 'renders the invoice template without warnings' do
  pdf = Soli::PDF.render(template: PdfTemplates::INVOICE, data: build(:invoice).to_pdf_data)

  expect(pdf).to start_with('%PDF-')
  expect(Soli::PDF.last_warnings).to be_empty
end

To check the text of a generated PDF, the pdf-reader gem extracts it:

text = PDF::Reader.new(StringIO.new(pdf)).pages.map(&:text).join("\n")
expect(text).to include(invoice.number)

#In CI

Cache ~/.cache/soli-pdf between runs, or fetch the binary in a setup step with bundle exec ruby -rsoli/pdf -e 'Soli::PDF::Binary.path'. See Installation.

#Deploying

  • Fetch the binary at build time, as the user the app runs as, so the first request doesn't download it.
  • The renderer writes nothing to disk except the gem's temporary template file, which is deleted after each render. A read-only file system works as long as Dir.tmpdir is writable.
  • Each render uses one short-lived process. Size your job concurrency with that in mind, rather than your thread count.