reportgen
Back to all posts

Dynamic Charts and Visualizations in PDFs: From Data to Beautiful Reports šŸ“Š

September 25, 2025

You're generating a quarterly report PDF. Full of numbers.

Your users open it. See a wall of tables. Their eyes glaze over.

What they really need? Visual data.

  • Bar charts showing growth trends
  • Pie charts for market share
  • Line graphs tracking revenue
  • Heatmaps highlighting patterns

But embedding charts in PDFs? That's where most developers give up.

This guide shows you exactly how to generate beautiful charts and embed them in PDFs - with multiple approaches, real code, and production-ready examples.


The Chart Generation Challenge

Why Charts in PDFs Are Tricky

Problem 1: PDFs are static documents

  • No JavaScript execution
  • Can't use interactive chart libraries (Chart.js, D3.js) directly
  • Need to generate images server-side

Problem 2: Image quality matters

  • Charts must be crisp when printed
  • Need high DPI for professional output
  • File size vs. quality tradeoff

Problem 3: Data freshness

  • Charts need to reflect real-time data
  • Can't pre-generate everything
  • Must render on-demand

The Solution: Server-side chart rendering to images, then embed in PDF.


Approach 1: Chart.js with Node Canvas

Setup

npm install chart.js chartjs-node-canvas

Basic Chart Generation

import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
 
const width = 800;
const height = 400;
 
const chartCallback = (ChartJS) => {
  // Configure Chart.js defaults
  ChartJS.defaults.color = '#374151';
  ChartJS.defaults.font.family = "'Inter', 'Arial', sans-serif";
  ChartJS.defaults.font.size = 12;
};
 
const chartJSNodeCanvas = new ChartJSNodeCanvas({
  width,
  height,
  chartCallback,
  backgroundColour: 'white'
});
 
async function generateBarChart(data) {
  const configuration = {
    type: 'bar',
    data: {
      labels: data.labels,
      datasets: [{
        label: 'Revenue',
        data: data.values,
        backgroundColor: '#3b82f6',
        borderColor: '#2563eb',
        borderWidth: 1
      }]
    },
    options: {
      responsive: true,
      plugins: {
        title: {
          display: true,
          text: 'Monthly Revenue',
          font: { size: 18, weight: 'bold' }
        },
        legend: {
          display: true,
          position: 'bottom'
        }
      },
      scales: {
        y: {
          beginAtZero: true,
          ticks: {
            callback: function(value) {
              return '$' + value.toLocaleString();
            }
          }
        }
      }
    }
  };
 
  // Render to buffer
  const imageBuffer = await chartJSNodeCanvas.renderToBuffer(configuration);
 
  return imageBuffer;
}
 
// Usage
const revenueData = {
  labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
  values: [12000, 19000, 15000, 25000, 22000, 30000]
};
 
const chartImage = await generateBarChart(revenueData);
 
// Convert to base64 for embedding in HTML
const base64Chart = chartImage.toString('base64');
 
const html = `
<img src="data:image/png;base64,${base64Chart}"
     alt="Bar chart showing monthly revenue from January to June"
     width="800" height="400">
`;

Line Chart with Multiple Datasets

async function generateLineChart(data) {
  const configuration = {
    type: 'line',
    data: {
      labels: data.months,
      datasets: [
        {
          label: 'Revenue',
          data: data.revenue,
          borderColor: '#3b82f6',
          backgroundColor: 'rgba(59, 130, 246, 0.1)',
          tension: 0.4,
          fill: true
        },
        {
          label: 'Expenses',
          data: data.expenses,
          borderColor: '#ef4444',
          backgroundColor: 'rgba(239, 68, 68, 0.1)',
          tension: 0.4,
          fill: true
        },
        {
          label: 'Profit',
          data: data.profit,
          borderColor: '#10b981',
          backgroundColor: 'rgba(16, 185, 129, 0.1)',
          tension: 0.4,
          fill: true
        }
      ]
    },
    options: {
      responsive: true,
      plugins: {
        title: {
          display: true,
          text: 'Financial Overview - Q1 2025',
          font: { size: 20, weight: 'bold' }
        },
        legend: {
          display: true,
          position: 'bottom'
        }
      },
      scales: {
        y: {
          beginAtZero: true,
          ticks: {
            callback: (value) => '$' + value.toLocaleString()
          }
        }
      }
    }
  };
 
  return await chartJSNodeCanvas.renderToBuffer(configuration);
}
 
