Originally published on my personal blog
Intro
It's pretty standard functionality for the dashboard (and not only) apps to export charts to PDF. Recently I needed to implement the following feature for one of the apps: a user should be able to export multiple charts into a multipage PDF document. Surprisingly, I spent a significant amount of time finding and developing a suitable solution. In this blog post, I'll describe how I solved this problem.
I'm going to use the following libraries:
- Highcharts and it's official wrapper for React
- jsPDF
- htmlToImage
Highcharts
As we already have been using the Highcharts library on the project, I will use this library for chart rendering in this tutorial. But the following approach, I believe, is suitable for other most commonly used React chart libraries.
First, let's add highcharts
and highcharts-react-official
(it's an official Highcharts wrapper for React) dependencies:
npm install highcharts highcharts-react-official
Next, we need to render several charts that we are going to export to PDF later.
Create a Chart component that will render a Highchart that accepts different chart options.
//Chart.tsx
import HighchartsReact from "highcharts-react-official";
import Highcharts from "highcharts";
type ChartProps = {
chartOptions: Highcharts.Options;
};
export default function Chart({ chartOptions }: ChartProps) {
return (
<div className="custom-chart">
<HighchartsReact
highcharts={Highcharts}
options={chartOptions}
containerProps={{ style: { height: "100%" } }}
/>
</div>
);
}
Note the class name of "custom-chart" of the wrapper div. We'll use it later.
And then to create a line chart, for example, we just need to pass options object for a line chart:
//options.ts
import Highcharts from "highcharts";
export const lineChartOptions: Highcharts.Options = {
title: {
text: "Chart 1",
},
series: [
{
type: "line",
data: [1, 2, 3, 8, 4, 7],
},
{
type: "line",
data: [5, 7, 6, 9, 5, 4],
},
],
};
//other chart options ommitted
Here we can add as many charts as we like:
//App.tsx
import "./App.css";
import Chart from "./Chart";
import {
barChartOptions,
columnChartOptions,
lineChartOptions,
} from "./options";
function App() {
return (
<div className="App">
<Chart chartOptions={lineChartOptions} />
<Chart chartOptions={barChartOptions} />
<Chart chartOptions={columnChartOptions} />
<div className="row">
<Chart chartOptions={lineChartOptions} />
<Chart chartOptions={columnChartOptions} />
</div>
</div>
);
}
export default App;
That's what we've got so far:
Export to PDF
There are numerous libraries that help with creating pdf docs. After investigating a couple of options, I've decided to use jsPDF library.
Highcharts (the same as most chart libraries) are mainly SVG elements. And it's tricky to convert SVG to PDF. At least I couldn't find any simple out-of-the-box solution. I've tried different approaches and libraries (canvg, html2canva, svg2pdf.js are among them), but nothing worked for me. Here is what has worked for me.
Basically, the main steps are:
- Initialize a new jsPDF instance
- Get all charts as HTML Elements
- Convert each HTML Element with chart into image (with htmlToImage library)
- Add converted chart image to the pdf doc with jsPDF's
addImage()
method - As we are adding multiple charts, create a new pdf page when needed
- Download generated pdf doc using jspdf's
save()
method
Now let's implement all these. Install jsPDF
and htmlToImage
packages:
npm install jspdf html-to-image
I prefer to keep all business logic separate from UI logic. So, create a new utils.ts
file where we are going to write all the export to pdf logic.
// utils.ts
export async function exportMultipleChartsToPdf() {
const doc = new jsPDF("p", "px"); // (1)
const elements = document.getElementsByClassName("custom-chart"); // (2)
await creatPdf({ doc, elements }); // (3-5)
doc.save(`charts.pdf`); // (6)
}
Here we initialize a new jspdf instance with portrait orientation ("p" parameter) and pixels ("px") as units of measure.
The essential thing in the above code is that the charts wrapper div class name should be unique for the app. It should be something more complex than just "custom-chart" in the production app.
Now let's implement steps 3-5.
To convert each chart HTML Element into image, we need to loop through the HTMLCollection of Elements and convert each element to image. Note that we need a base64-encoded data URL and it's very convenient that htmlToImage library does exactly that.
for (let i = 0; i < elements.length; i++) {
const el = elements.item(i) as HTMLElement;
const imgData = await htmlToImage.toPng(el);
}
That was step 3. Now we need to add each image data to a pdf document. Let's check the docs for jspdf's addImage()
method. The addImage()
method accepts 9 arguments:
- imageData - base64 encoded DataUrl or Image-HTMLElement or Canvas-HTMLElement. We got this covered in the previous step.
- format - format of file. It is "PNG" in our case.
- x - x Coordinate (in units declared at the inception of PDF document) against the left edge of the page. Say it would be 10px.
- y - y Coordinate (in units declared at the inception of PDF document) against the upper edge of the page. This one is a little bit trickier. We need to have a variable for keeping track of the used or already occupied pdf page space, e.g., start with the initial value of 20px, for example, and then increase it every time by the added image height.
- width - width of the image (in pixels in our case)
- height - height of the mage (again in pixels)
- alias - alias of the image (if used multiple times). This is a very important prop when adding multiple images. Without using it we'll have a blank page in our specific case.
- compression
- rotation
We are not going to use 8th and 9th props.
For getting the width and height of a chart container, we'll use offsetWidth
and offsetHeight
props of the HTML Element class. Let's implement this.
let top = 20;
for (let i = 0; i < elements.length; i++) {
const el = elements.item(i) as HTMLElement;
const imgData = await htmlToImage.toPng(el);
const elHeight = el.offsetHeight;
const elWidth = el.offsetWidth;
doc.addImage(imgData, "PNG", 10, top, elWidth, elHeight, `image${i}`);
top += elHeight;
}
So far, so good, but what if the chart's width is greater than a pdf doc's page width? The chart will be cut at the right. To escape this issue, we should resize the chart's width and height proportionally (to keep the initial width / height ratio) in case the chart's width is greater than a page's width.
let top = 20;
const padding = 10;
for (let i = 0; i < elements.length; i++) {
const el = elements.item(i) as HTMLElement;
const imgData = await htmlToImage.toPng(el);
let elHeight = el.offsetHeight;
let elWidth = el.offsetWidth;
const pageWidth = doc.internal.pageSize.getWidth();
// if chart do not fit to the page width
if (elWidth > pageWidth) {
const ratio = pageWidth / elWidth;
//resize chart width and heigth proportionally
elHeight = elHeight * ratio - padding;
elWidth = elWidth * ratio - padding;
}
doc.addImage(imgData, "PNG", padding, top, elWidth, elHeight, `image${i}`);
top += elHeight;
}
And the last thing we need to take care of is to create a new pdf page every time there is no space to add a new chart to the current page.
...
const pageHeight = doc.internal.pageSize.getHeight();
//if chart do not fit to the page height
if (top + elHeight > pageHeight) {
doc.addPage(); // add new page
top = 20; // reset height counter
}
...
Thus the final implementation of createPdf
function is:
async function creatPdf({
doc,
elements,
}: {
doc: jsPDF;
elements: HTMLCollectionOf<Element>;
}) {
let top = 20;
const padding = 10;
for (let i = 0; i < elements.length; i++) {
const el = elements.item(i) as HTMLElement;
const imgData = await htmlToImage.toPng(el);
let elHeight = el.offsetHeight;
let elWidth = el.offsetWidth;
const pageWidth = doc.internal.pageSize.getWidth();
if (elWidth > pageWidth) {
const ratio = pageWidth / elWidth;
elHeight = elHeight * ratio - padding;
elWidth = elWidth * ratio - padding;
}
const pageHeight = doc.internal.pageSize.getHeight();
if (top + elHeight > pageHeight) {
doc.addPage();
top = 20;
}
doc.addImage(imgData, "PNG", padding, top, elWidth, elHeight, `image${i}`);
top += elHeight;
}
}
To test how it works, add a button by clicking on which the exportMultipleChartsToPdf
function will run.
//App.tsx
import "./App.css";
import Chart from "./Chart";
import {
barChartOptions,
columnChartOptions,
lineChartOptions,
} from "./options";
import { exportMultipleChartsToPdf } from "./utils";
function App() {
return (
<div className="App">
<button className="button" onClick={exportMultipleChartsToPdf}>
Export to PDF
</button>
<Chart chartOptions={lineChartOptions} />
<Chart chartOptions={barChartOptions} />
<Chart chartOptions={columnChartOptions} />
<div className="row">
<Chart chartOptions={lineChartOptions} />
<Chart chartOptions={columnChartOptions} />
</div>
</div>
);
}
export default App;
And voilà, we exported multiple (6) charts as multipage (3 pages) pdf document!
Conclusion
The complete code is available in this GitHub repo.
Top comments (2)
Hey Kate, Your content is really helpful. I'm trying to build jira like webapp for in house use. Could you help me with some resources or guidance. I'm looking for google like calender component with api to integrate in the project. Thanks in advance.
Hi Kate,
What if the charts are in different places? Is it our responsibility to position the charts correctly in the pdf?
I have report in which each table row I have a chart in the 3rd colum and the first 2 column are just texts.
Likewise I will have that many number of rows based on the data which could leas to many pages.
In this example will the charts be positioned correctly in each row after export?
Please let me know