Generate PDFs dynamically in NodeJs/NestJs

Swapnil S
8 min readApr 17, 2024

--

During my internship, I was given a task to figure out how we could dynamically generate PDFs in the backend. I was informed that the frontend team already tried and failed. My first thought was this seems a very normal use case and a lot of libraries must exist. But looks like I was wrong.

These were the things which I was told should be dynamically generated in the pdf :
-> Graphs & Charts
-> Tables
-> Add Images
-> Generate TOC (Table of Contents) and Automatic Page numbers

I went on & tried multiple libraries (pdfkit etc). All of them had problems. They did not have simplest features that you would expect from a PDF generation library.

My final choice was jsPDF. Thats because of two things :

1. One just does not write only text in a PDF (saying bcz other lib lacked so much)
2. It has an extension library called jspdf-autotable which lets you generate actual tables (and not add image of a table in the pdf)

Add the following libraries using yarn/npm.

yarn add jspdf @types/jspdf tjspdf-autotable canvas

IF CANVAS INSTALL FAILS:
canvas was throwing some errors during installation.I googled and found out that there are some compatibility issues for canvas with M-series Apple chips. The solution : Build from source. Read the doc on how you can build from source.Also, canvas was not able to detect puppeteer path correctly. So I had to make changes for it as well. For me i was using docker and after some tinkering around i was able to build from source like this :


...