// Usage
const financialData = {
  months: ['January', 'February', 'March'],
  revenue: [50000, 65000, 75000],
  expenses: [35000, 40000, 45000],
  profit: [15000, 25000, 30000]
};
 
const lineChart = await generateLineChart(financialData);

Pie Chart

async function generatePieChart(data) {
  const configuration = {
    type: 'pie',
    data: {
      labels: data.labels,
      datasets: [{
        data: data.values,
        backgroundColor: [
          '#3b82f6', // Blue
          '#ef4444', // Red
          '#10b981', // Green
          '#f59e0b', // Amber
          '#8b5cf6'  // Purple
        ],
        borderWidth: 2,
        borderColor: '#ffffff'
      }]
    },
    options: {
      responsive: true,
      plugins: {
        title: {
          display: true,
          text: 'Market Share by Product',
          font: { size: 18, weight: 'bold' }
        },
        legend: {
          display: true,
          position: 'right'
        },
        tooltip: {
          callbacks: {
            label: function(context) {
              const label = context.label || '';
              const value = context.parsed;
              const total = context.dataset.data.reduce((a, b) => a + b, 0);
              const percentage = ((value / total) * 100).toFixed(1);
              return `${label}: ${percentage}%`;
            }
          }
        }
      }
    }
  };
 
  return await chartJSNodeCanvas.renderToBuffer(configuration);
}
 
// Usage
const marketShareData = {
  labels: ['Product A', 'Product B', 'Product C', 'Product D', 'Product E'],
  values: [30, 25, 20, 15, 10]
};
 
const pieChart = await generatePieChart(marketShareData);

Approach 2: QuickChart API (No Server-Side Rendering)

Why QuickChart?

Pros:

  • No server-side dependencies
  • Simple URL-based API
  • Supports Chart.js configuration
  • Free tier available

Cons:

  • External dependency
  • Rate limits on free tier
  • Privacy concerns (data sent to third party)

Basic Usage

function generateQuickChartUrl(config) {
  const chartConfig = encodeURIComponent(JSON.stringify(config));
  return `https://quickchart.io/chart?c=${chartConfig}&width=800&height=400&backgroundColor=white`;
}
 
const chartConfig = {
  type: 'bar',
  data: {
    labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
    datasets: [{
      label: 'Sales',
      data: [12, 19, 15, 25, 22, 30],
      backgroundColor: '#3b82f6'
    }]
  }
};
 
const chartUrl = generateQuickChartUrl(chartConfig);
 
const html = `
<img src="${chartUrl}" alt="Monthly sales bar chart" width="800" height="400">
`;

For production use: Cache the image or self-host QuickChart.


Approach 3: D3.js with JSDOM (Advanced)

Setup

npm install d3 jsdom canvas

Generate Custom Visualizations

import * as d3 from 'd3';
import { JSDOM } from 'jsdom';
import { createCanvas } from 'canvas';
 
