Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
matrix:
# Every Ruby the core team still maintains (https://endoflife.date/ruby); the -head ones are in head.
ruby: ["3.3", "3.4", "4.0"]
rails: [{version: "8.1", gemfile: Gemfile}, {version: "8.0", gemfile: gemfiles/rails_8_0.gemfile}]
env:
BUNDLE_GEMFILE: ${{ matrix.rails.gemfile != 'Gemfile' && matrix.rails.gemfile || '' }}
steps: &test-steps
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
/pkg/
*.gem
/.claude/settings.local.json
/gemfiles/*.lock
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ gemspec

group :development, :test do
gem "minitest"
gem "railties" # test/rails boots an application on it
gem "rake"
gem "rubocop-performance", require: false
gem "standard", require: false
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,15 @@ barcode "0123456789012", type: :ean13, width: 120, height: 40, text: true
data_matrix "alice@example.com", size: 40
```

Loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they share it in
memory:
Rails, 8.0 and later, with nothing to configure: controllers render PDFs, `.pdf.rb` templates draw them with the
view's helpers and translations, and Gamba loads before the server forks when the application eager loads:

```ruby
render pdf: InvoiceDocument.new(@invoice), filename: "invoice.pdf" # anything answering to_pdf, or a document
```

Outside Rails, loading font and SVG support once, before a server such as Puma or Unicorn forks its workers, so they
share it in memory:

```ruby
Gamba.eager_load!
Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ Rake::TestTask.new(:test) do |task|
task.libs << "lib" << "test"
task.test_files = FileList["test/*_test.rb"]
end

# Rails in a process of its own: its core extensions would otherwise change the Ruby the other tests run on.
Rake::TestTask.new("test:rails") do |task|
task.libs << "lib" << "test"
task.test_files = FileList["test/rails/*_test.rb"]
end
task test: "test:rails"
task default: :test

namespace :corpus do
Expand Down
19 changes: 18 additions & 1 deletion docs/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ <h1 class="page-title">Guide</h1>
<li><a href="#barcodes">QR codes, DataMatrix and barcodes</a></li>
<li><a href="#pdf-a">PDF/A and Factur-X</a></li>
<li><a href="#output">Output</a></li>
<li><a href="#rails">Rails</a></li>
<li><a href="#servers">Servers that fork</a></li>
<li><a href="#comparing">Comparing an app’s PDFs</a></li>
</ul>
Expand Down Expand Up @@ -278,11 +279,27 @@ <h2 id="output-title">Output</h2>
</div>
</section>

<section id="rails" aria-labelledby="rails-title">
<h2 id="rails-title">Rails</h2>
<div class="prose">
<p>In a Rails application, 8.0 or later, Gamba needs no configuration. A controller renders a document, an object of the application answering <code>to_pdf</code>, or a PDF’s bytes; the response is <code>application/pdf</code>, shown in the browser unless <code>disposition: :attachment</code>, and the filename is encoded for any language:</p>
<pre><code class="language-ruby"><span class="k">def</span> <span class="nf">show</span>
<span class="n">render</span> <span class="ss">pdf: </span><span class="no">InvoiceDocument</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="vi">@invoice</span><span class="p">),</span> <span class="ss">filename: </span><span class="s2">"Facture </span><span class="si">#{</span><span class="vi">@invoice</span><span class="p">.</span><span class="nf">number</span><span class="si">}</span><span class="s2">.pdf"</span>
<span class="k">end</span></code></pre>
<p>Teams that prefer views write <code>show.pdf.rb</code> templates. The code runs in the view, with helpers and translations at hand, and draws on <code>document</code>, a <code>Gamba::Document</code>; Prawn’s API is at <code>document.prawn</code>. A template that ends with a document of its own, Gamba’s or Prawn’s, renders that one.</p>
<pre><code class="language-ruby"><span class="c1"># app/views/invoices/show.pdf.rb</span>
<span class="n">document</span><span class="p">.</span><span class="nf">text</span> <span class="n">t</span><span class="p">(</span><span class="s2">".title"</span><span class="p">,</span> <span class="ss">number: </span><span class="vi">@invoice</span><span class="p">.</span><span class="nf">number</span><span class="p">),</span> <span class="ss">size: </span><span class="mi">18</span>
<span class="n">document</span><span class="p">.</span><span class="nf">table</span> <span class="vi">@invoice</span><span class="p">.</span><span class="nf">lines</span><span class="p">,</span> <span class="ss">columns: </span><span class="p">{</span><span class="ss">description: </span><span class="p">{},</span> <span class="ss">amount: </span><span class="p">{</span><span class="ss">align: :right</span><span class="p">}}</span>
<span class="n">document</span><span class="p">.</span><span class="nf">text</span> <span class="n">number_to_currency</span><span class="p">(</span><span class="vi">@invoice</span><span class="p">.</span><span class="nf">total</span><span class="p">)</span></code></pre>
<p>Active Storage attachments go straight to <code>image</code> and <code>pdf_page</code>, without a temporary file: anything that answers <code>download</code> is read that way. When the application eager loads, as in production, Gamba loads with it, before the server forks its workers.</p>
</div>
</section>

<section id="servers" aria-labelledby="servers-title">
<h2 id="servers-title">Servers that fork</h2>
<div class="prose">
<p>TrueType and SVG support load on first use, so a document of standard fonts never pays for them. Before a server forks its workers, load them once, so every worker shares that memory instead of loading its own copy:</p>
<pre><code class="language-ruby"><span class="c1"># config/puma.rb, before_fork, or an initializer</span>
<pre><code class="language-ruby"><span class="c1"># outside Rails: config/puma.rb, before_fork, or an initializer</span>
<span class="no">Gamba</span><span class="p">.</span><span class="nf">eager_load!</span></code></pre>
</div>
</section>
Expand Down
12 changes: 12 additions & 0 deletions gemfiles/rails_8_0.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

source "https://rubygems.org"

gemspec path: ".."

gem "minitest"
gem "railties", "~> 8.0.0"
gem "rake"
gem "rubocop-performance", require: false
gem "standard", require: false
gem "vernier"
2 changes: 2 additions & 0 deletions lib/gamba.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,5 @@ module Core

Prawn.image_handler.register(Prawn::Images::JPG)
Prawn.image_handler.register(Prawn::Images::PNG)

require_relative "gamba/railtie" if defined?(Rails::Railtie)
3 changes: 3 additions & 0 deletions lib/gamba/images.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ def embed_image(pdf_object, info, options)

private

# An IO, a path, or anything answering download, as Active Storage attachments and blobs do.
def verify_and_read_image(io_or_path)
return io_or_path.download if io_or_path.respond_to?(:download) && !io_or_path.respond_to?(:rewind)

if io_or_path.respond_to?(:rewind)
io_or_path.rewind
io_or_path.binmode if io_or_path.respond_to?(:binmode)
Expand Down
6 changes: 4 additions & 2 deletions lib/gamba/imported_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ class ImportedPage < Prawn::Images::Image

attr_reader :width, :height, :form

# source: a path, an IO, or the bytes of a PDF.
# source: a path, an IO, the bytes of a PDF, or anything answering download, as Active Storage attachments do.
def self.bytes(source)
if source.respond_to?(:read)
if source.respond_to?(:download) && !source.respond_to?(:read)
source.download.b
elsif source.respond_to?(:read)
source.rewind if source.respond_to?(:rewind)
source.read.b
elsif source.is_a?(String) && source.b.start_with?("%PDF-")
Expand Down
52 changes: 52 additions & 0 deletions lib/gamba/railtie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module Gamba
# What a Rails application gets once Gamba is in its bundle: `render pdf:` in controllers, `.pdf.rb` templates,
# and Gamba loaded before the server forks. Defined only when Rails is.
class Railtie < ::Rails::Railtie
# The bytes of what a controller renders or a template ends with: a Gamba::Document, a Prawn::Document, an
# object answering to_pdf (a document class of the application), or the bytes themselves.
def self.pdf(document)
if document.respond_to?(:to_pdf) then document.to_pdf
elsif document.is_a?(Gamba::Document) then document.to_s
elsif document.respond_to?(:render) then document.render
elsif document.is_a?(String) then document
else raise ArgumentError, "render pdf: takes a Gamba::Document, a Prawn::Document, an object answering to_pdf or a PDF's bytes, not #{document.class}"
end.b
end

# A `.pdf.rb` template: its code runs in the view, helpers and translations at hand, with `document`, a
# Gamba::Document; Prawn's API is at document.prawn. A template that ends with a document of its own (Gamba's
# or Prawn's) renders that one instead. A PDF is bytes, not text: the response loses the charset Rails adds.
module TemplateHandler
def self.call(template, source)
<<~RUBY
document = ::Gamba::Document.new
result = begin
#{source}
end
if respond_to?(:response) && response
response.content_type = Mime[:pdf].to_s
response.charset = false
end
::Gamba::Railtie.pdf(result.is_a?(::Gamba::Document) || result.is_a?(::Prawn::Document) ? result : document)
RUBY
end
end

# Before the server forks its workers, so they share what Gamba loads instead of each loading its own.
config.before_eager_load { Gamba.eager_load! }

initializer "gamba.renderer" do
ActiveSupport.on_load(:action_controller) do
ActionController::Renderers.add(:pdf) do |document, options|
send_data Gamba::Railtie.pdf(document), {type: Mime[:pdf], disposition: "inline"}.merge(options.slice(:filename, :disposition, :status))
end
end
end

initializer "gamba.template_handler" do
ActiveSupport.on_load(:action_view) { ActionView::Template.register_template_handler(:rb, Gamba::Railtie::TemplateHandler) }
end
end
end
2 changes: 1 addition & 1 deletion script/highlight_docs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def highlight(page, path)

check = ARGV.include?("--check")
stale_paths = Dir[File.expand_path("../docs/*.html", __dir__)].sort.select do |path|
page = File.read(path)
page = File.read(path, encoding: Encoding::UTF_8)
highlighted = highlight(page, path)
next false if highlighted == page

Expand Down
168 changes: 168 additions & 0 deletions test/rails/rails_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# frozen_string_literal: true

require "minitest/autorun"
require "open3"
require "rbconfig"
require "fileutils"
require "tmpdir"
require "logger"
require "rails"
require "action_controller/railtie"
require "gamba"
require_relative "../support/oracle"

CORPUS_MISSING_MESSAGE = "corpus/cache is empty: run `bundle exec rake corpus:fetch`"

# A Rails application with Gamba in its bundle: controllers render PDFs, `.pdf.rb` templates draw them with the
# view's helpers, and Gamba loads before the server forks.
class RailsTest < Minitest::Test
VIEWS = Dir.mktmpdir("gamba-views")
TEMPLATES = File.join(VIEWS, "rails_test/documents").tap { FileUtils.mkdir_p(_1) }
File.write(File.join(TEMPLATES, "show.pdf.rb"), <<~RUBY)
document.text t("greeting", name: "Alice")
document.text number_to_currency(1234.5, unit: "EUR ", format: "%u%n")
RUBY
File.write(File.join(TEMPLATES, "prawn.pdf.rb"), <<~RUBY)
Prawn::Document.new(page_size: "A4") { text "Prawn in a template" }
RUBY

# An application's own document class: Gamba asks it for to_pdf.
Invoice = Struct.new(:number) do
def to_pdf
Gamba.document { |document| document.text "Invoice #{number}" }.to_s
end
end

# What an Active Storage attachment answers.
Attachment = Struct.new(:bytes) do
def download
bytes
end
end

class Application < Rails::Application
config.root = VIEWS
config.eager_load = false
config.logger = Logger.new(nil)
config.secret_key_base = "0" * 64
config.hosts.clear
config.i18n.backend = I18n::Backend::Simple.new
end

class DocumentsController < ActionController::Base
prepend_view_path VIEWS

def native
render pdf: Gamba.document { |document| document.text "Hello Alice" }, filename: "Facture été 2026.pdf"
end

def prawn
render pdf: Prawn::Document.new { text "Hello from Prawn" }, disposition: :attachment
end

def invoice
render pdf: Invoice.new("INV-2026-0001")
end

def show
render "show", formats: :pdf
end

def template_document
render "prawn", formats: :pdf
end
end

Application.initialize!
Application.routes.draw do
%w[native prawn invoice show template_document].each { |action| get "documents/#{action}", to: "rails_test/documents##{action}" }
end
I18n.backend.store_translations(:en, greeting: "Hello %{name}")

def test_a_controller_renders_a_document_inline_with_its_filename_encoded
status, headers, body = request("native")

assert_equal 200, status
assert_equal "application/pdf", headers["content-type"]
assert_equal %(inline; filename="Facture ete 2026.pdf"; filename*=UTF-8''Facture%20%C3%A9t%C3%A9%202026.pdf), headers["content-disposition"]
assert_equal ["Hello Alice"], lines(body)
end

def test_prawn_documents_and_objects_answering_to_pdf_render_too
_status, headers, body = request("prawn")

assert_equal "attachment", headers["content-disposition"]
assert_equal ["Hello from Prawn"], lines(body)
assert_equal ["Invoice INV-2026-0001"], lines(request("invoice").last)
assert_raises(ArgumentError) { Gamba::Railtie.pdf(Object.new) }
end

def test_a_template_draws_on_a_document_with_the_view_helpers
status, headers, body = request("show")

assert_equal 200, status
assert_equal "application/pdf", headers["content-type"]
assert_equal ["Hello Alice", "EUR 1,234.50"], lines(body)
end

def test_a_template_that_ends_with_its_own_document_renders_that_one
assert_equal ["Prawn in a template"], lines(request("template_document").last)
end

def test_active_storage_attachments_are_images_and_pdf_pages
path = File.join(Oracle::ROOT, "corpus/cache/prawn/data/images/dice.png")
skip CORPUS_MISSING_MESSAGE unless File.exist?(path)

png = File.binread(path)
letterhead = Attachment.new(Prawn::Document.new { text "Letterhead" }.render)
document = Prawn::Document.new do
image Attachment.new(png), width: 50
pdf_page letterhead, width: 100
end

assert_includes lines(document.render), "Letterhead"
end

def test_gamba_loads_before_the_server_forks_when_rails_eager_loads
output, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", <<~RUBY)
require "logger"
require "rails"
require "action_controller/railtie"
require "gamba"
class EagerApplication < Rails::Application
config.root = Dir.pwd
config.eager_load = true
config.logger = Logger.new(nil)
config.secret_key_base = "0" * 64
end
EagerApplication.initialize!
print Gamba::Barcodes.autoload?(:QRCode).inspect, " ", Gamba.autoload?(:ImportedPage).inspect
RUBY

assert status.success?, output
assert_equal "nil nil", output.lines.last
end

def test_without_rails_gamba_defines_nothing_of_it
output, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", 'require "gamba"; print defined?(Rails).inspect, " ", defined?(ActiveSupport).inspect, " ", Gamba.const_defined?(:Railtie, false)')

assert status.success?, output
assert_equal "nil nil false", output
end

private

def request(action)
status, headers, body = Application.call(Rack::MockRequest.env_for("/documents/#{action}"))
[status, headers, body.each.to_a.join]
end

def lines(pdf)
Tempfile.create(["rails", ".pdf"]) do |file|
file.binmode
file.write(pdf)
file.flush
Oracle.text_layout(file.path).flat_map { |page| page.glyphs.group_by(&:last).sort.map { |_y, glyphs| glyphs.map(&:first).join } }
end
end
end
3 changes: 2 additions & 1 deletion test/support/oracle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ module Oracle

ENGINES = {
"prawn" => {gemfile: File.join(ROOT, "oracle/Gemfile"), load_path: []},
"gamba" => {gemfile: File.join(ROOT, "Gemfile"), load_path: ["-I", File.join(ROOT, "lib")]}
# The bundle the tests run in: CI runs them under each Rails's gemfile, the others' gems not installed.
"gamba" => {gemfile: File.expand_path(ENV["BUNDLE_GEMFILE"].to_s.empty? ? "Gemfile" : ENV["BUNDLE_GEMFILE"], ROOT), load_path: ["-I", File.join(ROOT, "lib")]}
}.freeze

Rendering = Data.define(:engine, :directory, :failure)
Expand Down
Loading