Rendering JavaScript Charts in PDFs with wkhtmltopdf
In the previous post we set up wkhtmltopdf to generate PDFs from Rails views. That works well for text, tables, and basic layout — but what about charts? If your reports include graphs, you need them in the PDF too.
wkhtmltopdf includes a WebKit rendering engine that executes JavaScript. Chart.js renders to a <canvas> element, and wkhtmltopdf captures the canvas output. The chart renders during PDF generation the same way it would in a browser — no server-side image generation needed.
The Approach
- Include Chart.js directly in the PDF layout (not via the asset pipeline)
- Write inline
<script>blocks that create charts - Tell wkhtmltopdf to wait for JavaScript to finish before capturing the page
The key is the JavaScript wait. wkhtmltopdf renders the page, executes scripts, and then converts to PDF. If the chart hasn’t finished rendering by the time wkhtmltopdf captures, you get a blank canvas.
Update the PDF Layout
app/views/layouts/pdf.html.erb:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<%= wicked_pdf_stylesheet_link_tag "pdf" %>
<%= wicked_pdf_javascript_include_tag "pdf_charts" %>
</head>
<body>
<%= yield %>
</body>
</html>
app/assets/javascripts/pdf_charts.js:
// Bundle Chart.js for PDF rendering
//= require chart.js/dist/chart.umd.js
Or download Chart.js and place it directly in your assets. The important thing is that it’s available via wicked_pdf_javascript_include_tag, which converts the path to a file:// URL that wkhtmltopdf can load.
A Report with Charts
app/views/reports/show.html.erb:
<h1><%= @report.title %></h1>
<p class="text-muted"><%= @report.date_range %></p>
<div style="display: flex; gap: 40px; margin-bottom: 40px;">
<div style="flex: 1;">
<h2>Revenue by Month</h2>
<canvas id="revenueChart" width="400" height="300"></canvas>
</div>
<div style="flex: 1;">
<h2>Sales by Category</h2>
<canvas id="categoryChart" width="400" height="300"></canvas>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Line chart — Revenue by Month
new Chart(document.getElementById("revenueChart"), {
type: "line",
data: {
labels: <%= raw @report.months.to_json %>,
datasets: [{
label: "Revenue",
data: <%= raw @report.monthly_revenue.to_json %>,
borderColor: "#0d6efd",
backgroundColor: "rgba(13, 110, 253, 0.1)",
fill: true,
tension: 0.3
}]
},
options: {
animation: false,
responsive: false,
plugins: { legend: { display: false } },
scales: {
y: {
ticks: {
callback: function(value) {
return "$" + value.toLocaleString()
}
}
}
}
}
});
// Doughnut chart — Sales by Category
new Chart(document.getElementById("categoryChart"), {
type: "doughnut",
data: {
labels: <%= raw @report.categories.to_json %>,
datasets: [{
data: <%= raw @report.category_totals.to_json %>,
backgroundColor: ["#0d6efd", "#198754", "#ffc107", "#dc3545", "#6f42c1"]
}]
},
options: {
animation: false,
responsive: false,
plugins: {
legend: { position: "bottom" }
}
}
});
});
</script>
<div class="page-break"></div>
<h2>Detail</h2>
<table>
<thead>
<tr>
<th>Month</th>
<th>Category</th>
<th class="text-right">Revenue</th>
<th class="text-right">Orders</th>
</tr>
</thead>
<tbody>
<% @report.line_items.each do |item| %>
<tr>
<td><%= item.month %></td>
<td><%= item.category %></td>
<td class="text-right"><%= number_to_currency item.revenue %></td>
<td class="text-right"><%= item.orders %></td>
</tr>
<% end %>
</tbody>
</table>
Two critical options in the chart config:
animation: false— Disables the draw animation. wkhtmltopdf captures the canvas at a point in time. If the animation is still running, you get a partially drawn chart or nothing at all.responsive: false— Prevents Chart.js from trying to resize based on the container. In a PDF context, the container dimensions are fixed. Let thewidthandheightattributes on the<canvas>control the size.
Configure wkhtmltopdf to Wait
Tell wkhtmltopdf to wait for JavaScript execution:
def show
@report = Report.find(params[:id])
respond_to do |format|
format.html
format.pdf do
render pdf: "report_#{@report.id}",
template: "reports/show",
layout: "pdf",
javascript_delay: 1000,
no_stop_slow_scripts: true,
disable_javascript: false
end
end
end
javascript_delay: 1000 waits 1 second after the page loads before converting to PDF. This gives Chart.js time to parse the data and render the canvas. For simple charts, 500ms is usually enough. For pages with multiple charts or large datasets, increase to 1500–2000ms.
no_stop_slow_scripts: true prevents wkhtmltopdf from killing scripts that take longer than expected.
A Reusable Chart Partial
Extract repeated chart markup into a partial:
app/views/components/_pdf_chart.html.erb:
<div style="margin-bottom: 30px;">
<% if local_assigns[:title] %>
<h3><%= title %></h3>
<% end %>
<canvas id="<%= chart_id %>"
width="<%= local_assigns[:width] || 500 %>"
height="<%= local_assigns[:height] || 300 %>">
</canvas>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
new Chart(document.getElementById("<%= chart_id %>"), <%= raw config.to_json %>);
});
</script>
Use it from the report view:
<%= render partial: "components/pdf_chart", locals: {
chart_id: "revenue",
title: "Revenue by Month",
config: {
type: "line",
data: {
labels: @report.months,
datasets: [{
label: "Revenue",
data: @report.monthly_revenue,
borderColor: "#0d6efd",
fill: false
}]
},
options: {
animation: false,
responsive: false,
plugins: { legend: { display: false } }
}
}
} %>
Build the chart config in Ruby as a hash and let to_json handle serialization. This keeps the view clean and makes it easy to build configs dynamically from database data.
Fallback: Server-Side Image Generation
If you hit rendering issues with wkhtmltopdf’s JavaScript engine, the fallback is to pre-render charts as images on the server. Use a headless browser to capture the chart canvas:
# Using Ferrum (headless Chrome)
require "ferrum"
class ChartRenderer
def self.render_to_png(chart_html, width: 600, height: 400)
browser = Ferrum::Browser.new(headless: true)
page = browser.create_page
page.set_content(chart_html)
page.network.wait_for_idle
png = page.screenshot(format: "png", full: true)
browser.quit
png
end
end
Then embed the resulting PNG in the PDF template with wicked_pdf_image_tag. This is more complex but gives you full control over rendering.
Tips
- Always set
animation: false. This is the most common cause of blank charts in PDFs. The chart is mid-animation when wkhtmltopdf captures the page. - Use fixed canvas dimensions. Set
widthandheighton the<canvas>element and setresponsive: false. Don’t rely on CSS sizing in the PDF context. - Test with
javascript_delay. Start at 500ms and increase if charts don’t render. Check the PDF output — if you see empty canvases, the delay is too short. - Keep chart data small. Large datasets with thousands of points render slowly in wkhtmltopdf’s older WebKit engine. Aggregate data on the server to keep chart data under a few hundred points.
- Color printing. wkhtmltopdf respects CSS
@media printrules. If your charts look faded, check that you’re not applying print-specific styles that reduce color saturation. - Chart.js version. wkhtmltopdf’s JavaScript engine is older. Stick with Chart.js 3.x or 4.x. If you hit syntax errors, the Chart.js version may be using features the engine doesn’t support.