async function generateD3Chart(data) {
  const width = 800;
  const height = 400;
  const margin = { top: 20, right: 20, bottom: 30, left: 50 };
 
  // Create virtual DOM
  const dom = new JSDOM('<!DOCTYPE html><body></body>');
  const body = d3.select(dom.window.document).select('body');
 
  // Create SVG
  const svg = body.append('svg')
    .attr('width', width)
    .attr('height', height)
    .attr('xmlns', 'http://www.w3.org/2000/svg');
 
  const g = svg.append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);
 
  const innerWidth = width - margin.left - margin.right;
  const innerHeight = height - margin.top - margin.bottom;
 
  // Scales
  const x = d3.scaleBand()
    .domain(data.map(d => d.label))
    .range([0, innerWidth])
    .padding(0.2);
 
  const y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.value)])
    .range([innerHeight, 0]);
 
  // X-axis
  g.append('g')
    .attr('transform', `translate(0,${innerHeight})`)
    .call(d3.axisBottom(x));
 
  // Y-axis
  g.append('g')
    .call(d3.axisLeft(y));
 
  // Bars
  g.selectAll('.bar')
    .data(data)
    .enter().append('rect')
    .attr('class', 'bar')
    .attr('x', d => x(d.label))
    .attr('y', d => y(d.value))
    .attr('width', x.bandwidth())
    .attr('height', d => innerHeight - y(d.value))
    .attr('fill', '#3b82f6');
 
  // Get SVG string
  const svgString = body.html();
 
  return svgString;
}
 
// Usage
const chartData = [
  { label: 'Q1', value: 50000 },
  { label: 'Q2', value: 65000 },
  { label: 'Q3', value: 75000 },
  { label: 'Q4', value: 90000 }
];
 
const svgChart = await generateD3Chart(chartData);
 
// Embed SVG directly in HTML
const html = `
<div style="width: 800px; height: 400px;">
  ${svgChart}
</div>
`;

Complete Report Example with Multiple Charts

import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
 
