Intro
As a Developer, I sometimes need to render PDF documents. However, each library is different, and it's hard to decide which is best for you in terms of time and learning curve. I used to use Pupeteer and JSpdf for this purpose, but is there another way?
Everything takes time to research. You are under the pressure of deadlines and tend to choose whatever is easiest or has good reviews. You solve problems on your way, but looking into all possible options consumes a lot of your time.
In this article, I will provide information about PDF generation, different libraries, and recommendations on where to use it.
I would concentrate on next libraries:
- React PDF
- PDF lib
- PDF me
- PDF make
- JSPDF
I created a GitHub repo with the Next.js project.
For each library(almost), I added the following features:
- Client-side generation
- Server-side generation
- Download buttons for Server/Client generated documents
- Dynamic Table Generation
- Template form to fill data
But I will give you a brief information about alternative solutions as well.
Important disclaimer
As you may know, PDF libraries are quite heavy, so don't include them in your bundle. Always add lazy loading for them.
Example for next.js. For react, you can use React.lazy
const DynamicPDFComponent = dynamic(
() => import('./Big_Component_With_Pdf_Rendering'),
{
ssr: false,
loading: () => <span>...loading</span>,
}
);
Also simpliest way to preview PDF in web application is IFrame so don't need to look for fancy library
const blob = new Blob([document.buffer], { type: 'application/pdf' });
const documentUrl = URL.createObjectURL(blob);
...
<iframe
src={pdfDocument}
width="100%"
height="500px"
...
/>
Now, let's start with alternative solutions.
Alternative Solutions
html2pdf.js
It's a wrapper on top of html2canvas and JSPDF. You get an element from DOM, put it into the library, make a screenshot, and put it into PDF.
const element = document.getElementById('element-to-print');
html2pdf(element);
Easy right? On the other hand, it's the same as putting any image into a PDF. The image can be blurry, Some styles might be off(I remember this problem existed when I tried it last time), and texts in the PDF would not be searchable. It's a way, an easy one, but it is not that reliable for me
Playwrite, Pupeeter
You set up a browser on the backend. Yup, it's a browser. You feed it with URL or HTML content, wait for it to finish rendering, and print it using the browser.
Problems? If you need to generate a lot, you'll need to manage your setup wisely. Otherwise, the Browser will take up a large portion of your memory or crash. It may be simple but costly if you need to generate many PDF documents.
If you need to generate PDF documents quickly and automatically, I think using a PDF generation library is better than implementing a workaround in a browser. However, it definitely has its own use cases.
And now it's time to start for real
Libraries Review
React PDF
I was skeptical about this library at first. JSX code in a React application that would automagically convert into a PDF document? It's too good to be true.
Surprisingly, I was wrong. It's now my number one library for PDF generation. The only downside is that it depends on React, and it's problematic to use with other frameworks if you want to render it on the client. But in all other cases, it's a breeze to work with it.
In addition, you can use familiar ways of styling using CSS in JS like approach
Installation
yarn add @react-pdf/renderer
In order to make your first document, you need to import related components
import {
Page,
Text,
View,
Document,
StyleSheet,
} from '@react-pdf/renderer';
It does not support HTML tags, so you need to use their components
- Page - Unit that represents a page, you can separate content by pages by yourself or make everything in one page and let React PDF wrap the content for you
- View - it's grouping element, aka div
- Text - it's span or p, obviously
- Document - it's a wrapper and top-level element.
- StyleSheet - CSS in JS like component for reusable styles
let's create first document
import {
Page,
Text,
View,
Document,
StyleSheet,
} from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: {
flexDirection: 'row',
backgroundColor: '#E4E4E4',
},
section: {
margin: 10,
padding: 10,
flexGrow: 1,
},
});
export const PdfDocument = () => {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.section}>
<Text>Section #1</Text>
</View>
<View style={styles.section}>
<Text>Section #2</Text>
</View>
</Page>
</Document>
);
}
It's so readable and easy to work with. You can create reusable components, pass props, and do everything that you regularly do in JSX.
Want to use a custom font? Easy
import { StyleSheet, Font } from '@react-pdf/renderer' // Register font
Font.register({ family: 'Roboto', src: source }); // Reference font
const styles = StyleSheet.create({ title: { fontFamily: 'Roboto' } })
Want to insert an image or other elements? Check their documentation
In case of need, you can even render SVG
Or, if you're working with some Chart generation library, you can save the chart as an image and put it in the document using the Image tag
Once you have prepared a document, you can show it to a user using their PDFViewer component like
import {
PDFViewer
} from '@react-pdf/renderer';
import {
PdfDocument
} from './PdfDocument';
const styles = StyleSheet.create({
page: {
flexDirection: 'row',
backgroundColor: '#E4E4E4',
},
section: {
margin: 10,
padding: 10,
flexGrow: 1,
},
});
export const PreviewDocument = () => {
return (
<PDFViewer>
<PdfDocument />
</PDFViewer>
);
}
Or you can render it on BE like
import { renderToStream } from '@react-pdf/renderer';
import {
PdfDocument
} from './PdfDocument';
export const reactPdfRenderToStream = () => {
return renderToStream(<PdfDocument />);
};
In order to make a route and send it to the client by API, you need to convert the result from renderToStream(): NodeJS.ReadableStream
to ReadableStream<Uint8Array>
.
You can do it this way:
export function streamConverter(
stream: NodeJS.ReadableStream
): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
stream.on('data', (chunk: Buffer) =>
controller.enqueue(new Uint8Array(chunk))
);
stream.on('end', () => controller.close());
stream.on('error', (error: NodeJS.ErrnoException) =>
controller.error(error)
);
},
});
}
and send your content to the Client
...
return new NextResponse(doc, {
status: 200,
headers: new Headers({
'content-disposition': `attachment; filename=${document name}.pdf`,
'content-type': 'application/zip',
}),
})
As you can see, it's a 100% straightforward library. I would use it as my main one from this point.
PDF me
I have mixed feelings about this one. It has some incredible features but fails to provide basic convenience.
The incredible feature of this library is the UI template builder of PDF documents: https://pdfme.com/template-design.
You can literally drag and drop elements where you see fit, play with UI, download template, and... just use it. This feature shines when you need to make Documents with a limited dynamic compound. If you have a more or less static position of elements, don't have elements with unpredictable sizes(like a table that can have from 2 to infinite rows), or need to provide the possibility for a user to fill fields of the document by himself. it's brilliant.
Curious why I wrote that it fails in basic convenience. Things are different if you need to generate dynamic content and write a template on your own. More about it down below.
Installation
npm i @pdfme/generator @pdfme/common @pdfme/schemas
The whole document should be written in template file like
import { BLANK_PDF, Template } from '@pdfme/common';
const template: Template = {
basePdf: BLANK_PDF,
schemas: [
[
{
{
name: 'a',
type: 'text',
position: { x: 0, y: 0 },
width: 10,
height: 10,
},
}
]
]
}
const inputs = { a: 'some text' };
You need to define the base PDF. It can be a blank PDF, as in the example, or you can add content to an existing PDF.
During document generation, the name of any item in such a template can be replaced by an input object. Alternatively, you can add a readOnly attribute to it and make it static. This is an interesting feature because you can separate the template itself from the values you want to pass there.
NOTE:
If you try to pass a number ininputs
, you would get hardly trackable error, always use strings. At the same time, I'm using typescript, so it's quite frustrating that I don't get a type error for it
A full list of possible properties can be found if you play with their UI editor and save a template. I didn't find documentation that covers it
The template itself tends to grow in size rapidly. To make it at least readable I tried to make it into separate functions with reusable styling and I feel like developers didn't expect anyone to write such templates by hands
type Props = {
name: string;
y: number;
position: 'right' | 'left';
};
export const infoTitle = ({ name, y, position }: Props) => {
const x = position === 'left' ? 10.29 : 107.9;
return [
{
name: `${name}Label`,
type: 'text',
content: 'Invoice From:',
position: {
x: x,
y: y,
},
width: 45,
height: 10,
rotate: 0,
alignment: 'left',
verticalAlignment: 'top',
fontSize: 15,
lineHeight: 1,
characterSpacing: 0,
fontColor: '#686868',
backgroundColor: '',
opacity: 1,
strikethrough: false,
underline: false,
required: false,
readOnly: true,
fontName: 'NotoSerifJP-Regular',
},
{
name: name,
type: 'text',
content: 'Type Something...',
position: {
x: x + 38,
y: y,
},
width: 45,
height: 10,
rotate: 0,
alignment: position,
verticalAlignment: 'top',
fontSize: 15,
lineHeight: 1,
characterSpacing: 0,
fontColor: '#000000',
backgroundColor: '',
opacity: 1,
strikethrough: false,
underline: false,
required: true,
readOnly: false,
},
];
};
Also, it's frustrating that I need to pass the element's position, height, and width each time.
And since you need to provide that information BEFORE rendering something it makes dynamic rendering a bit more complicated.
Imagine you want to generate a table. And this table can take any size. So you need to calculate its height, how to do it?
you need to calculate it on top of the function because we're making an object here
const tableLastY = 89 + (items + 1) * baseTableItemHeight;
Problems? If your text element is bigger than the item itself it will grow in size, but since you didn't know about it the text will overlap
I know that it's relevant to most libraries. But in those libraries, I can at least calculate the size of the text before rendering it. How to do it here? I don't know, it's a mystery. But maybe there's a way
The basic generation of the document looks like
import { text } from '@pdfme/schemas';
...
generate({
template,
inputs,
plugins: { Text: text },
})
As a result of generate
you would get Promise<UInt8>
, and you also need to list all used plugins. Sometimes it's unclear what plugins they have. I didn't find good documentation that fully covers it. But if you try to use something that's not listed in plugins, then you would get an error.
and you can send it from BE like
return new NextResponse(doc, {
status: 200,
headers: new Headers({
'content-disposition': `attachment; filename=${data.title}${data.invoiceId}.pdf`,
'content-type': 'application/zip',
}),
});
In conclusion:
If you have the means to prepare a Template that covers all your needs, then everything is simple. If you need to generate a template by code and support dynamic content, then it's better to look for other options
PDF Make
This library is my second favorite now. The only reason it's second is that I could not make it work on the server side. I had a limited amount of time, so maybe later, I would fill this gap. Other than that, its Framework agnostic provides a convenient way of styling, doesn't rely on the absolute positioning of elements, and provides everything that React PDF delivers but with a bit less convenient way of declaring PDF template
It wraps around PDFKit. As a result, I decided to now include PDF kit library in this article
Installation
npm install pdfmake
Template structure:
You have 2 fields in the documents styles - for reusable styles and content for content
{
content: [
{ text: 'Invoice', style: 'header' },
{ text: data.invoiceId, alignment: 'right' },
],
styles: {
header: {
fontSize: 22,
bold: true,
alignment: 'right',
},
}
}
It also supports columns to render tables or multi-column texts
{
margin: [0, 40, 100, 0],
align: 'right',
columns: [
{
// star-sized columns fill the remaining space
// if there's more than one star-column, available width is divided equally
width: '*',
text: '',
},
{
// auto-sized columns have their widths based on their content
width: 'auto',
text: 'Total',
},
{
width: 'auto',
text: calculateTotalOfInvoice(data.items),
},
],
// optional space between columns
columnGap: 10,
}
I like that everything can be settled by margins between elements, and you can use columnGap instead of trying to calculate everything by yourself.
And generation on the client side looks like
import * as pdfMake from "pdfmake/build/pdfmake";
import * as pdfFonts from 'pdfmake/build/vfs_fonts';
(<any>pdfMake).addVirtualFileSystem(pdfFonts);
pdfMake.createPdf(template)
const blob = pdfDocGenerator.getBlob();
You need to provide fonts for the library to work.
Things are a bit different on the BE side.
In short, there're 3 differences
- You get PDFkit as a result of generation
- Hardly trackable errors
- You need to pass fonts during creation.
The code looks like
import PdfPrinter from 'pdfmake';
import path from 'path';
import { IInvoice } from '../../types/invoice';
import { templateBuilder } from './templateBuilder';
const fonts = {
Roboto: {
normal: path.resolve('./fonts/Roboto-Regular.ttf'),
bold: path.resolve('./fonts/Roboto-Medium.ttf'),
italics: path.resolve('./fonts/Roboto-Italic.ttf'),
bolditalics: path.resolve('./fonts/Roboto-MediumItalic.ttf'),
},
};
export const pdfMakeServerGeneration = (
data: IInvoice
): Promise<NodeJS.ReadableStream> => {
return new Promise((resolve) => {
const printer = new PdfPrinter(fonts);
const docDefinition = templateBuilder(data);
resolve(printer.createPdfKitDocument(docDefinition));
});
};
Problems? Yup, when I try to return the result in next.js, it makes my route not exist and doesn't provide any debugging information.
If I published the article and didn't remove this place, then it means that I didn't find how to fix the problem. Feel free to write me if you know how to solve it)
Other than that, it's on par with React PDF, and once I fix the Backend problem, it would be superior just because it's framework-agnostic
PDF Lib
This one I didn't like that much. For one simple reason (0, 0) point of the document is in the bottom left corner. So you need to make around your whole thinking and reinvent the wheel to make it point to the top left corner. A bit of inconvenience yes but together with a weird color function
import { rgb } from 'pdf-lib';
rgb(101 / 255, 123 / 255, 131 / 255),
and better alternatives it may be something that you want to think twice before using.
Installation
yarn add pdf-lib
Basic usage
import { Color, PDFDocument, PDFFont, PDFPage, StandardFonts } from 'pdf-lib';
const fontSize = 18;
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.TimesRoman);
const page = doc.addPage();
page.setFont(font);
const { height } = page.getSize();
const elementHeight = font.sizeAtHeight(fontSize);
page.drawText('Some text', {
x: 10,
y: height - (elementHeight + 10),
size: fontSize,
color,
});
Since you're rendering from the bottom to the top, you need to deduct the height of the element like height - (elementHeight + spacing on top)
to make sure that your text is not cropped
Also as you can see, in order to use the library, you need to register the default font first, like
const font = await doc.embedFont(StandardFonts.TimesRoman);
page.setFont(font);
and if you want to learn the size of the text element you need to do it by font object as well
const elementHeight = font.sizeAtHeight(fontSize);
Such little inconvenience makes me think that JSPdf is a better option than this one. It provides the same way of layout declaration, but you need to pass fewer variables in each function.
Also, since you're pointing to the exact location, you need to save and update the pointer to the last rendered item in case you want to render something dynamically
let lastRenderedItem = 180;
data.items.forEach((x) => {
wrapper.drawText({
text: x.title,
x: 30,
y: lastRenderedItem,
size: regularFont,
});
wrapper.drawText({
text: `${x.quantity}`,
x: 240,
y: lastRenderedItem,
size: regularFont,
});
wrapper.drawText({
text: `$${x.rate}`,
x: 360,
y: lastRenderedItem,
size: regularFont,
});
wrapper.drawText({
text: `$${(x.quantity ?? 0) * (x.rate ?? 0)}`,
x: 490,
y: lastRenderedItem,
size: regularFont,
});
lastRenderedItem += 22;
page.drawLine({
start: { x: 10, y: height - lastRenderedItem - 5 },
end: { x: width - 10, y: height - lastRenderedItem - 5 },
color: rgb(101 / 255, 123 / 255, 131 / 255),
});
});
on the bright side, you can use the same code for both the client and backend and save your document like
const pdf: UInt8Array = await doc.save();
JSPdf
This is the library I'm most familiar with. And for me it seems quite straightforward so not much details about this one. I like it, but managing big documents is a hassle. But I would prefer it other than PDF lib
Installation
yarn add jspdf
Basic usage
import { jsPDF as JsPDF } from 'jspdf';
const doc = new JsPDF();
doc.setFontSize(16);
doc.setTextColor('black');
doc.text('Some text', padding, 45);
const result = new Uint8Array(doc.output('arraybuffer'));
You need to be careful because the library is synchronous. So wrap it in a promise to not block your main thread for too long.
With this library, you can do almost anything: add images, draw, generate content(but keep the position pointer the same as in the PDF lib), and so on.
0,0 points to the top left corner by default and all styles that you define have block scope.
Example
doc.setFontSize(16);
doc.setTextColor('black');
/* block starts, everything would use this font size and color*/
...
doc.setFontSize(17);
doc.setTextColor('gray');
/* new block with scoped usage*/
...
This way, you can group together content that requires similar styles or write reusable functions to toggle styles on and off on demand.
You can render it in an array buffer or save it to a local machine right away
const result = new Uint8Array(doc.output('arraybuffer'));
Conclusion
From this point, I have two favorite generation libraries: React PDF and PDF Make. I am looking forward to using them more and experiencing any hidden problems they may have.
It's a review article for libraries that I used for the first time. If you see any problems, feel free to write a comment or message me.
Hope it was of help to you)
Top comments (6)
Thanks so much for this list! I used Puppeteer in the past in combination with handlebars to bulk print custom invoices, but I think I'll try that React one.
Yup, it would be more performant. But if you have a designer and custom invoices that are more or less static(like no dynamic rows in the table), then I think PDFMe would be even easier to integrate. Purely because you would make a template in UI builder and use it in generation as it is
I came to the same conclusion as you.
As a result, I ended up using PDFMake in a Vue 3 project to generate dynamic tables as PDF.
It worked well, but it was somewhat tedious to implement.
I can relate. PDF generation always has trade-offs.
But using margins instead of absolute positioning is nice
Good job! A very informative read!
I have a question, did you ever try react-pdf-viewer ? It has plugin architecture (as well as PDF me) and looks simple for usage.
I think the purpose of react-pdf-viewer is different than for PDF generation libraries.
I didn't use it before, but as I can see, it provides reach functionality to view and interact with PDF documents but doesn't allow you to generate one. Am I wrong?