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?







Playing Dungeons&Dragons online?

Check out my side project, Dwarven Academy!

Early-access to a new digital character sheet!



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.