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-canvasBasic 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 canvasGenerate 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 sizeAdvanced 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.