← All posts

Rendering JavaScript Charts in PDFs with wkhtmltopdf

July 7, 2022 rails

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

  1. Include Chart.js directly in the PDF layout (not via the asset pipeline)
  2. Write inline <script> blocks that create charts
  3. 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 the width and height attributes 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 width and height on the <canvas> element and set responsive: 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 print rules. 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.
Let’s work together! Tell Me About Your Project