Rails PDF Genereren: Prawn, Grover en HTML-naar-PDF in Productie
Rails PDF genereren met Prawn, Grover en HTML-naar-PDF via headless Chrome — wanneer wat, productie-setup, achtergrondtaken en S3-opslag.
De lege PDF dook op in een Sentry-melding om twee uur ‘s nachts, drie dagen voor de jaarlijkse indieningstermijn van de klant.
Geen exception. Geen stacktrace. De job had succesvol afgerond, het bestand stond in S3, en de downloadlink werkte prima. De PDF had alleen geen tekst. Tweehonderd pagina’s zorgvuldig opgemaakt financieel data, volledig leeg — alsof de generator door de inkt heen was.
Na negentien jaar Rails ben ik dit exacte probleem vier keer tegengekomen in verschillende projecten. Elke keer is de oorzaak hetzelfde: een Prawn-gegenereerde PDF die in development prima werkt, maar in productie kapotgaat omdat de lettertypebestanden niet staan waar de code ze verwacht. Geen error — Prawn slaat tekst die het niet kan renderen stilletjes over en gaat verder.
De fix die ochtend was één Dockerfile-regel. Maar de diepere les ging over begrijpen welke aanpak voor PDF-genereren geschikt is voor welke situatie, en wat elke aanpak nodig heeft om betrouwbaar te draaien in een container.
De Drie Aanpakken voor Rails PDF Genereren
Rails-ontwikkelaars in 2026 kiezen uit drie strategieën:
-
Prawn — programmatische PDF-constructie in pure Ruby. Je plaatst elk element expliciet in een coördinatensysteem. Beste keuze voor bonnen, facturen, certificaten: documenten met voorspelbare, gestructureerde inhoud waarbij je pixel-nauwkeurige controle wilt.
-
Grover — HTML-naar-PDF via headless Chromium. Je rendert een normaal HTML-template (ERB, Phlex, wat je ook gebruikt), en Chrome drukt het af. Beste keuze voor complexe layouts, grafieken, branded rapporten, alles wat je al als webpagina bouwt.
-
PDFKit / WickedPDF — de oudere HTML-naar-PDF-aanpak via
wkhtmltopdf. Zowel wkhtmltopdf als WickedPDF zijn op dit moment niet meer actief onderhouden. Als je ze in een project hebt, vervang ze dan door Grover. Ik behandel deze niet uitgebreid.
De keuze is meestal eenvoudig: genereer je gestructureerde datadocumenten (facturen, pakbonnen, compliance-rapporten), gebruik dan Prawn. Print je webpagina’s of marketingdocumenten waarbij de HTML al bestaat of geavanceerde styling nodig heeft, gebruik dan Grover.
Prawn voor Facturen en Gestructureerde Documenten
Voeg de gem toe:
# Gemfile
gem "prawn"
gem "prawn-table"
Een minimale factuuregenerator:
# app/services/pdf/invoice_generator.rb
class Pdf::InvoiceGenerator
FONT_PATH = Rails.root.join("app/assets/fonts")
LOGO_PATH = Rails.root.join("app/assets/images/logo.png")
def initialize(invoice)
@invoice = invoice
end
def generate
Prawn::Document.new(page_size: "A4", margin: [40, 50, 40, 50]) do |pdf|
configure_fonts(pdf)
render_header(pdf)
render_line_items(pdf)
render_totals(pdf)
render_footer(pdf)
end
end
private
def configure_fonts(pdf)
pdf.font_families.update(
"Inter" => {
normal: FONT_PATH.join("Inter-Regular.ttf"),
bold: FONT_PATH.join("Inter-Bold.ttf"),
italic: FONT_PATH.join("Inter-Italic.ttf"),
bold_italic: FONT_PATH.join("Inter-BoldItalic.ttf")
}
)
pdf.font "Inter"
end
def render_header(pdf)
pdf.image LOGO_PATH.to_s, width: 120
pdf.move_down 20
pdf.text "Factuur ##{@invoice.number}", size: 22, style: :bold
pdf.text @invoice.created_at.strftime("%-d %B %Y"), size: 10, color: "666666"
pdf.move_down 30
pdf.font_size(10) do
pdf.text_box @invoice.customer.billing_address,
at: [pdf.bounds.right - 200, pdf.cursor],
width: 200
end
end
def render_line_items(pdf)
pdf.move_down 40
headers = ["Omschrijving", "Aantal", "Stuksprijs", "Totaal"]
rows = @invoice.line_items.map do |item|
[item.description, item.quantity, format_money(item.unit_price_cents), format_money(item.total_cents)]
end
Prawn::Table.new([headers] + rows, pdf,
header: true,
row_colors: %w[F9F9F9 FFFFFF],
cell_style: { size: 10, padding: [8, 10] },
column_widths: [280, 50, 90, 90]
) do |t|
t.row(0).font_style = :bold
t.row(0).background_color = "1A1A2E"
t.row(0).text_color = "FFFFFF"
t.columns(1..3).align = :right
end.draw
end
def render_totals(pdf)
pdf.move_down 20
subtotal = @invoice.subtotal_cents
tax = @invoice.tax_cents
total = @invoice.total_cents
[[nil, "Subtotaal", format_money(subtotal)],
[nil, "BTW (21%)", format_money(tax)],
[nil, "Totaal", format_money(total)]].each_with_index do |(_, label, amount), i|
style = i == 2 ? :bold : :normal
pdf.font_size(10) do
pdf.text_box label, at: [340, pdf.cursor], width: 100, style: style
pdf.text_box amount, at: [440, pdf.cursor], width: 75, align: :right, style: style
end
pdf.move_down i == 2 ? 20 : 14
end
end
def render_footer(pdf)
pdf.bounding_box([0, 30], width: pdf.bounds.width) do
pdf.stroke_color "DDDDDD"
pdf.stroke_horizontal_rule
pdf.move_down 8
pdf.font_size(8) { pdf.text @invoice.company.footer_text, color: "888888" }
end
end
def format_money(cents)
"€ #{format('%.2f', cents.to_f / 100)}"
end
end
Aanroepen vanuit een controller of job:
# app/controllers/invoices_controller.rb
def download
invoice = current_account.invoices.find(params[:id])
pdf = Pdf::InvoiceGenerator.new(invoice).generate
send_data pdf.render,
filename: "factuur-#{invoice.number}.pdf",
type: "application/pdf",
disposition: "inline"
end
Het Lettertypeprobleem
Prawn vereist TTF-bestanden op de paden die je opgeeft. In development verwijst Rails.root.join("app/assets/fonts") naar je lokale checkout. In een Docker-container verwijst het naar de werkdirectory van de container — die al dan niet de lettertypen bevat, afhankelijk van wat je in je Dockerfile met COPY meeneemt.
De oplossing is expliciet en saai. In je Dockerfile:
# Dockerfile
COPY app/assets/fonts /rails/app/assets/fonts
Als je Propshaft of Sprockets asset-compilatie gebruikt en lettertypen terechtkomen in public/assets, pas het pad dan aan. Het punt is: test File.exist? op elk lettertypepad in een opstartcheck, niet in de PDF-generator zelf.
# config/initializers/pdf_fonts.rb
Pdf::InvoiceGenerator::FONTS.values.flatten.each do |path|
raise "PDF-lettertype ontbreekt: #{path}" unless File.exist?(path)
end
Dit verandert een stille runtime-fout in een luide opstartfout die zichtbaar is in CI — niet om twee uur ‘s nachts.
Grover voor HTML-naar-PDF-conversie
Grover gebruikt Puppeteer onder de motorkap, dat een Chromium-instantie aanstuurt. De gem roept een Node.js-script aan dat Chrome start, je pagina laadt, en Chrome’s print-naar-PDF API aanroept. Het resultaat is een PDF die overeenkomt met wat een gebruiker in de browser ziet.
# Gemfile
gem "grover"
Je hebt ook Node.js en Puppeteer nodig in de omgeving:
npm install puppeteer
Het eenvoudigste gebruik: render een ERB-template naar HTML, geef het aan Grover:
# app/services/pdf/report_generator.rb
class Pdf::ReportGenerator
def initialize(report)
@report = report
end
def generate
html = ApplicationController.render(
template: "reports/pdf",
layout: "pdf",
assigns: { report: @report }
)
Grover.new(html, format: "A4", print_background: true).to_pdf
end
end
De PDF-layout (app/views/layouts/pdf.html.erb) is een minimale HTML-pagina. Geen turbo, geen stimulus, geen JavaScript dat afhankelijk is van een live server:
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; }
body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11px; color: #111; }
.page-break { page-break-after: always; }
@page { margin: 15mm 20mm; }
</style>
</head>
<body>
<%= yield %>
</body>
</html>
Chromium Draaien in Docker
Het lastigste deel van Grover in productie is Chromium. Chrome vereist verschillende systeembibliotheken die niet in een minimale Ruby-image zitten, en het moet draaien in no-sandbox-modus in containers zonder gebruikersnamespaces.
# Dockerfile (fragment)
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
chromium-driver \
fonts-liberation \
libnss3 \
libatk-bridge2.0-0 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libasound2 \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
Configureer Grover vervolgens om de systeem-Chromium te gebruiken met de vereiste flags:
# config/initializers/grover.rb
Grover.configure do |config|
config.options = {
executable_path: ENV.fetch("PUPPETEER_EXECUTABLE_PATH", nil),
args: %w[
--no-sandbox
--disable-setuid-sandbox
--disable-dev-shm-usage
--disable-gpu
--no-first-run
--no-zygote
],
timeout: 30_000,
wait_until: "networkidle0",
emulate_media_type: "print"
}
end
--no-sandbox is verplicht in Docker-containers zonder user namespace remapping. --disable-dev-shm-usage voorkomt dat Chrome crasht wanneer /dev/shm (shared memory) te klein is — de standaard 64 MB in Docker is niet genoeg voor Chrome’s rendererprocessen.
Als je Kamal gebruikt, voeg de Chromium-pakketten toe aan een aangepast Dockerfile in plaats van aan de basisimage, en zorg dat je Kamal-rolconfiguratie voldoende geheugen heeft — een Grover-render neemt 150–300 MB per Chrome-instantie.
Grover met Inline CSS versus Externe Stylesheets
Chrome in Grover draait zonder netwerktoegang tot je Rails-server tenzij je een basis-URL configureert. Relatieve <link>-tags naar je CSS-bestanden worden niet opgelost. Twee schone opties:
Optie 1: CSS inline zetten. Genereer de CSS als string in de layout:
<style>
<%= File.read(Rails.root.join("public/assets/css/pdf.css")) %>
</style>
Optie 2: Stel een basis-URL in. Configureer Grover om een interne basis-URL te gebruiken zodat assets van je app opgehaald kunnen worden:
Grover.new(html,
format: "A4",
base_url: "http://localhost:3000"
).to_pdf
Dit werkt alleen als je Rails-server bereikbaar is vanuit het PDF-generatieproces — prima voor Sidekiq-jobs op dezelfde host, maar fragiel in een gedistribueerde omgeving. Inline CSS is eenvoudiger en draagbaarder.
Achtergrondtaken voor Rails PDF Genereren
PDF’s synchroon genereren in een controlleractie blokkeert een Puma-thread voor de duur van de operatie. Een complexe Grover-render kan 3–8 seconden duren. Dat is onacceptabel in een webverzoek.
Het patroon dat ik gebruik: genereer in een achtergrondtaak, sla het resultaat op, en poll of push-notify de gebruiker wanneer het klaar is.
# app/jobs/invoice_pdf_job.rb
class InvoicePdfJob < ApplicationJob
queue_as :pdf_generation
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
pdf_bytes = Pdf::InvoiceGenerator.new(invoice).generate.render
invoice.pdf.attach(
io: StringIO.new(pdf_bytes),
filename: "factuur-#{invoice.number}.pdf",
content_type: "application/pdf"
)
invoice.update!(pdf_status: "ready")
InvoiceReadyMailer.notify(invoice).deliver_later
rescue => e
invoice&.update!(pdf_status: "failed")
raise
end
end
De pdf-bijlage gebruikt Active Storage, dat je al zou moeten hebben geconfigureerd met S3 voor productie. Sla de status op in het model zodat de UI een spinner kan tonen totdat pdf_status == "ready":
# app/models/invoice.rb
has_one_attached :pdf
enum :pdf_status, { pending: 0, ready: 1, failed: 2 }, default: :pending
Zet de taak in de wachtrij wanneer de factuur definitief gemaakt wordt:
class Invoice < ApplicationRecord
after_update_commit :enqueue_pdf_generation, if: :saved_change_to_finalized_at?
private
def enqueue_pdf_generation
InvoicePdfJob.perform_later(id)
end
end
Voor het downloadendpoint: controleer de status en serveer de opgeslagen PDF of redirect naar een “PDF wordt aangemaakt”-pagina:
def download
invoice = current_account.invoices.find(params[:id])
case invoice.pdf_status
when "ready"
redirect_to rails_blob_url(invoice.pdf, disposition: "inline")
when "pending"
render json: { status: "pending" }, status: :accepted
when "failed"
render json: { status: "failed" }, status: :unprocessable_entity
end
end
Een Turbo Stream die pollt aan de front-end verwerkt de pending-status netjes zonder websockets.
Dedicated Queue en Workerconfiguratie
PDF-generatie is rekening-intensief en mag niet concurreren met je reguliere applicatietaken. Geef het een dedicated queue en beperk de concurrency zodat je niet tegelijkertijd twaalf Chrome-instanties opstart en het geheugen uitput:
# Solid Queue: in config/queue.yml
# pdf_generation:
# workers: 2
# threads: 1 # één PDF tegelijk per worker
# Sidekiq: in config/sidekiq.yml
# :queues:
# - [pdf_generation, 2]
# :concurrency: 1 # voor het PDF-workerproces
Als je Sidekiq gebruikt, start een apart proces voor de PDF-queue met concurrency 1:
bundle exec sidekiq -q pdf_generation -c 1
Elke Chrome-instantie heeft ~200 MB RAM nodig. Twee workers bij concurrency 1 betekent twee gelijktijdige Chrome-processen: 400 MB. Houd dat budget in gedachten bij het dimensioneren van je server of het configureren van Kamal-rollen.
Caching: PDF’s Pre-genereren op het Juiste Moment
Een PDF bij eerste aanvraag genereren en cachen is niet altijd de juiste aanpak. Overweeg wanneer de inhoud kan veranderen:
- Facturen: definitieve facturen veranderen nooit. Eén keer genereren, voor altijd opslaan. De bovenstaande
after_update_commit-aanpak is correct. - Rapporten met dynamische datumbereiken: kunnen niet gecached worden; altijd vers genereren. Houd de generatie snel (streef naar sub-2s met Prawn, sub-5s met Grover) en gebruik het achtergrondtaak-patroon.
- Maandoverzichten: genereer bij maandafsluiting, sla op. Stuur een “uw overzicht is klaar”-e-mail in plaats van on-demand te genereren.
Voor PDF’s die opnieuw gegenereerd moeten worden bij datawijzigingen, koppel generatie aan een versiesleutel:
def current_pdf_version
Digest::SHA1.hexdigest([
@report.updated_at.to_i.to_s,
@report.line_items.maximum(:updated_at).to_i.to_s
].join)
end
Sla de versie op naast de bijlage en zet de taak opnieuw in de wachtrij als de versie verandert. Dit vermijdt het probleem van verouderde cache zonder alles bij elke schrijfactie te invalideren.
PDF-generatie Testen
PDF’s testen vereist een andere aanpak dan HTML-output testen. Je kunt de visuele layout niet gemakkelijk controleren, maar je kunt de structuur en inhoud wel controleren.
Voor Prawn: test of de gegenereerde bytes een geldige PDF zijn en verwachte tekst bevatten:
# test/services/pdf/invoice_generator_test.rb
class InvoiceGeneratorTest < ActiveSupport::TestCase
setup do
@invoice = invoices(:confirmed)
end
test "genereert een geldige PDF" do
pdf = Pdf::InvoiceGenerator.new(@invoice).generate.render
assert pdf.start_with?("%PDF"), "output moet een PDF zijn"
assert pdf.bytesize > 1_000, "PDF mag niet leeg zijn"
end
test "bevat factuurnummer in de output" do
require "pdf/reader"
reader = PDF::Reader.new(StringIO.new(Pdf::InvoiceGenerator.new(@invoice).generate.render))
full_text = reader.pages.map(&:text).join(" ")
assert_includes full_text, @invoice.number
assert_includes full_text, @invoice.customer.name
end
end
De pdf-reader gem laat je tekst extraheren uit een gerenderde PDF, wat de belangrijkste assertie dekt: staat de inhoud in het document? Voeg het alleen toe aan de testgroep:
gem "pdf-reader", group: :test
Voor Grover in tests: vermijd het starten van Chrome bij elke testrun. Stub de generator op de servicelaag:
RSpec.describe InvoicePdfJob do
it "voegt een PDF toe aan de factuur" do
invoice = create(:invoice, :confirmed)
allow(Pdf::InvoiceGenerator).to receive(:new).and_return(
instance_double(Pdf::InvoiceGenerator, generate: double(render: "%PDF fake"))
)
InvoicePdfJob.perform_now(invoice.id)
expect(invoice.reload.pdf).to be_attached
expect(invoice.pdf_status).to eq("ready")
end
end
Bewaar het daadwerkelijk renderen met Chrome voor een kleine set integratietests die apart van de unit-suite draaien — een pdf RSpec-tag, uitgesloten van de standaard run.
Checklist voor Productieproblemen
Na jarenlang Rails PDF-generatie in productie te hebben gedraaid, zijn dit de dingen die kapotgaan:
Lettertypebestanden ontbreken in containers. Hierboven behandeld. Voeg een opstartcheck toe en voeg een expliciete COPY toe in het Dockerfile.
Grover-time-outs. Verhoog timeout in de Grover-configuratie en stel een ruime wait_until in. Voor pagina’s met JavaScript-gerenderde inhoud wacht networkidle0 tot alle netwerkverzoeken afgerond zijn — maar het kan eeuwig wachten als je pagina long-polling-verzoeken doet. Gebruik in dat geval networkidle2 (maximaal twee inflight-verzoeken).
Chrome OOM-kills. Docker-containers zonder expliciete geheugenlimiet kunnen de host uitputten. Stel een geheugenlimiet in op de container die PDF-generatietaken uitvoert. Chrome’s rendererprocessen verbruiken elk 50–200 MB afhankelijk van de paginacomplexiteit.
Active Storage schijfruimte. PDF-archieven groeien. Stel een lifecycle-beleid in op je S3-bucket om oude PDF’s na 90 dagen naar Glacier te verplaatsen en na 7 jaar te verwijderen (voldoet aan de meeste compliance-vereisten). De Active Storage S3-configuratiepost behandelt S3-bucketbeleid.
Prawn rendert Unicode-tekens onjuist. De ingebouwde lettertypestatistieken van Prawn dekken geen niet-Latijnse schriften. Als je PDF’s genereert voor klanten met Japanse, Arabische of CJK-inhoud, embed een lettertype dat die codepunten dekt (Noto Sans is de standaardkeuze) en verwerk de lettertypeselectie per locale.
Verkeerd papierformaat. Prawn gebruikt standaard US Letter. Europese klanten verwachten A4. Stel page_size: "A4" expliciet in en documenteer het. Ik heb deze bug twee keer gerepareerd in hetzelfde project omdat iemand een nieuwe generator had toegevoegd zonder de bestaande te lezen.
Veelgestelde Vragen
Moet ik Prawn of Grover gebruiken voor Rails PDF genereren?
Gebruik Prawn wanneer je nauwkeurige programmatische controle over de layout nodig hebt: facturen, pakbonnen, certificaten, compliance-documenten met strikte opmaakregels. Prawn is pure Ruby — geen systeemafhankelijkheden, geen headless browser, snellere opstart, en makkelijker te beredeneren. Gebruik Grover wanneer je document een HTML-pagina is die je al bouwt (rapporten, dashboards, marketingmateriaal), of wanneer je CSS-layout-functies nodig hebt die pijnlijk te repliceren zijn in Prawn’s coördinatensysteem. Grover’s outputkwaliteit komt overeen met wat gebruikers in de browser zien, wat een aanzienlijk voordeel is voor complexe layouts.
Hoe voeg ik paginanummers toe aan Prawn PDF’s?
Prawn heeft een number_pages-methode die het voltooide document verwerkt en paginanummers op elke pagina stempelt:
pdf = Prawn::Document.new(page_size: "A4")
# ... render inhoud over meerdere pagina's ...
pdf.number_pages "<page> / <total>",
at: [pdf.bounds.right - 50, 0],
align: :right,
size: 9,
color: "888888"
pdf.render
Roep het aan nadat alle inhoud gerenderd is, niet ertijdens. Prawn lost <total> op in een tweede doorgang over het voltooide document.
Hoe verwerk ik meerpagina-inhoud in Grover?
Chrome verwerkt paginering automatisch op basis van @page- en page-break-after/break-before-CSS-regels. Stel de paginadimensies en marges in in het @page-blok van je PDF-layout:
@page {
size: A4;
margin: 15mm 20mm;
}
Gebruik .page-break-klassen in je template om op sectiegrensen breekpunten te forceren. Grover respecteert CSS-kolomindeling en flexbox — complexe meerkolomse PDF’s die 300 regels Prawn-code zouden vergen zijn eenvoudig HTML.
Is WickedPDF nog een goede optie in 2026?
Nee. wkhtmltopdf heeft sinds 2020 geen release meer gehad en wordt niet meer onderhouden. De rendering-engine is een verouderde WebKit die moderne CSS niet ondersteunt — Flexbox, Grid en CSS custom properties zijn gedeeltelijk of volledig kapot. WickedPDF omhult wkhtmltopdf. Als je WickedPDF vandaag nog gebruikt, migreer dan naar Grover. De HTML-templates zijn met kleine aanpassingen overdraagbaar; de hoofdwijziging is het vervangen van de Grover-initialisator voor Puppeteer-flags en het verwijderen van het wkhtmltopdf-binair van je servers.
PDF-generatie is een van die Rails-functies die eenvoudig lijkt totdat het stilletjes kapotgaat om twee uur ‘s nachts. TTB Software bouwt en onderhoudt productie-Rails-applicaties — van factuuregeneratoren tot compliance-rapportage-pipelines. Negentien jaar Rails betekent dat we elke edge case al hebben meegemaakt die jij straks gaat ontdekken.
Related Articles
Rails ActiveSupport::Notifications: Aangepaste Instrumentatie voor Metrics en Productie-monitoring
Rails ActiveSupport::Notifications vormt de basis voor aangepaste instrumentatie, metrics en APM-integratie. Abonneer...
Rails Error Reporter: Gestructureerde Foutafhandeling Zonder rescue-Spaghetti
Rails Error Reporter centraliseert foutafhandeling in productie met handle, record en report. Stop met rescue-blokken...
Rails StackProf: Flamegraph CPU-profilering voor Ruby-productieapplicaties
Rails StackProf flamegraph-profilering laat precies zien waar je Ruby CPU verbrandt — sampling-middleware, drie profi...