I often design PDFs for emailing tickets, invoices, certificates, reports. Heck, I even had the whole business idea of building CertificateOwl that is centered around generating and sending PDFs!

Todays mission: “When an invoice is created, generate a PDF and email it to the client”. Example:

docraptor-generate-pdf-full-flow

How would you do that?

Usually I would:

  • generate PDF from HTML with gem wicked_pdf
  • store the PDF with ActiveStorage
  • send the PDF with ActionMailer

But there’s a problem:

1. 💀 Gem WickedPDF is dead. #

Since 2015 I have always relied on the gem wicked_pdf for generating PDFs out of my HTML templates. With wicked_pdf, we could design an app/views/invoices/show.pdf.erb HTML document, and format.pdf would render more-less what we designed. Designing PDFs felt like WYSIWYG (what you see is what you get).

However in 2023 the underlying technology behind this gem, wkhtmltopdf, has been archived. This means that gem "wicked_pdf" is no longer recommended for any new projects. In fact, we should consider replacing it in existing projects!

2. So, what are the alternatives? #

  1. Gem DocRaptor - ruby API wrapper around the advanced Prince HTML-to-PDF technology.
  2. Gem Prawn - DSL to script PDF documents with plain Ruby.
  3. Gem Ferrum - virtual “headless” browser opens a page in “Print”/”Save to PDF” view.

While Prawn and Ferrum offer fundamentally different approaches to generating PDF, I think DocRaptor might be the the best “plug-and-play” replacement for wicked_pdf, because it uses the same technological principle (HTML-to-PDF).

I think that CSS Paged Media is the killer feature of DocRaptor/Prince technology: it allows us to have maximum CSS control of what is rendered on a single PDF page.

By the way, I first casually heard about DocRaptor on IndieRails Podcast: Matt Gordon - Going from Consulting to Products. Let’s give it a try!

3. DocRaptor.com basic usage #

Useful resources:

Add the gem:

# Gemfile
gem "docraptor"

Create a job that would generate a PDF for an Invoice record.

DocRaptor::DocApi.new.create_doc makes an API request to DocRaptor.

The API request will try to turn the app/views/invoices/show.html.erb template into PDF.

The API response will be saved locally as a PDF in your apps’ root folder.

# rails g job Invoices::ToPdf

# app/jobs/invoices/to_pdf_job.rb
DocRaptor.configure do |config|
  config.username = "YOUR_API_KEY_HERE" # THIS key works in test mode!
end

class Invoices::ToPdfJob < ApplicationJob
  queue_as :default

  def perform(invoice)
    # document_content = ActionController::Base.render( # bad
    document_content = ApplicationController.render(
      template: 'invoices/show',
      # layout: 'layouts/application', # in development fails with "File system access is not allowed"
      layout: 'layouts/pdf',
      assigns: { invoice: }
    )

    response = DocRaptor::DocApi.new.create_doc(
      test: true,
      document_type: "pdf",
      document_content: document_content,
    )

    # Generate a unique filename for each invoice PDF
    filename = "invoice_#{invoice.id}.pdf"

    # Save the PDF locally
    File.write(filename, response, mode: "wb")
    puts "Successfully created #{filename}!"
  rescue StandardError => error
    puts "#{error.class}: #{error.message}"
  end
end

Now you can run this job whenever an invoice is created:

# app/models/invoice.rb
  after_create_commit do
    Invoices::ToPdfJob.perform(self)
  end

Inside the root folder of your app you will have a downloaded PDF! It will look more-less like this:

docraptor-local-storage-file-preview.png

4. FIX ERROR: File system access is not allowed. #

The DocRaptor API does not have access to these assets inside your localhost:3000 app by default:

# app/views/layouts/application.html.erb

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>

The official docs suggest using Ngrok.

My easiest solution: create a separate PDF layout that will not contain internal asset path.

<!-- app/views/layouts/pdf.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>DocraptorHtmlToPdf</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
  </head>
  <style>
    /* your inline CSS goes here */
  </style>
  <body>
    <%= yield %>
  </body>
</html>

5. Store PDFs in ActiveStorage #

Normally you will want to store generated PDFs in app/cloud storage (not local file storage). Let’s do it!

Install ActiveStorage:

rails active_storage:install
rails db:migrate

Declare the ActiveStorage association on the Invoice model:

# app/models/invoice.rb
  has_one_attached :pdf_document

Finally, instead of storing a file locally, upload it to ActiveStorage!

# app/jobs/invoices/to_pdf_job.rb
  # Save the PDF locally
-  File.write(filename, docraptor_api_response, mode: "wb")

  # Save in active storage
+ invoice.pdf_document.attach(io: StringIO.new(docraptor_api_response), filename: filename, content_type: 'application/pdf')

