July 19, 2024

Generate pdfs in browser

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?

Invoice report

What the users want?

Lets first look at the common client requests.

Single record report

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.

Multi record (list) report

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.

Building the reporting system

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):

  1. A way to generate pdf files (duh:)
  2. A report file (format) that specifies the content of the pdf file. We pass this file to the first level and out comes a pdf. This file would contain the data and the visual styling, and it can even be dynamic with embedded data-queries. In a nutshell, put the name of client in the top left corner, in bold and red.
  3. A tool where a user can visually build the report, for example drag the client-name attribute onto a canvas a choose bold font and red color.

We shall look at steps 1 and 2. We will leave step 3 for a future blog (series?)

How can we generate pdfs?

  • there are many many many ways, to do it, depending on your needs and how your app/system is built so far.
  • As this is a fairly common need, there are packages for almost every environment and language.
  • The most advanced packages can convert any html to pdf... by using an embedded browser engine (yay).

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.

Report format

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.

  1. className and style define the visual attributes of the shape. More later.
  2. pos - is an 2 element array of column and row position in grid.
  3. colSpan - optional number of columns this element spans. defaults to 1
  4. measuredSize - only used internally after the measure phase.

For the visual attributes, we are following the css specification, that means

  1. Colors are argb hex strings
  2. Margin and padding is a 4 number array of thickness, in the css direction: top right bottom left.
  3. Border is drawn between margin and padding.
  4. horzAlign and vertAlign align the shape within the grid cell
  5. horzTextAlign aligns the shape content within the shape.
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;
}

Generating the pdf

How do we convert the report json into a pdf?
There are two passes.

  1. We measure
  2. We paint

For the measuring phase we have to

  1. Calculate the physical (pixel) size of grid columns. We only support explicit pixel or fractional units.
  2. Then we fit the elements into to columns. This will give us the desired height - depending on the actual text string and the font size and all other style properties one or more lines of text will be needed. If the image width is larger than the grid column(s) size, we shall adjust the image width and height both to maintain aspect ration.
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];
}
  1. Drawing. The important part here is, that the pdf primitive drawing functions maintain a current position and paint starting at the current position and advancing the current position to the last point of the primitive (like line star and end points).

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;
}

Putting in together.

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).

  1. We setup styles we will be using.
  2. We add a mainGrid - the root of our report.
  3. We add a titleGrid as a sub-grid to the main grid, which contains the user name and date.
  4. Then we add the questions and answers as text shapes to the mainGrid.
  5. Once the report is built, we pass it to the renderDocument method.
  6. Finally we use a helper function to start the download of the pdf.
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);
}

Final page ;)

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!

Continue reading

Drag and drop (it like it's hot)

November 03, 2024
Drag and drop (it like it's hot)

Skeuomorphism - the idea that digital elements resemble their real-world counterparts - arguably reached its peak with Apple’s iOS 6. However the idea is both much older than iOS and still very much alive today.

Ultimate HTML input elements

Processing bank transfer in Salesforce.com

©2022-2024 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net