# Install necessary system dependencies for canvas compilation
RUN apt-get update && \
apt-get install -y \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
python3 \
python3-pip \
chromium \
git && \
rm -rf /var/lib/apt/lists/*

# Check if setuptools is installed for Python
RUN pip3 show setuptools || true

# Install setuptools if not already installed
RUN apt-get install python3-setuptools

ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
ENV CHROMIUM_PATH /usr/bin/chromium

...

The following code is of NestJs and should be easily replicable in any NodeJs Environment

// pdf.module.ts 

import { Module } from '@nestjs/common';
import { PdfService } from './pdf.service';

@Module({
providers: [PdfService],
exports: [PdfService],
})
export class PdfModule {}

Brief Explanation of everything thats happening:
1. We keep track of the pointer manually.
2. Altho I have hardcoded margins , You can easily make them dynamic
3. When the client requests on GET /pdf route, All the details of his org are fetched from a DB and a report is generated.
4. The PDF is generated, saved, sent to the client and then deleted from memory.

// pdf.interface.ts

import { TextOptionsLight } from 'jspdf';
import { UserOptions } from 'jspdf-autotable';

export interface index {
Index: string;
Page: number;
}

export interface TableOptions extends UserOptions {
ignoreFields?: string[];
tableName: string;
addToIndex?: boolean;
}

export interface TextOptions extends TextOptionsLight {
x?: number;
y?: number;
addToIndex?: boolean;
}

Function & Instance Variables definition:
-> xMargin, yMargin : Horizontal & Vertical Margin Respectively
-> indexData : saves info about things to be added to TOC
-> x, y : current pointer coordinates in the 2d sheet

  • updatePointer(): After adding each thing ( text, image etc) we move the pointer to correct new location (i.e to new x and y).
  • addNewPage(): adds a new page in pdf and moves the pointer to start of margins.
  • addImage(imageData: Buffer, options?: any): takes an image as a buffer and adds it to pdf. Can be Resized using options. Default is taking up whole width & height of pdf. Exact position of image can also be specified using options.x and options.y; Default is the current pointer position.
  • addGenericTable<T>(dataArr: T[], options: TableOptions): Takes and array of objects and generates table. TableOptions is interesting. You get three new options other than default ones:
    ignoreFields?: fields that you donot want to be included in the table. For example : I am printing a table of users. I have an array of User obj: User[]. but user also has a password field which i dont want to include in the table. So I ll pass [‘password’] in this field.
    tableName: The name of the table
    addToIndex?: Whether you want this to be mentioned in TOC page.
  • addText(text: string, options?: TextOptions) : Self Explanatory. Options has addToIndex? field similar to addGenericTable()
  • addNewLine() : add new empty Line. May be to change paragraphs.
  • render() : Finish writing the pdf & render it.
// pdf.service.ts

import { Injectable } from '@nestjs/common';
import { TextOptionsLight, jsPDF } from 'jspdf';
import autoTable, { UserOptions } from 'jspdf-autotable';
import { TableOptions, TextOptions, index } from './pdf.interface';

@Injectable()
export class PdfService {
private doc: jsPDF;
private readonly filePath = './output.pdf';
private readonly xMargin = 20;
private readonly yMargin = 30;
private indexData: index[] = [];
private x: number
private y: number

private defaultTableOptions: TableOptions = {
tableName: 'default table name',
ignoreFields: [],
addToIndex: false,
};

constructor() {
this.doc = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' });
this.resetXandY()
this.updatePointer();
}

private resetXandY(){
this.x = this.xMargin
this.y = this.yMargin
}

private updatePointer(){
this.doc.moveTo(this.x,this.y)
}

async addNewPage() {
this.doc.addPage();
this.resetXandY()
this.updatePointer()
}

// Adds image at position (x, y) with width and height
async addImage(imageData: Buffer, options?: any) {
this.doc.addImage(
imageData,
'JPEG',
options?.x || this.x,
options?.y || this.y,
options?.width || this.doc.internal.pageSize.getWidth(),
options?.height || this.doc.internal.pageSize.getHeight(),
);

this.y = options?.height || this.doc.internal.pageSize.getHeight() + this.doc.getLineHeight()
this.updatePointer()
}

async addGenericTable<T>(dataArr: T[], options: TableOptions) {
if (dataArr.length === 0) {
console.error('Data array is empty');
return;
}

const mergedOptions: TableOptions = {
...this.defaultTableOptions,
...options,
startY : this.y + this.doc.getLineHeight()
};

this.addText(`${mergedOptions.tableName}`);

if (mergedOptions.addToIndex) {
this.indexData.push({
Index: mergedOptions.tableName,
Page: this.doc.getCurrentPageInfo().pageNumber,
});
}

const headers = Object.keys(dataArr[0]).filter(
(key) => !mergedOptions.ignoreFields?.includes(key),
);

const transformedData = dataArr.map((item: any, index) =>
headers.map((key: string) =>
item[key] instanceof Date ? item[key].toISOString() : item[key],
),
);

autoTable(this.doc, {
head: [headers],
body: transformedData,
didDrawCell: (data) => {},
...mergedOptions,
});
this.y = (this.doc as any).lastAutoTable.finalY + this.doc.getLineHeight();
this.updatePointer()
}

async addText(text: string, options?: TextOptions) {
const lines = this.doc.splitTextToSize(
text,
this.doc.internal.pageSize.width - this.xMargin * 2,
);

if(options?.addToIndex){
this.indexData.push({
Index: text,
Page: this.doc.getCurrentPageInfo().pageNumber,
});
}

console.log(`posi before writing TEXT '${text}' is ${this.x} & ${this.y}`)
this.doc.text(
lines,
options?.x || this.x,
options?.y || this.y,
);
this.y = this.doc.getTextDimensions(lines).h + this.doc.getLineHeight();
this.updatePointer()
}

async addNewLine(){
this.y += this.doc.getLineHeight()
this.x = this.xMargin;
this.updatePointer();
}

async render(): Promise<string> {
await this.addPageNumbers();
await this.index();
return new Promise<string>((resolve, reject) => {
this.doc.save(this.filePath);
resolve(this.filePath);
});
}

private async addPageNumbers() {
const pageCount = (this.doc as any).internal.getNumberOfPages(); //Total Page Number
for (let i = 0; i < pageCount; i++) {
this.doc.setPage(i);
const pageCurrent = (this.doc as any).internal.getCurrentPageInfo()
.pageNumber; //Current Page
this.doc.setFontSize(12);
this.doc.text(
'page: ' + pageCurrent + '/' + pageCount,
this.xMargin,
this.doc.internal.pageSize.height - this.yMargin/2,
);
}
}

private async index() {
this.doc.setPage(2);
this.resetXandY();
this.updatePointer();
await this.addGenericTable(this.indexData, {
tableName: `Table of Contents`,
theme: 'grid',
});
}
}
// org.controller.ts (only a part is shown)

@ApiTags('Organization')
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth('jwt')
@Controller('org')
export class OrgController {
constructor(private readonly orgService: OrgService) {}

@Get('pdf')
@ApiOperation({ summary: 'Mail Reports' })
async generatePdf(@GetOrg() org: Org, @Res() res: Response) {
let filePath: any;
try {
filePath = await this.orgService.createPdfInOneFile(org.id);

res.setHeader('Content-disposition', 'attachment; filename=output.pdf');
res.setHeader('Content-type', 'application/pdf');

res.sendFile(filePath, { root: process.cwd() }, (err) => {
if (err) {
console.error(err);
}
fs.unlinkSync(filePath); // Remove the temporary PDF file
});
} catch (err) {
console.log('Error generating PDF:', err);
throw err;
}
}
}
//org.service.ts (only a part is shown)

@Injectable()
export class OrgService {
constructor(
@InjectRepository(Org)
private orgRepository: Repository<Org>,
private userService: UserService,
private pdfService: PdfService,
private visualizationService: VisualizationService,
) {}

async createPdfInOneFile(orgId: string): Promise<any> {
const usersData = await this.findAllUsersOfOrg(orgId);

const chart = await this.visualizationService.createChart();

await this.pdfService.addText(`Heading`);
await this.pdfService.addNewLine(); // Leave an empty Line
await this.pdfService.addText(`SubHeading`);
await this.pdfService.addNewLine();

//one page left empty for TOC
await this.pdfService.addNewPage();

await this.pdfService.addNewPage();
await this.pdfService.addGenericTable(usersData, {
ignoreFields: ['password', 'otp', 'otpCreatedAt', 'lastPasswordUpdateAt'],
tableName: 'Users Table',
addToIndex: true, //add to TOC
theme: 'grid',
});

//changed ignoreFields.Table resizes automatically. Look in pdf images
await this.pdfService.addGenericTable(usersData, {
ignoreFields: ['password', 'otp', 'otpCreatedAt', 'lastPasswordUpdateAt', 'createdAt'],
tableName: 'Users2 Table',
addToIndex: true , // add to TOC
theme: 'grid',
});

await this.pdfService.addNewPage();
await this.pdfService.addText(`TRAILING PAGE`, {
align: 'center',

});
this.pdfService.addImage(chart,{width : 200 ,height : 200})
return await this.pdfService.render();
}
}

Thats it for pdf generation. Now Lets see how we can generate chart as an image and add it to the pdf. I wrote a visualisation service for the same. It uses canvas which uses pupeeter. How it works is it starts a pupeeter instance generates chart & takes a screenshot. I know this sounds clumsy but this is the only way possible as of now.

import { Injectable } from '@nestjs/common';
import { createCanvas } from 'canvas';
import * as echarts from 'echarts';
import puppeteer from 'puppeteer';

@Injectable()
export class VisualizationService {
// Currently I am not using any data to generate chart just harcoded values.
async createChart(data?): Promise<Buffer> {
const echartOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: [
{
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
axisTick: {
alignWithLabel: true,
},
},
],
yAxis: [
{
type: 'value',
},
],
series: [
{
name: 'Direct',
type: 'bar',
barWidth: '60%',
data: [10, 52, 200, 334, 390, 330, 220],
},
],
};

const canvas = createCanvas(600, 600);
const chart = echarts.init(canvas as any);

chart.setOption(echartOption);

return canvas.toBuffer('image/png');
}
}

Sample Images from generated PDF:

await this.pdfService.addNewLine();
TOC generated automatically. And page numbers also add correctly
We could also change chart size dynamically using this.doc.internal.pageSize.getHeight() & getWidth() methods of jsPDF.

Final Words.

I count this as a decent implementation but far from best. One immediate problem i can think of is we leave 1 page empty for TOC initially what if all the TOCs don’t fit? I haven’t yet done that bcz this fulfils my requirements as of now but doing that also wont be very tough.

--

--

Responses (8)