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.tmpdiris writable. - Each render uses one short-lived process. Size your job concurrency with that in mind, rather than your thread count.