Let’s start from the obvious. If you using WickedPDF in 2023 it’s a little bit more problematic than just having issues with PDF generation from time to time. It means that you are building your PDF markups with tables, not using modern CSS abilities like flex and grids. It must be hard. But probably it’s because dealing with PDFs was difficult the last time you had to do it. But no matter what, the time has come. You need to up your PDF game. wkhtmltopdf is deprecated. Let’s go over your options.

Wicked PDF

So for our example, let’s consider that your app PDF controller action looks more or less like this:

respond_to do |format|
  format.pdf do
    pdf_html = ApplicationController.new.render_to_string(  
      template: 'templates/pdfs/my_pdf',  
      formats: [:pdf],  
      layout: 'layouts/pdf',  
      assigns: { model: @model }, 
      encoding: 'UTF-8'  
    )  
      
    pdf = WickedPdf.new.pdf_from_string(
      pdf_html,  
      page_size: 'Letter',  
      dpi: 96,  
      margin: {  
        left: 0,  
        right: 0,  
        top: 40,  
        bottom: 20  
      },  
      header: {  
        content: header_html,  
        spacing: 10  
      },  
      footer: {  
        left: "",  
        right: 'Page [page] of [topage]',  
        font_size: 8,  
        spacing: 10  
      }
    ) 
    send_data(pdf, filename: 'test.pdf', type: 'application/pdf', disposition: 'inline')
  end
end  	

Grover

grover feels almost like drop-in replacement for your WickedPDF. We only need to replace pdf generation code with something like that:

pdf = Grover.new(
  pdf_html,  
  format: 'Letter',  
  margin: {  
      left: 0,  
      right: 0,  
      top: '40px',  
      bottom: '10px'  
  },  
  headerTemplate: "Header",  
  footerTemplate: "Page [pageNumber] of [totalPages]"
).to_pdf

Ferrum

Grover is good, but it require you to have puppeteer in the system. What if you using importmaps and it makes no sense to include npm in your project for puppeteer only. That is where Ferrum shines. Let’s take a look at this approach:

Ferrum::Browser.new(timeout: 7).tap do |browser|  
  browser.content = html  
  begin  
    browser.evaluate_async(%(addEventListener('load', arguments[0]);), browser.timeout)  
  rescue StandardError  
    nil  
  end  
  browser.network.wait_for_idle  
  pdf = browser.pdf(  
    format: :letter,  
    encoding: :binary,  
    margin_left: 0,  
    margin_right: 0,  
    margin_top: 0,  
    margin_bottom: 0,  
    print_background: true  
  )  
  browser&.reset  
  browser&.quit  
  send_data(pdf, filename: 'test.pdf', type: 'application/pdf', disposition: 'inline')  
end

One important thing to mention for all of these examples, is that if you are importing css into your pdf layouts, you probably still like to keep wicked_pdf gem in your gem file for a bunch of magic around that. I honestly do not know, why Rails Core team keeps neglecting these things in Asset Pipeline API, that is obviously needed in both PDF and Mail complex scenarios, but only provided here and as is. Maybe I missing something.

There are also some half-cooked approach like this that can replace that code:

def inline_asset(asset_name)  
  "<style type='text/css'>#{Rails.application.assets.find_asset(asset_name).to_s}</style>".html_safe  
end

Place it in ApplicationHelper and you good to go.

Lambda Function

If you already utilizing AWS Lambda you can go in a different direction, and try to spin your headless Chrome there:

"use strict";

const chromium = require('chrome-aws-lambda');
const Sentry = require("@sentry/node");
Sentry.init({ dsn: 'https://...@sentry.io/...' });

module.exports.pdf = async (event, context) => {
  let browser = null;
  let response = null;
  const  body = JSON.parse(event.body);
  const { html } = body;
  const { options = { format: 'Letter', margin: { top: '0', bottom: '0', left: '0', right: '0' } } } = body;
  const { remoteContent = true } = body;
  const { emulateMedia = 'print' } = body;

  try {
    browser = await chromium.puppeteer.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath,
      headless: true,
    });
    const page = await browser.newPage();
    await page.emulateMedia(emulateMedia);
    
    if (remoteContent === true) {
      await page.goto(`data:text/html;base64,${Buffer.from(html).toString('base64')}`, {
          waitUntil: 'networkidle0'
      });
    } else {
        //page.setContent will be faster than page.goto if html is a static
        await page.setContent(html);
    }
    const pdfStream = await page.pdf(options);
    response = {
      statusCode: 200,
      headers: {
        "Content-type": "application/pdf",
        'content-disposition': 'attachment;'
      },
      body: pdfStream.toString("base64"),
      isBase64Encoded: true
    };
  } catch (error) {
    Sentry.captureException(error);
    await Sentry.flush(2000);
    return context.fail(error);
  } finally {
    if (browser !== null) {
      await browser.close();
    }
  }
  return context.succeed(response);
};

Additional details can be found on package site. And for Rails client to that:

class PdfLambda
  include HTTParty
  base_uri ENV.fetch('PDF_LAMBDA_URL')
  debug_output $stderr if ENV.fetch('DEBUG_PDF_LAMBDA', false)

  attr_accessor :raw_html, :filename, :options, :remote_content, :wait_for_fonts

  def initialize(raw_html, filename, options)
    self.raw_html = raw_html
    self.filename = filename
    self.options = options
    self.remote_content = options.delete(:remote_content) || false
    self.wait_for_fonts = options.delete(:waitForFonts) || false
    return if File.exist?(Rails.root.join('tmp/pdfs'))

    FileUtils.mkdir(Rails.root.join('tmp/pdfs'))
  end

  def generate_file
    response = nil
    File.open(filepath, 'wb') do |file|
      response = self.class.post('/',
                                 body: pdf_options,
                                 headers: headers,
                                 stream_body: true) do |fragment|
        if fragment.code == 200
          file.write(fragment)
        elsif [301, 302].exclude?(fragment.code)
          raise StandardError, "Non-success status code while streaming #{fragment.code}"
        end
      end
    end
    response&.success? ? filepath : false
  end

  def filepath
    Rails.root.join('tmp/pdfs', filename)
  end

  def pdf_options
    {
      html: raw_html,
      remoteContent: remote_content,
      waitForFonts: wait_for_fonts,
      options: {
        format: 'Letter', margin: { top: '0.1in', bottom: '70', left: '0.25in', right: '0.25in' }
      }.merge(options)
    }.to_json
  end

  def headers
    {
      'Accept-Version': '',
      'x-api-key': ENV['PDF_LAMDBA_API_KEY'],
      Accept: 'application/pdf'
    }
  end
end

and it can be used like that in our code:

format.pdf do
  pdf_html = ApplicationController.new.render_to_string(  
    template: 'templates/pdfs/my_pdf',  
    formats: [:pdf],  
    layout: 'layouts/pdf',  
    assigns: { model: @model }, 
    encoding: 'UTF-8'  
  )
  pdf_lambda = PdfLambda.new(
    pdf_html, 'test.pdf',  
    {  
      displayHeaderFooter: true,  
      printBackground: true,  
      waitForFonts: true,  
      footerTemplate: <<~TEMPLATE  
<span class="pageNumber"></span>/<span class="totalPages"></span>
TEMPLATE  
	}
)  
  pdf_lambda.generate_file
  File.open(pdf_path, 'r') do |pdf|  
    send_data pdf.read,  
              type: 'application/pdf',  
              disposition: 'inline',  
              filename: filename  
  end  
  File.delete(pdf_path)  
end