Now that we have a generated & attached PDF, we can:

To make PDF preview work, add gem image_processing:

# Gemfile
gem "image_processing", ">= 1.2"

Now we can display the attached PDF in our views:

# invoices/show.html.erb

# download pdf_document
link_to "Download", rails_blob_path(@invoice.pdf_document, disposition: "attachment")
# open pdf_document in browser
link_to "Download", rails_blob_path(@invoice.pdf_document, disposition: "inline")

# metadata
@invoice.pdf_document.representable?
@invoice.pdf_document.url
@invoice.pdf_document.blob.filename
@invoice.pdf_document.blob.content_type
number_to_human_size(@invoice.pdf_document.blob.byte_size)

# preview
image_tag @invoice.pdf_document.representation(resize_to_limit: [100, 100])
image_tag @invoice.pdf_document.preview(resize_to_limit: [100, 100])

Example image preview of an attached PDF with a link to download it:

<% if @invoice.pdf_document.attached? %>
  <%= link_to rails_blob_path(@invoice.pdf_document, disposition: "inline") do %>
    <% if @invoice.pdf_document.representable? %>
      <%= image_tag @invoice.pdf_document.representation(resize_to_limit: [200, 200]) %>
    <% end %>
    <br>
    <%= @invoice.pdf_document.blob.filename %>
    <%= number_to_human_size @invoice.pdf_document.blob.byte_size %>
  <% end %>
<% end %>

Will look like this:

docraptor-active-storage-show-attachment

Clicking the link will open the file:

docraptor-pdf-opened-inline

Amazing! What if we want to now email the generated PDF?

6. ActionMailer: Send PDF via emai #

# rails g mailer invoice created
# InvoiceMailer.created(@invoice).deliver_later
class InvoiceMailer < ApplicationMailer
  def created(invoice)
    @invoice = invoice

    # Attach the PDF to the email
    attachments["invoice.pdf"] = invoice.pdf_document.download if invoice.pdf_document.attached?

    mail(to: invoice.email, subject: 'Your invoice')
  end
end

Voila! Now your email will have an attached invoice PDF:

docraptor-email-preview

7. DocRaptor document hosting #

Are using ActiveStorage only for DocRaptor-generated documents?

You can host generated documents directly with DocRaptor and have fewer dependencies (no need for ActiveStorage, AWS S3…)

According to the docs:

  • .create_doc returns a pdf string
  • .create_hosted_doc returns a URL to the hosted document

So we simply replace create_doc with create_hosted_doc:

-  response = DocRaptor::DocApi.new.create_doc(
+  response = DocRaptor::DocApi.new.create_hosted_doc(
+  invoice.update(pdf_url: response.download_url)

We can add a new attribute like pdf_url to our Invoice and update it.

That’s it: now DocRaptor replaced both ActiveStorage and our cloud storage provider!

8. URL to PDF #

Instead of rendering an internal template, we can create pdf from any public url using document_url:

# # app/jobs/invoices/to_pdf_job.rb
- document_content: document_content,
+ document_url: 'https://blog.corsego.com/ruby-on-rails-developer-interview-questions',
+ document_url: invoice_url(@invoice),

docraptor-url-to-pdf

9. DocRaptor is not free? #

To remove the “TEST DOCUMENT” branding from the generated PDFs, we will need to register a DocRaptor account and get an API key:

docraptor-api-key

Use YOUR_API_KEY_HERE for development, and our real API key for production:

-  config.username = "YOUR_API_KEY_HERE" # THIS key works in test mode!
+  config.username = "EGergerVAEmkivmreVaerr-rgveW"

In a post-wicked_pdf world, I think Prince is the only easy HTML-to-PDF tool.

Prince is a well maintained technology that lets you perform very advanced PDF features.

Purchasing Prince directly is an expensive upfront payment investment:

princexml-pricing

Via DocRaptor, we can get “pay-as-you-go” access to:

  • Prince technology
  • a Ruby wrapper
  • document hosting

docraptor-pricing

So the price is between 12 cents and 2,5 cents per PDF document.

10. Final thoughts #

  • Most often in production you would generate a PDF for an important money-related event (ticket sold, order placed, contract signed, invoice issued). A PDF is the first thing you deliver after a successful online transaction - you want it to be a fulfilling experience for your customer…
  • I like the idea of outsourcing PDF generation and hosting.
  • We can try to decrease the costs by generate PDF only on demand (when the user clicks a link to “view pdf”).
  • When I return to building CertificateOwn (my certificate generation app), I will likely use DocRaptor.
  • Another thing I did not like about wkhtmltopdf is the giant build size (my Rails app on Heroku without it was 47MB, and with it was 150+MB)