async function generateQuarterlyReport(reportData) {
  const chartRenderer = new ChartJSNodeCanvas({
    width: 800,
    height: 400,
    backgroundColour: 'white'
  });
 
  // Generate revenue chart
  const revenueChart = await chartRenderer.renderToBuffer({
    type: 'line',
    data: {
      labels: reportData.months,
      datasets: [{
        label: 'Revenue',
        data: reportData.revenue,
        borderColor: '#3b82f6',
        backgroundColor: 'rgba(59, 130, 246, 0.1)',
        tension: 0.4,
        fill: true
      }]
    },
    options: {
      responsive: true,
      plugins: {
        title: {
          display: true,
          text: 'Revenue Trend',
          font: { size: 18, weight: 'bold' }
        }
      }
    }
  });
 
  // Generate market share chart
  const marketShareChart = await chartRenderer.renderToBuffer({
    type: 'doughnut',
    data: {
      labels: reportData.products,
      datasets: [{
        data: reportData.marketShare,
        backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']
      }]
    },
    options: {
      responsive: true,
      plugins: {
        title: {
          display: true,
          text: 'Market Share by Product',
          font: { size: 18, weight: 'bold' }
        }
      }
    }
  });
 
  // Generate regional sales chart
  const regionalSalesChart = await chartRenderer.renderToBuffer({
    type: 'bar',
    data: {
      labels: reportData.regions,
      datasets: [{
        label: 'Sales',
        data: reportData.regionalSales,
        backgroundColor: '#10b981'
      }]
    },
    options: {
      responsive: true,
      indexAxis: 'y', // Horizontal bar chart
      plugins: {
        title: {
          display: true,
          text: 'Sales by Region',
          font: { size: 18, weight: 'bold' }
        }
      }
    }
  });
 
  // Convert to base64
  const revenueChartBase64 = revenueChart.toString('base64');
  const marketShareChartBase64 = marketShareChart.toString('base64');
  const regionalSalesChartBase64 = regionalSalesChart.toString('base64');
 
  // Build HTML
  const html = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Quarterly Report - Q3 2025</title>
  <style>
    body {
      font-family: 'Inter', Arial, sans-serif;
      line-height: 1.6;
      color: #1f2937;
      max-width: 1000px;
      margin: 0 auto;
      padding: 40px;
    }
 
    h1 {
      font-size: 32px;
      font-weight: 700;
      color: #111827;
      margin-bottom: 10px;
    }
 
    h2 {
      font-size: 24px;
      font-weight: 600;
      color: #374151;
      margin-top: 40px;
      margin-bottom: 20px;
      padding-bottom: 10px;
      border-bottom: 2px solid #e5e7eb;
    }
 
    .metric-card {
      display: inline-block;
      width: 30%;
      background: #f9fafb;
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      padding: 20px;
      margin-right: 3%;
      margin-bottom: 20px;
      vertical-align: top;
    }
 
    .metric-card:nth-child(3n) {
      margin-right: 0;
    }
 
    .metric-label {
      font-size: 12px;
      color: #6b7280;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      margin-bottom: 8px;
    }
 
    .metric-value {
      font-size: 28px;
      font-weight: 700;
      color: #111827;
    }
 
    .metric-change {
      font-size: 14px;
      margin-top: 4px;
    }
 
    .positive {
      color: #10b981;
    }
 
    .negative {
      color: #ef4444;
    }
 
    .chart-container {
      margin: 30px 0;
      page-break-inside: avoid;
    }
 
    .chart-container img {
      width: 100%;
      height: auto;
      display: block;
    }
 
    .page-break {
      page-break-after: always;
    }
  </style>
</head>
<body>
  <header>
    <h1>Quarterly Business Report</h1>
    <p style="color: #6b7280; font-size: 16px;">Q3 2025 Performance Summary</p>
  </header>
 
  <section>
    <h2>Key Metrics</h2>
 
    <div class="metric-card">
      <div class="metric-label">Total Revenue</div>
      <div class="metric-value">$${reportData.totalRevenue.toLocaleString()}</div>
      <div class="metric-change positive">↑ ${reportData.revenueGrowth}% vs Q2</div>
    </div>
 
    <div class="metric-card">
      <div class="metric-label">Active Customers</div>
      <div class="metric-value">${reportData.activeCustomers.toLocaleString()}</div>
      <div class="metric-change positive">↑ ${reportData.customerGrowth}% vs Q2</div>
    </div>
 
    <div class="metric-card">
      <div class="metric-label">Profit Margin</div>
      <div class="metric-value">${reportData.profitMargin}%</div>
      <div class="metric-change ${reportData.marginChange >= 0 ? 'positive' : 'negative'}">
        ${reportData.marginChange >= 0 ? '↑' : '↓'} ${Math.abs(reportData.marginChange)}% vs Q2
      </div>
    </div>
  </section>
 
  <section>
    <h2>Revenue Analysis</h2>
    <div class="chart-container">
      <img src="data:image/png;base64,${revenueChartBase64}"
           alt="Line chart showing revenue trend over the quarter">
    </div>
    <p>Revenue grew consistently throughout Q3, with particularly strong performance in September.</p>
  </section>
 
  <div class="page-break"></div>
 
  <section>
    <h2>Product Performance</h2>
    <div class="chart-container">
      <img src="data:image/png;base64,${marketShareChartBase64}"
           alt="Doughnut chart showing market share distribution by product">
    </div>
    <p>Product A continues to lead with 35% market share, while Product C shows promising growth.</p>
  </section>
 
  <section>
    <h2>Regional Sales</h2>
    <div class="chart-container">
      <img src="data:image/png;base64,${regionalSalesChartBase64}"
           alt="Horizontal bar chart showing sales performance by region">
    </div>
    <p>North America remains our strongest region, with EMEA showing significant quarter-over-quarter growth.</p>
  </section>
</body>
</html>
  `;
 
  // Generate PDF
  const pdfBuffer = await generatePDF(html);
 
  return pdfBuffer;
}
 
// Usage
const reportData = {
  months: ['July', 'August', 'September'],
  revenue: [85000, 92000, 105000],
  totalRevenue: 282000,
  revenueGrowth: 12,
  activeCustomers: 1547,
  customerGrowth: 8,
  profitMargin: 28,
  marginChange: 3,
  products: ['Product A', 'Product B', 'Product C', 'Product D', 'Product E'],
  marketShare: [35, 25, 20, 12, 8],
  regions: ['North America', 'EMEA', 'APAC', 'Latin America'],
  regionalSales: [120000, 85000, 52000, 25000]
};
 
const pdf = await generateQuarterlyReport(reportData);

Performance Optimization for Charts

1. Cache Chart Images

import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
async function getOrGenerateChart(cacheKey, generateFn) {
  // Try cache first
  const cached = await redis.getBuffer(cacheKey);
  if (cached) {
    return cached;
  }
 
  // Generate chart
  const chartBuffer = await generateFn();
 
  // Cache for 1 hour
  await redis.setex(cacheKey, 3600, chartBuffer);
 
  return chartBuffer;
}
 
// Usage
const chartBuffer = await getOrGenerateChart(
  `chart:revenue:${year}-${quarter}`,
  () => generateRevenueChart(data)
);

2. Generate Charts in Parallel

async function generateReportWithCharts(data) {
  // Generate all charts in parallel
  const [revenueChart, pieChart, barChart] = await Promise.all([
    generateRevenueChart(data.revenue),
    generatePieChart(data.marketShare),
    generateBarChart(data.regionalSales)
  ]);
 
  // Build HTML with all charts
  const html = buildReportHTML({
    revenueChart: revenueChart.toString('base64'),
    pieChart: pieChart.toString('base64'),
    barChart: barChart.toString('base64')
  });
 
  return html;
}

3. Optimize Image Size

import sharp from 'sharp';
 
async function optimizeChartImage(chartBuffer) {
  return sharp(chartBuffer)
    .png({ quality: 80, compressionLevel: 9 })
    .toBuffer();
}
 
// After generating chart
const chartBuffer = await generateBarChart(data);
const optimizedChart = await optimizeChartImage(chartBuffer);
 
// 30-50% smaller file size

Advanced Chart Types

Stacked Bar Chart

const stackedBarConfig = {
  type: 'bar',
  data: {
    labels: ['Q1', 'Q2', 'Q3', 'Q4'],
    datasets: [
      {
        label: 'Online Sales',
        data: [30000, 35000, 40000, 45000],
        backgroundColor: '#3b82f6'
      },
      {
        label: 'Retail Sales',
        data: [20000, 22000, 25000, 28000],
        backgroundColor: '#10b981'
      },
      {
        label: 'Enterprise Sales',
        data: [15000, 18000, 20000, 22000],
        backgroundColor: '#f59e0b'
      }
    ]
  },
  options: {
    responsive: true,
    scales: {
      x: { stacked: true },
      y: { stacked: true }
    }
  }
};

Radar Chart

const radarConfig = {
  type: 'radar',
  data: {
    labels: ['Speed', 'Reliability', 'Comfort', 'Safety', 'Efficiency'],
    datasets: [
      {
        label: 'Product A',
        data: [85, 90, 70, 95, 80],
        borderColor: '#3b82f6',
        backgroundColor: 'rgba(59, 130, 246, 0.2)'
      },
      {
        label: 'Product B',
        data: [75, 85, 90, 85, 70],
        borderColor: '#10b981',
        backgroundColor: 'rgba(16, 185, 129, 0.2)'
      }
    ]
  },
  options: {
    responsive: true,
    scales: {
      r: {
        min: 0,
        max: 100
      }
    }
  }
};

Final Thoughts

Charts transform data into insights.

With server-side rendering, you can generate any visualization you need:

  • āœ… Bar charts for comparisons
  • āœ… Line charts for trends
  • āœ… Pie charts for proportions
  • āœ… Complex dashboards with multiple visualizations

Your reports will go from boring to brilliant.

Ready to generate PDFs with stunning visualizations? Try reportgen.io - embed charts effortlessly.