WkhtmlToPDF is dead. Long live WkhtmlToPDF
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