When you build an app for clients, there will be point where they’ll ask for reporting.
What they usual mean by that, is to have a way to take a bunch of records from the database do some calculations and put it into a pdf.
So how do we build that?
Lets first look at the common client requests.
One of the most common is a “single record” report. For example when the user needs to present some results about his client. Or he needs to create a sales quote or even an invoice.
The single record is a bit misleading, because the report will start with a single customer, case or quote record, but it might also soak up related records. For the customer we might want to see the number of orders placed, for a case the number or even the topics of associated tasks, on a quote we usually want to see the products. And so on.
This report type is used to present data and/or to share the data with humans. We usually don’t expect it to be manipulated by another tool.
However if we need both a human readable (and pleasing) version and something machine readable, embedding a QR is the way to go.
The second type of report is a list or records. This one is usually less heavy on the actual design of the report and might just be fine as an excel file. The interesting part is to properly filter the records for output and again add related records or aggregations.
In this case, the report will be usually loaded into another app (excel being the most common), to be further processed, combined with other data sources and then either presented, or even fed back.
What are the parts we need for a reporting system?
The architecture of the system for single record reports will look something like this, (bottom up):
We shall look at steps 1 and 2. We will leave step 3 for a future blog (series?)
Our requirements dictated that the pdf had to be generated in the browser locally. And we wanted the most control, so we went with a browser based javascript solution, with no additional dependencies.
The library is called pdfkit.
npm install pdfkit
However this is a rather bare-bones, low level drawing library. Fear not we shall build what we need ourselves.
Our report format has only three shapes. A grid (table) that can contain text, image or another grid. To draw each of this object we assign a style object, that specifies colors, alignment, borders, margins, padding and for text the font to use.
Here are the typescript definitions for supported shapes.
export interface IPdfObject {
type: "text" | "image" | "grid" | "border";
className?: string; // style name
style?: IPdfStyle; // explicit style
pos?: [number, number]; // grid column and row
colSpan?: number; // grid column span, defaults to 1
measuredSize?: [number, number]; // measured size of the object
}
export interface IPdfText extends IPdfObject {
type: "text";
text: string;
link?: string;
}
export interface IPdfImage extends IPdfObject {
type: "image";
image: any; //base64, arrayBuffer;
size: [number, number];
}
export interface IPdfGrid extends IPdfObject {
type: "grid";
cols: string[]; // x fr, px, fit-content
rows: string[]; // auto or px
measuredCols: number[]; // measured widths of columns
measuredRows: number[]; // measured heights of rows
objects: IPdfObject[];
}
All the shapes inherit from the IPdfObject
which holds the common properties for all shapes.
For the visual attributes, we are following the css specification, that means
export interface IPdfStyle {
borderThickness?: number[];
borderColor?: string;
borderStyle?: string;
margin?: number[]; // top right bottom left
padding?: number[]; // top right bottom left
background?: string; // color
color?: string;
horzAlign?: "start" | "end" | "center" | "stretch";
vertAlign?: "start" | "end" | "center" | "stretch";
horzTextAlign?: "start" | "end" | "center" | "stretch"; // content alignment
vertTextAlign?: "start" | "end" | "center" | "stretch";
fontFamily?: string;
fontSize?: number;
pageBreakAfter?: boolean;
}
For convenience, we also have a few builder methods.
export const createGrid = (x: number, y: number, cols: string[], rows: string[], children?: IPdfObject[]) => {
const grid = {
pos: [x, y],
type: "grid",
cols: cols,
rows: rows,
className: "grid",
measuredCols: [], measuredRows: [],
objects: children || [],
} as IPdfGrid;
return grid;
}
export const addGrid = (parent: IPdfGrid, x: number, y: number, cols: string[], rows: string[], children?: IPdfObject[]) => {
const grid = createGrid(x, y, cols, rows, children);
addObject(parent, x, y, grid);
return grid;
}
export const addObject = (grid: IPdfGrid, x: number, y: number, obj: IPdfObject) => {
if (y < 0)
y = grid.rows.length;
if (grid.rows.length <= y) {
grid.rows.push("auto");
}
obj.pos = [x, y];
grid.objects.push(obj);
}
export const addText = (grid: IPdfGrid, x: number, y: number, className: string, text: string, style?: IPdfStyle) => {
const textObj = {
pos: [x, y],
type: "text",
text: text == null ? "" : text,
style: style,
className: className
} as IPdfText;
addObject(grid, x, y, textObj);
return textObj;
}
export const addImage = (grid: IPdfGrid, x: number, y: number, image: any, size: [number, number], className: string, style?: IPdfStyle) => {
const img = {
pos: [x, y],
type: "image",
image: image,
size: size,
style: style,
className: className
} as IPdfImage;
addObject(grid, x, y, img);
return img;
}
const getObjStyle = (obj: IPdfObject, styles: { [key: string]: IPdfStyle }) => {
let style = { ...((obj.className && styles[obj.className]) || {}) };
if (obj.style)
style = { ...style, ...obj.style };
return style;
}
How do we convert the report json into a pdf?
There are two passes.
For the measuring phase we have to
const measureGrid = (doc: idoc, parentWidth: number, obj: IPdfGrid, styles: { [key: string]: IPdfStyle }) => {
const rows = (obj.rows && obj.rows.length && obj.rows) || ["auto"];
const cols = (obj.cols && obj.cols.length && obj.cols) || ["1fr"];
const measuredCols: number[] = [];
let availWidth = parentWidth;
let lastFr = 0;
let fr = 0;
for (const c of cols) {
const num = + (c.substring(0, c.length - 2));
if (c.endsWith("px")) {
availWidth -= num;
} else {
fr += num;
lastFr = measuredCols.length;
}
measuredCols.push(num);
}
if (availWidth < 0)
availWidth = 0;
let spentFr = 0;
for (let i = 0; i < measuredCols.length; i++) {
const c = cols[i];
if (c.endsWith("fr")) {
if (i === lastFr) {
measuredCols[i] = availWidth - spentFr; // round up last column
} else {
const w = ((availWidth / fr) * measuredCols[i]) | 0;
measuredCols[i] = w;
spentFr += w;
}
}
}
const measuredRows: number[] = [];
for (const r of rows) {
let rowHeight = 0;
//if (r === "auto") {
for (const child of obj.objects) {
const pos = child.pos || [0, 0];
if (pos[1] === measuredRows.length) {
let parentWidth = measuredCols[pos[0]];
if (child.colSpan) {
parentWidth = measuredCols.slice(pos[0], pos[0] + (child.colSpan || 1)).reduce((prev, agg) => agg + prev, 0);
}
const h = Math.ceil(measureHeight(doc, parentWidth, child, styles));
if (rowHeight < h)
rowHeight = h;
}
}
//} else {
if (r !== "auto") {
rowHeight = +r;
}
measuredRows.push(rowHeight);
}
obj.measuredCols = measuredCols;
obj.measuredRows = measuredRows;
const width = measuredCols.reduce((acc, c) => (acc + c), 0);
const height = measuredRows.reduce((acc, c) => (acc + c), 0);
obj.measuredSize = [width, height];
return height;
}
const measureHeight = (doc: idoc, parentWidth: number, obj: IPdfObject, styles: { [key: string]: IPdfStyle }) => {
const style = getObjStyle(obj, styles);
const textObj = obj as IPdfText;
const padding = style.padding || [0, 0, 0, 0];
const margin = style.margin || [0, 0, 0, 0];
const border = style.borderThickness || [0, 0, 0, 0];
const outerWidth = padding[1] + padding[3] + margin[1] + margin[3] + border[1] + border[3];
const outerHeight = padding[0] + padding[2] + margin[0] + margin[2] + border[0] + border[2];
parentWidth -= outerWidth;
let sz: [number, number];// = [0, 0];
let height = 0;
switch (obj.type) {
case "text":
doc.font(style.fontFamily || "Helvetica");
doc.fontSize(style.fontSize || 12)
height = doc.heightOfString(textObj.text, { "width": parentWidth, height: Infinity });
height = Math.ceil(height);
const padRight = doc._fontSize / 6;
const width = Math.min(parentWidth, Math.ceil(doc.widthOfString(textObj.text, { "width": parentWidth, height: Infinity }) + padRight));
sz = [width, height];
break;
case "image":
const img = obj as IPdfImage;
sz = img.size || [100, 100];
if (sz[0] >= parentWidth) {
sz[1] = sz[1] * parentWidth / sz[0];
sz[0] = parentWidth;
}
break;
case "grid":
height = measureGrid(doc, parentWidth, obj as IPdfGrid, styles) + outerHeight;
sz = obj.measuredSize || [0, 0];
break;
default:
throw Error("unknown type:" + obj.type);
}
obj.measuredSize = [sz[0] + outerWidth, sz[1] + outerHeight];
return obj.measuredSize[1];
}
The render function, first aligns the drawing position according to the margins and the draws the borders and background.
This functionality is shared for all shape types.
After that, we switch by the shape type and draw the text (with proper alignment), image or grid.
Keep in mind that these methods are private. They will be called from the renderDocument function we will show later.
const render = (doc: idoc, size: [number, number], obj: IPdfObject, styles: { [key: string]: IPdfStyle }) => {
const style = getObjStyle(obj, styles);
const padding = style.padding || [0, 0, 0, 0];
const margin = style.margin || [0, 0, 0, 0];
const border = style.borderThickness || [0, 0, 0, 0];
const halign = style.horzAlign || "stretch";
const valign = style.vertAlign || "stretch";
let objSize = obj.measuredSize as [number, number];
//objSize = [objSize[0] - margin[1] - margin[3], objSize[1] - margin[0] - margin[2]];
//let sz = [size[0] - margin[1] - margin[3], size[1] - margin[0] - margin[2]];
let sz = [size[0], size[1]];
const align = (align: string, pos: number, i: number) => {
switch (align) {
case "center":
pos = pos + (sz[i] - objSize[i]) / 2;
break;
case "end":
pos = pos + sz[i] - objSize[i];
break;
case "start":
default:
break;
}
if (align !== "stretch")
sz[i] = objSize[i];
return pos;
}
doc.x = align(halign, doc.x, 0);
doc.y = align(valign, doc.y, 1);
doc.x += margin[3];
doc.y += margin[0];
sz[0] -= margin[3] + margin[1];
sz[1] -= margin[2] + margin[0];
const borderStyle = style.borderColor || "black";//{ color: style.borderColor || "black" };
if (style.borderStyle === "dotted")
doc.dash(1, { space: 2 });
else
doc.undash();
if (border[0] && border[0] === border[1] && border[0] === border[2] && border[0] === border[3]) {
const h = border[0] / 2;
doc.lineWidth(border[0]).rect(doc.x+h, doc.y+h, sz[0]-border[0], sz[1]-border[0]).stroke(borderStyle);
} else {
const right = doc.x + sz[0] - border[1];
const bottom = doc.y + sz[1];
if (border[0])
doc.lineWidth(border[0]).polygon([doc.x, doc.y], [right, doc.y]).stroke(borderStyle);
//doc.rect(doc.x, doc.y, sz[0], border[0]).fill(borderStyle);
if (border[1])
doc.lineWidth(border[1]).polygon([right, doc.y], [right, bottom]).stroke(borderStyle);
if (border[2])
doc.lineWidth(border[2]).polygon([doc.x, bottom], [right, bottom]).stroke(borderStyle);
//doc.rect(doc.x, doc.y + sz[1] - border[2], sz[0], border[2]).fill(borderStyle);
if (border[3])
doc.lineWidth(border[3]).polygon([doc.x, doc.y], [doc.x, bottom]).stroke(borderStyle);
//doc.rect(doc.x, doc.y, border[3], sz[1]).fill(borderStyle);
}
doc.x += border[3];
doc.y += border[0];
sz[0] -= border[3] + border[1];
sz[1] -= border[2] + border[0];
if (style.background)
doc.lineWidth(0).rect(doc.x, doc.y, sz[0], sz[1]).fillAndStroke(style.background, style.background);
doc.fill(style.color || "black");
doc.x += padding[3];
doc.y += padding[0];
sz[0] -= padding[3] + padding[1];
sz[1] -= padding[2] + padding[0];
const textObj = obj as IPdfText;
switch (obj.type) {
case "text":
doc.font(style.fontFamily || "Helvetica");
doc.fontSize(style.fontSize || 12)
const f = (doc as any)._font;
doc.y = doc.y + ((-f.descender + f.lineGap / 2) * doc._fontSize / 1000);
let textAlign = "left";
switch (style.horzTextAlign || "start") {
case "start": textAlign = "left"; break;
case "end": textAlign = "right"; break;
case "center": textAlign = "center"; break;
case "stretch": textAlign = "justify"; break;
}
const realHeight = (objSize[1]) - padding[0] - padding[2] - border[0] - border[2] - margin[0] - margin[2];
switch (style.vertTextAlign || "start") {
case "end": doc.y = doc.y + sz[1] - realHeight; break;
case "center": doc.y = doc.y + (sz[1] - realHeight) / 2; break;
}
const options = { width: sz[0], "baseline": "top", align: textAlign } as any;
if (textObj.link) {
options.link = textObj.link;
options.underline = true;
}
return doc.text(textObj.text, options); //(textObj.text, { "width": parentWidth, height: Infinity }); // fixme: border+padding
case "image":
const imgObj = obj as IPdfImage;
return doc.image(imgObj.image, { width: Math.min(sz[0],imgObj.size[0]) });
case "grid":
return renderGrid(doc, size, obj as IPdfGrid, styles);
default:
throw Error("unknown type:" + obj.type);
}
}
To draw the grid, we simply go over all the cells and call render
.
const renderGrid = (doc: idoc, size: [number, number], obj: IPdfGrid, styles: { [key: string]: IPdfStyle }) => {
const pageHeight = doc.page.height - doc.page.margins.top - doc.page.margins.bottom;
const docX = doc.x;
const docY = doc.y;
let startX = docX;
let startY = docY;
const rows = obj.measuredRows;
const cols = obj.measuredCols;
for (let y = 0; y < rows.length; y++) {
startX = docX;
if (doc.y + rows[y] > pageHeight) {
doc.addPage();
startY = doc.y;
}
for (let x = 0; x < cols.length; x++) {
for (const c of obj.objects) {
const p = c.pos || [0, 0];
if (p[0] === x && p[1] === y) {
doc.x = startX;
doc.y = startY;
let parentWidth = cols[x];
if (c.colSpan) {
parentWidth = cols.slice(x, x + (c.colSpan || 1)).reduce((prev, agg) => agg + prev, 0);
}
render(doc, [parentWidth, rows[y]], c, styles);
}
}
startX += cols[x];
}
startY += rows[y];
doc.y = startY; // ensure doc.Y is set correctly after this call.
}
doc.x = docX;
}
Finally to render the document we pass a grid
to the renderDocument
function.
import RobotoBold from './font/Roboto-Bold.ttf';
import RobotoLight from './font/Roboto-Light.ttf';
export const renderDocument = async (mainGrid: IPdfGrid, styles: { [key: string]: IPdfStyle }, docTitle?: string, docSize?: any) => {
const PDFDocument = window["PDFDocument" as any] as unknown as idoc;
docSize = docSize ? { ...docSize } : {};
if (!docSize.size)
docSize.size = "A4";
if (!docSize.margins)
docSize.margins = { left: 40, right: 40, top: 40, bottom: 40 };
let doc = new PDFDocument(docSize);
let stream = doc.pipe((window["blobStream" as any] as any)());
let fontUrl = RobotoLight;
let fontData = await (await fetch(fontUrl)).arrayBuffer();
doc.registerFont("roboto", fontData);
fontUrl = RobotoBold;
fontData = await (await fetch(fontUrl)).arrayBuffer();
doc.registerFont("roboto-Bold", fontData);
const docWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
measureHeight(doc, docWidth, mainGrid, styles);
render(doc, [docWidth, 10000], mainGrid, styles);
doc.info['Title'] = docTitle || 'Faktura';
doc.info['Author'] = 'Inuko.com';
doc.info['Producer'] = 'Inuko.net';
doc.info['Creator'] = 'Inuko.net';
doc.end();
const p = new Promise<Blob>((res, rej) => {
stream.on('finish', function () {
res(stream.toBlob("application/pdf"));
});
});
return p;
}
Now that we have a (PDF) report format and a function (renderDocument
) to convert it into a PDF, we can finally make some pdfs, yay!
Let's start with a simple report of an feedback session between peers (called inter-vision).
const generateIntervisionPdf = async (record: any) => {
const styles: { [key: string]: any } = {
title: {
margin: [2, 2, 20, 2],
fontSize: 24,
fontFamily: "roboto",
color: "navy",
},
subTitle: {
margin: [2, 2, 20, 2],
fontSize: 20,
fontFamily: "roboto",
color: "navy",
},
tableHeader: {
margin: [0, 0, 0, 0],
padding: [2, 2, 7, 2],
fontSize: 12,
fontFamily: "roboto-Bold",
color: "navy",
vertTextAlign: "center",
},
text: {
margin: [0, 0, 0, 0],
padding: [2, 2, 7, 2],
fontSize: 12,
fontFamily: "roboto",
color: "black",
vertTextAlign: "start",
}
};
styles["tableHeaderR"] = { ...styles["tableHeader"], horzTextAlign: "end" };
styles["textR"] = { ...styles["text"], horzTextAlign: "end" };
const docTitle = "Intervision";
const mainGrid = createGrid(0, 0, ["1fr"], ["auto"]);
addText(mainGrid, 0, 0, "title", record.name);
const titleGrid = addGrid(mainGrid, 0, 1, ["1fr", "1fr"], ["auto"]);
addText(titleGrid, 0, 0, "tableHeader", "Mentor");
addText(titleGrid, 1, 0, "tableHeaderR", "Family code");
addText(titleGrid, 0, 1, "text", record.ownerid.label);
addText(titleGrid, 1, 1, "textR", record.familyid.label);
const addItem = (label, text) => {
addText(mainGrid, 0, -1, "tableHeader", label);
addText(mainGrid, 0, -1, "text", text||"");
}
addItem("Who is doing the intervision", record.other_mentorid.label);
addItem("Date", new Date(record.startdate).toLocaleDateString());
addText(mainGrid, 0, -1, "subTitle", "Coffee talk");
addItem("1. What I'm good at and what works:", record.what_works);
addItem("2. What I should avoid:", record.what_to_avoid);
addItem("3. What can I do differently:", record.what_to_change);
addItem("4. My next meeting goal:", record.my_goal);
const blob = await renderDocument(mainGrid, styles, docTitle);
download(blob as any, record.name + ".pdf", "application/pdf");
}
export const download = (data: string, filename: string, type: string) => {
const file = new Blob([data], { type: type });
const a = document.createElement("a");
const url = URL.createObjectURL(file);
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(function () {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 0);
}
For better or worse pdfs are a part of our digital lifes.
We used them to share data with others, to archive information or just as a precursor to printing.
All clients will eventually require some level of PDF support.
Depending on your needs you could teach users to use the browser's print to PDF feature.
Or you could hide the browser somewhere on the server (in so called headless mode) and invoke the printing from code.
Or, if you need to do build the pdfs in the browser, and/or you need more control, feel free to reuse the code above.
Happy hacking!