Nicolò Andronio

Nicolò Andronio

Full-stack developer, computer scientist, engineer
Evil Genius in the spare time

Simple tables with node and pdfkit

I was working on a report creation feature yesterday, using node, express and pdfkit. Eventually I had to generate a bunch of tables so I started looking for node modules that would allow me to insert tables in a pdf document. Surprisingly, I found very little: the only two alternatives were Voilab pdf tables and pdfmake. The latter is an advanced declarative library to generate pdf documents client-side in the browser; considering that I was bound to use pdfkit with its imperative syntax and the fact that the approach was completely different and required several despicable workaround, I ultimately decided to walk away from pdfmake. This only left me with the first option, which is quite nice. However, as an experiment, I wanted to check if I could render some simple tables through low level pdfkit rendering calls. It turned out to be quite simple and effective, so why not sharing it?

Pdfkit module enriched with the table rendering functionview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
'use strict';

const PDFDocument = require('pdfkit');

class PDFDocumentWithTables extends PDFDocument {
constructor (options) {
super(options);
}

table (table, arg0, arg1, arg2) {
let startX = this.page.margins.left, startY = this.y;
let options = {};

if ((typeof arg0 === 'number') && (typeof arg1 === 'number')) {
startX = arg0;
startY = arg1;

if (typeof arg2 === 'object')
options = arg2;
} else if (typeof arg0 === 'object') {
options = arg0;
}

const columnCount = table.headers.length;
const columnSpacing = options.columnSpacing || 15;
const rowSpacing = options.rowSpacing || 5;
const usableWidth = options.width || (this.page.width - this.page.margins.left - this.page.margins.right);

const prepareHeader = options.prepareHeader || (() => {});
const prepareRow = options.prepareRow || (() => {});
const computeRowHeight = (row) => {
let result = 0;

row.forEach((cell) => {
const cellHeight = this.heightOfString(cell, {
width: columnWidth,
align: 'left'
});
result = Math.max(result, cellHeight);
});

return result + rowSpacing;
};

const columnContainerWidth = usableWidth / columnCount;
const columnWidth = columnContainerWidth - columnSpacing;
const maxY = this.page.height - this.page.margins.bottom;

let rowBottomY = 0;

this.on('pageAdded', () => {
startY = this.page.margins.top;
rowBottomY = 0;
});

// Allow the user to override style for headers
prepareHeader();

// Check to have enough room for header and first rows
if (startY + 3 * computeRowHeight(table.headers) > maxY)
this.addPage();

// Print all headers
table.headers.forEach((header, i) => {
this.text(header, startX + i * columnContainerWidth, startY, {
width: columnWidth,
align: 'left'
});
});

// Refresh the y coordinate of the bottom of the headers row
rowBottomY = Math.max(startY + computeRowHeight(table.headers), rowBottomY);

// Separation line between headers and rows
this.moveTo(startX, rowBottomY - rowSpacing * 0.5)
.lineTo(startX + usableWidth, rowBottomY - rowSpacing * 0.5)
.lineWidth(2)
.stroke();

table.rows.forEach((row, i) => {
const rowHeight = computeRowHeight(row);

// Switch to next page if we cannot go any further because the space is over.
// For safety, consider 3 rows margin instead of just one
if (startY + 3 * rowHeight < maxY)
startY = rowBottomY + rowSpacing;
else
this.addPage();

// Allow the user to override style for rows
prepareRow(row, i);

// Print all cells of the current row
row.forEach((cell, i) => {
this.text(cell, startX + i * columnContainerWidth, startY, {
width: columnWidth,
align: 'left'
});
});

// Refresh the y coordinate of the bottom of this row
rowBottomY = Math.max(startY + rowHeight, rowBottomY);

// Separation line between rows
this.moveTo(startX, rowBottomY - rowSpacing * 0.5)
.lineTo(startX + usableWidth, rowBottomY - rowSpacing * 0.5)
.lineWidth(1)
.opacity(0.7)
.stroke()
.opacity(1); // Reset opacity after drawing the line
});

this.x = startX;
this.moveDown();

return this;
}
}

module.exports = PDFDocumentWithTables;

I wrote the code to make it extend the original PDFDocument implementation so that, from outside, it looks like tables were natively implemented. I also used the same signature styles the author chose for all the rendering functions. With the following simple example you can see that it is really easy to obtain a neat-looking result despite the lack of customisation:

Pdf generation example using tablesview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
'use strict';

const fs = require('fs');
const PDFDocument = require('./pdfkit-tables');
const doc = new PDFDocument();

doc.pipe(fs.createWriteStream('example.pdf'));

const table0 = {
headers: ['Word', 'Comment', 'Summary'],
rows: [
['Apple', 'Not this one', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla viverra at ligula gravida ultrices. Fusce vitae pulvinar magna.'],
['Tire', 'Smells like funny', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla viverra at ligula gravida ultrices. Fusce vitae pulvinar magna.']
]
};

doc.table(table0, {
prepareHeader: () => doc.font('Helvetica-Bold'),
prepareRow: (row, i) => doc.font('Helvetica').fontSize(12)
});

const table1 = {
headers: ['Country', 'Conversion rate', 'Trend'],
rows: [
['Switzerland', '12%', '+1.12%'],
['France', '67%', '-0.98%'],
['England', '33%', '+4.44%']
]
};

doc.moveDown().table(table1, 100, 350, { width: 300 });

doc.end();

Which produces this result.

I didn’t publish this code because of course it is extremely trivial and does not allow for the rich customisation experience that other libraries and frameworks offer. Nonetheless it was a nice experiment with a neat acceptable result! I hope that someone will find it useful eventually.