diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45d83f5..da7b6fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index ce5a268..8a8ae32 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /pkg/ *.gem /.claude/settings.local.json +/gemfiles/*.lock diff --git a/Gemfile b/Gemfile index f13d30a..221286d 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/README.md b/README.md index 9434786..8fab19e 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/Rakefile b/Rakefile index 6a0d7f8..efe6134 100644 --- a/Rakefile +++ b/Rakefile @@ -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 diff --git a/docs/guide.html b/docs/guide.html index b095be7..c54a427 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -45,6 +45,7 @@

Guide

  • QR codes, DataMatrix and barcodes
  • PDF/A and Factur-X
  • Output
  • +
  • Rails
  • Servers that fork
  • Comparing an app’s PDFs
  • @@ -278,11 +279,27 @@

    Output

    +
    +

    Rails

    +
    +

    In a Rails application, 8.0 or later, Gamba needs no configuration. A controller renders a document, an object of the application answering to_pdf, or a PDF’s bytes; the response is application/pdf, shown in the browser unless disposition: :attachment, and the filename is encoded for any language:

    +
    def show
    +  render pdf: InvoiceDocument.new(@invoice), filename: "Facture #{@invoice.number}.pdf"
    +end
    +

    Teams that prefer views write show.pdf.rb templates. The code runs in the view, with helpers and translations at hand, and draws on 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.

    +
    # app/views/invoices/show.pdf.rb
    +document.text t(".title", number: @invoice.number), size: 18
    +document.table @invoice.lines, columns: {description: {}, amount: {align: :right}}
    +document.text number_to_currency(@invoice.total)
    +

    Active Storage attachments go straight to image and pdf_page, without a temporary file: anything that answers download is read that way. When the application eager loads, as in production, Gamba loads with it, before the server forks its workers.

    +
    +
    +

    Servers that fork

    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:

    -
    # config/puma.rb, before_fork, or an initializer
    +
    # outside Rails: config/puma.rb, before_fork, or an initializer
     Gamba.eager_load!
    diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile new file mode 100644 index 0000000..25150df --- /dev/null +++ b/gemfiles/rails_8_0.gemfile @@ -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" diff --git a/lib/gamba.rb b/lib/gamba.rb index 111efda..e7ce052 100644 --- a/lib/gamba.rb +++ b/lib/gamba.rb @@ -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) diff --git a/lib/gamba/images.rb b/lib/gamba/images.rb index 3680dc4..73f80d2 100644 --- a/lib/gamba/images.rb +++ b/lib/gamba/images.rb @@ -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) diff --git a/lib/gamba/imported_page.rb b/lib/gamba/imported_page.rb index 061ac38..214ea84 100644 --- a/lib/gamba/imported_page.rb +++ b/lib/gamba/imported_page.rb @@ -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-") diff --git a/lib/gamba/railtie.rb b/lib/gamba/railtie.rb new file mode 100644 index 0000000..51cb5a8 --- /dev/null +++ b/lib/gamba/railtie.rb @@ -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 diff --git a/script/highlight_docs.rb b/script/highlight_docs.rb index 72c9f81..0b903bd 100644 --- a/script/highlight_docs.rb +++ b/script/highlight_docs.rb @@ -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 diff --git a/test/rails/rails_test.rb b/test/rails/rails_test.rb new file mode 100644 index 0000000..1c623b7 --- /dev/null +++ b/test/rails/rails_test.rb @@ -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 diff --git a/test/support/oracle.rb b/test/support/oracle.rb index d601b10..29c4969 100644 --- a/test/support/oracle.rb +++ b/test/support/oracle.rb @@ -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)