Currently Available: Need a skilled Software Developer for your next project?
Categories
Laravel

Creating PDFs with Chart.js charts in Laravel using wkhtmltopdf

Rendering Chart.js charts into PDFs with wkhtmltopdf can be surprisingly tricky. wkhtmltopdf uses QtWebKit (an older browser engine), while Chart.js draws on an HTML5 canvas and often assumes modern browser APIs. This post shows a robust, production-ready approach to make them play nicely together in a Laravel app.

Why this is hard

wkhtmltopdf runs an old WebKit that's missing or has buggy JS features like Function.prototype.bind and CanvasRenderingContext2D.setLineDash. Canvas timing is another issue since charts may render after wkhtmltopdf snapshots the page. Responsive and animated charts cause additional problems, often resulting in blank or partial renders due to animations and fluid sizing.

What works

Use wkhtmltopdf 0.12.6 or higher with patched Qt. Older versions are prone to Chart.js issues and crashes. Chart.js 2.9.x works best for compatibility with QtWebKit. You need to disable animations and responsiveness in Chart.js and fix the canvas size explicitly using both width/height attributes and CSS.

Apply two small polyfills (shown below) to fix compatibility issues. Signal wkhtmltopdf when JS is done via window.status combined with the wkhtml "window-status" option. For extra reliability, replace the canvas with a static PNG using canvas.toDataURL just before printing, since wkhtmltopdf captures images more reliably than live canvas.

Example

HTML/Blade view with Chart.js and polyfills

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <!-- Use Chart.js 2.9.x for compatibility -->
  <script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script>
  <style>
    .chart-wrap { width: 900px; height: 420px; margin: 0 auto; }
  </style>
</head>
<body>
  <h2>Monthly Signups</h2>

  <div class="chart-wrap">
    <!-- Fixed size helps wkhtmltopdf capture correctly -->
    <canvas id="reportChart" width="900" height="420" style="width:900px;height:420px;"></canvas>
    <!-- We'll swap the canvas for this image right before printing -->
    <img id="reportChartImage" alt="chart" style="display:none;width:900px;height:420px;" />
  </div>

  <!-- wkhtmltopdf compatibility polyfills -->
  <script>
    (function(setLineDash){
      if (setLineDash) {
        CanvasRenderingContext2D.prototype.setLineDash = function(){
          if (!arguments[0] || !arguments[0].length) { arguments[0] = [1,0]; }
          return setLineDash.apply(this, arguments);
        };
      }
    })(CanvasRenderingContext2D && CanvasRenderingContext2D.prototype ? CanvasRenderingContext2D.prototype.setLineDash : null);

    Function.prototype.bind = Function.prototype.bind || function (thisp) {
      var fn = this;
      return function () { return fn.apply(thisp, arguments); };
    };
  </script>

  <script>
    (function() {
      function render() {
        try {
          var canvas = document.getElementById('reportChart');
          if (!canvas) { window.status = 'readyToPrint'; return; }

          var ctx = canvas.getContext('2d');
          var config = {
            type: 'bar',
            data: {
              labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
              datasets: [{
                label: 'Signups',
                data: [12, 32, 18, 27, 33, 21],
                backgroundColor: '#2E7D32',
                borderColor: '#2E7D32',
                fill: true
              }]
            },
            options: {
              responsive: false,
              maintainAspectRatio: false,
              devicePixelRatio: 1,
              animation: { duration: 0 },
              hover: { animationDuration: 0 },
              responsiveAnimationDuration: 0,
              legend: { display: true },
              title: { display: false }
            }
          };

          var chart = new Chart(ctx, config);

          // Replace canvas with image for rock-solid capture
          setTimeout(function(){
            try {
              var img = document.getElementById('reportChartImage');
              if (img) {
                img.src = canvas.toDataURL('image/png');
                img.style.display = 'block';
                canvas.parentNode && canvas.parentNode.removeChild(canvas);
              }
            } catch (ignore) {}
            // Tell wkhtmltopdf we're done
            window.status = 'readyToPrint';
          }, 100);
        } catch (e) {
          window.status = 'readyToPrint';
        }
      }

      if (document.readyState === 'complete' || document.readyState === 'interactive') {
        render();
      } else {
        window.addEventListener('load', render);
      }
    })();
  </script>
</body>
</html>

wkhtmltopdf command

wkhtmltopdf \
  --enable-javascript \
  --javascript-delay 4000 \
  --window-status readyToPrint \
  --no-stop-slow-scripts \
  --enable-local-file-access \
  http://localhost/example-report.html out.pdf

These options do the following:

  • enable-javascript: allow JS execution
  • javascript-delay: fallback wait window (ms)
  • window-status: wait for window.status === 'readyToPrint'
  • no-stop-slow-scripts: prevent early termination
  • enable-local-file-access: allow local assets if needed

Laravel controller

public function exportPdf()
{
    // Render a view that inlines Chart.js and the polyfills above
    $html = view('reports.pdf-example', [/* your data */])->render();

    // Example using Snappy (wkhtmltopdf)
    $pdf = \PDF::loadHTML($html)
        ->setOption('enable-javascript', true)
        ->setOption('no-stop-slow-scripts', true)
        ->setOption('enable-local-file-access', true)
        ->setOption('javascript-delay', 4000)
        ->setOption('window-status', 'readyToPrint')
        ->setOption('page-size', 'A4')
        ->setOption('orientation', 'Portrait');

    return $pdf->download('report.pdf');
}

Troubleshooting

Blank chart
Ensure window.status is set to 'readyToPrint' after rendering. Increase --javascript-delay if data loading is slow. Verify fixed sizes: both canvas width/height attributes and CSS.

Segmentation fault or crash (0.12.4/0.12.5)
Use the setLineDash polyfill shown above or upgrade to 0.12.6+.

Blurry charts
Increase the canvas size (e.g., 1200×560) and allow downscale in CSS. Keep devicePixelRatio = 1 for consistency, or render at higher logical size before toDataURL.

Chart.js v3+
QtWebKit lacks ES features used by v3. Prefer v2.9.x with wkhtmltopdf. Alternative: switch to a headless Chromium renderer (e.g., Puppeteer) or an SVG charting library.

External assets blocked
Use --enable-local-file-access and inline assets where possible.

Production checklist

For production deployments, ensure you have wkhtmltopdf 0.12.6 or higher with patched Qt and Chart.js 2.9.x. Disable animations and responsiveness, use fixed canvas size with both attributes and CSS, and include the Function.prototype.bind and setLineDash polyfills. Replace the canvas with PNG via canvas.toDataURL before printing. Use window-status and a sensible javascript-delay. Inline assets or allow local asset access.

With these practices, Chart.js charts render consistently and clearly in PDFs generated by wkhtmltopdf without resorting to vendor-specific hacks or fragile timing tricks.

What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Subscribe to my newsletter

Get new posts when I publish them.

I respect your privacy. Unsubscribe at any time.

Leave a Reply

Your email address will not be published. Required fields are marked *