jsPDF and You

pdftypescriptjavascriptrefactoringlearningjsPDF

An introduction to creating PDFs with TypeScript

PDFs and You

Published {new Date(frontmatter.publishDate).toLocaleDateString()}

Making PDFs - nobody wants to talk about it! It’s unglamorous work, but at some point, many of us will have a need to generate PDFs programmatically in an app we’re responsible for maintaining. As with everything in JavaScript land, there are probably more solutions to this problem than there need to be. Today we’ll be taking a look at one of the most popular - a library called jsPDF.

As a library, jsPDF has been around in some form or another for over a decade and is still being actively maintained. That is to say, this is not some ancient PDF library forgotten by time. If you’ve used it in the past, you might be pleasantly surprised at some of the upgrades that it’s received in the last couple of years.

One of the biggest upgrades to jsPDF as a whole is the inclusion of types in the official package. This brings with it all the usual benefits - type checking at compilation time, better autocomplete in your editor - that are especially useful in an imperative-heavy library like this.

Today, we’ll set up a basic React app to generate our PDF, so all of our generating will be done in the browser. Of course, you could also do this without React, or do it server-side and send it to the client to download, or include as an attachment in an email - you can use it anywhere you can run JavaScript.

We’ll go through some patterns to make this a little nicer to maintain, and easier to reason about, and see the power of TypeScript in action. After each update to our code, you’ll see a “Generate PDF” button in this post. Go ahead and click it, and a PDF generated by the code we just wrote should open up in a new tab!

Outline

Installation and Setup

From your terminal, run:

npx create-react-app react-pdf-app --template typescript
cd react-pdf-app
npm start

If everything has gone correctly, you should see the usual spinning react atom. Great!

Next, we’ll install the jsPDF library:

npm install jspdf

Create two new files in the src directory:

touch src/utils.ts src/data.ts

For our exercise today, we’ll be making a packing list for a theoretical shipment we’re sending out. Using some mock data, we’ll generate a header, footer, and put our items on the document.

We’ll be shipping out enough items to necessitate using multiple pages, so we won’t want to do all that work twice. Instead, we’ll extract our header and footer code into functions that we can call for each page - making our imperative code just a little more declarative.

In data.ts, paste the following code:

// src/data.ts

export type item = {
  name: string;
  quantity: number;
};

export const data: item[] = [
  { name: 'Item 1', quantity: 25 },
  { name: 'Item 2', quantity: 49 },
  { name: 'Item 3', quantity: 19 },
  { name: 'Item 4', quantity: 9 },
  { name: 'Item 5', quantity: 6 },
  { name: 'Item 6', quantity: 3 },
  { name: 'Item 7', quantity: 36 },
  { name: 'Item 8', quantity: 13 },
  { name: 'Item 9', quantity: 12 },
  { name: 'Item 10', quantity: 28 },
  { name: 'Item 11', quantity: 19 },
  { name: 'Item 12', quantity: 34 },
  { name: 'Item 13', quantity: 17 },
  { name: 'Item 14', quantity: 42 },
  { name: 'Item 15', quantity: 32 },
  { name: 'Item 16', quantity: 30 },
  { name: 'Item 17', quantity: 5 },
  { name: 'Item 18', quantity: 38 },
  { name: 'Item 19', quantity: 1 },
  { name: 'Item 20', quantity: 10 },
  { name: 'Item 21', quantity: 37 },
  { name: 'Item 22', quantity: 42 },
  { name: 'Item 23', quantity: 23 },
  { name: 'Item 24', quantity: 44 },
  { name: 'Item 25', quantity: 43 },
  { name: 'Item 26', quantity: 4 },
  { name: 'Item 27', quantity: 7 },
  { name: 'Item 28', quantity: 16 },
  { name: 'Item 29', quantity: 27 },
  { name: 'Item 30', quantity: 20 },
  { name: 'Item 31', quantity: 8 },
  { name: 'Item 32', quantity: 39 },
  { name: 'Item 33', quantity: 43 },
  { name: 'Item 34', quantity: 21 },
  { name: 'Item 35', quantity: 39 },
  { name: 'Item 36', quantity: 26 },
  { name: 'Item 37', quantity: 39 },
  { name: 'Item 38', quantity: 32 },
  { name: 'Item 39', quantity: 34 },
  { name: 'Item 40', quantity: 1 },
  { name: 'Item 41', quantity: 34 },
  { name: 'Item 42', quantity: 4 },
  { name: 'Item 43', quantity: 19 },
  { name: 'Item 44', quantity: 12 },
  { name: 'Item 45', quantity: 1 },
  { name: 'Item 46', quantity: 37 },
  { name: 'Item 47', quantity: 2 },
  { name: 'Item 48', quantity: 28 },
  { name: 'Item 49', quantity: 22 },
  { name: 'Item 50', quantity: 2 },
];

In utils.js, we’ll start with a very basic generatePDF function, just enough to get some data on the page:

// src/utils.js

import { jsPDF } from 'jspdf';
import { item } from './data';

export function generatePDF(data: item[]): void {
  const doc = new jsPDF();
  const listofItems = data.map((item) => `${item.name} - ${item.quantity}`);
  doc.text(listofItems, 10, 10);
  window.open(doc.output('bloburl'));
}

Here, we instantiate a new PDF document with new jsPDF(). We then add some text to it with a call to the jsPDF.text() method, which takes string | string[] as its first parameter, the starting x position relative to the left edge of the document as the second parameter, and the y position relative to the top of the document a the third.

The default unit is in millimeters and the default document size is A4 in portrait orientation, but you can customize these when instantiating the document if you need to. Lastly, thanks to this StackOverflow post for demonstrating how to open the generated PDF in a new window instead of downloading it.

Now, update your app.tsx file to look like the following:

// src/app.tsx

import './App.css';
import { data } from './data';
import { generatePDF } from './utils';

function App() {
  const handleClick = () => {
    generatePDF(data);
  };

  return (
    <div className='App'>
      <header className='App-header'>
        <button onClick={handleClick}>Generate PDF</button>
      </header>
    </div>
  );
}

export default App;

Back in the browser, you should just see the “Generate PDF” button, like this one:

Go ahead and click it! You should be greeted with your very own PDF opening in a new tab. Congratulations, we’re done, right?

Adding Pages

Well, no, not quite. We don’t have the header or footer, and you might’ve noticed our list of items is so long that it runs off the page. Let’s tackle that last issue first.

Back in utils.ts, let’s take a closer look at that doc.text() line. Passing jsPDF.text() an array is a quick way to get some data on the page, but we’re going to need a little more fine-grained control.

How about:

// src/utils.ts

import { jsPDF } from 'jspdf';
import { item } from './data';

export function generatePDF(data: item[]): void {
  const doc = new jsPDF();
  const pageHeight =
    doc.internal.pageSize.height || doc.internal.pageSize.getHeight();
  const pageWidth =
    doc.internal.pageSize.width || doc.internal.pageSize.getWidth();
  const lineHeight = 8;
  const startingHeight = 10;
  let currentHeight = startingHeight;

  for (let item of data) {
    if (currentHeight > pageHeight) {
      doc.addPage();
      currentHeight = startingHeight;
    }
    doc.text(item.name, 10, currentHeight);
    doc.text(`${item.quantity}`, pageWidth - 10, currentHeight, {
      align: 'right',
    });
    currentHeight += lineHeight;
  }

  window.open(doc.output('bloburl'));
}

Now we’re talkin’! We pull the internal measurements for the height and width of the document and save those as constants for easy reference. We first set startingHeight and currentHeight variables as our “cursor” to keep track of where we are on the page.

By looping through the items in the data set and incrementing the currentHeight variable each time, we can check at each iteration of the loop if the height goes beyond what our page is - and add a page and reset our cursor in the new page if that’s the case.

We’ve also got our first look at the options object we can pass to the .text() method - in this case, we’re using it to align the right side of our text parameter with the given x coordinate by passing in { align: 'right' }.

Page Layout

Okay, we’ve got all of our items on visible pages. Now let’s tackle that header:

// src/utils.ts

import { jsPDF } from 'jspdf';
import { item } from './data';

export function generatePDF(data: item[]): void {
  const doc = new jsPDF();
  const pageHeight =
    doc.internal.pageSize.height || doc.internal.pageSize.getHeight();
  const pageWidth =
    doc.internal.pageSize.width || doc.internal.pageSize.getWidth();
  const lineHeight = 8;

  let currentHeight = createPdfHeader(doc);

  for (let item of data) {
    if (currentHeight > pageHeight) {
      doc.addPage();
      currentHeight = createPdfHeader(doc);
    }
    doc.setFontSize(12);
    doc.text(item.name, 10, currentHeight);
    doc.text(`${item.quantity}`, pageWidth - 10, currentHeight, {
      align: 'right',
    });
    currentHeight += lineHeight;
  }

  window.open(doc.output('bloburl'));
}

function createPdfHeader(doc: jsPDF): number {
  const pageWidth =
    doc.internal.pageSize.width || doc.internal.pageSize.getWidth();
  const startingHeight = 10;
  let currentHeight = startingHeight;
  doc.setFontSize(20);
  doc.text('Packing List', pageWidth / 2, currentHeight, { align: 'center' });
  currentHeight += 10;
  doc.line(10, currentHeight, pageWidth - 10, currentHeight);
  return currentHeight + 10;
}

Nice! We created a function to handle all of the imperative code around creating the header, so then we can just call that method in the main function - making our code more than just a little easier to grok and reason about. We use a similar cursor as in our main function to keep track of our height on the page.

A height is returned from the function so that we can use it to set our starting height post-header in the main function. We also have our first calls to setFontSize and line - fairly self explanatory, as well as a new option for our options object in text - { align: 'center' }. Something to keep in mind though, is that our createPdfHeader function operates on the same instance of the document as our main function.

This means setting the font size affects any text we would write after the header as well. It’s not a bad idea to get into the habit of explicitly setting the font size before you write any text if you’re dealing with frequent shifts in sizes. We’ve made the necessary change right before we add our items to the page in the loop of our main function.

Okay, our header looks great! Now for the footer - to do this we need to start keeping track of how many pages there are total, and what page we’re currently on.

// src/utils.ts

export function generatePDF(data: item[]): void {
  const doc = new jsPDF();
  const pageHeight =
    doc.internal.pageSize.height || doc.internal.pageSize.getHeight();
  const pageWidth =
    doc.internal.pageSize.width || doc.internal.pageSize.getWidth();
  const lineHeight = 8;

  let currentPage = 1;
  let currentHeight = createPdfHeader(doc);

  for (let item of data) {
    if (currentHeight > pageHeight) {
      doc.addPage();
      currentPage++;
      currentHeight = createPdfHeader(doc);
    }
    doc.setFontSize(12);
    doc.text(item.name, 10, currentHeight);
    doc.text(`${item.quantity}`, pageWidth - 10, currentHeight, {
      align: 'right',
    });
    currentHeight += lineHeight;
  }

  createPdfFooter(doc, currentPage);

  window.open(doc.output('bloburl'));
}

Similar to how we solved the header problem, we create a new function to handle creating our footer and adding the page count. Unlike our header function, however, we only call createPdfFooter once, at the end.

// src/utils.ts

function createPdfFooter(doc: jsPDF, numberOfPages: number) {
  for (let i = 1; i <= numberOfPages; i++) {
    doc.setPage(i);
    doc.line(
      10,
      doc.internal.pageSize.height - 20,
      doc.internal.pageSize.width - 10,
      doc.internal.pageSize.height - 20
    );
    doc.text(
      `Page ${i} of ${numberOfPages}`,
      10,
      doc.internal.pageSize.height - 10
    );
  }
}

The main reason we do this is because we can’t add the total number of pages to the count until we’ve added them all! We handle this by passing in the number of pages (we’ve been keeping track of every time we add a new one) and manually setting the page we’re working on with a call to jsPDF.setPage().

Whoops! We forgot to leave room for our footer while we were looping through our items. This is just a reminder that jsPDF is a powerful tool, but it has no issue with letting you write stuff on top of other stuff creating a big ole jumbled mess. Don’t worry, it’s all part of the process - especially as you get into more complicated layouts.

We’ll just tweak our height check while looping through the items:

// src/utils.ts

for (let item of data) {
  if (currentHeight > pageHeight - 30) {
    doc.addPage();
      currentPage++;
      currentHeight = createPdfHeader(doc);
    }
  doc.setFontSize(12);
  doc.text(item.name, 10, currentHeight);
  doc.text(`${item.quantity}`, pageWidth - 10, currentHeight, {
    align: 'right',
  });
  currentHeight += lineHeight;
}

Awesome! We’ve got a pretty decent looking packing slip now! Our code smells a little funny though:

  • We’ve got a bunch of “magic numbers” hardcoded in.
  • We’re repeating ourselves in some places we don’t need to.
  • There’s no headers for our list of items!

Let’s clean it up a bit, shall we?

Refactor

// src/utils.ts

import { jsPDF } from 'jspdf';
import { item } from './data';

type lineHeightFunction = (fontSize: number) => number;

interface IDocumentConfig {
  pageHeight: number;
  pageWidth: number;
  getLineHeight: lineHeightFunction;
  margin: number;
  normalFontSize: number;
  titleFontSize: number;
  numberOfPages: number;
}

export function generatePDF(data: item[]): void {
  const doc = new jsPDF();
  const pageHeight =
    doc.internal.pageSize.height || doc.internal.pageSize.getHeight();
  const pageWidth =
    doc.internal.pageSize.width || doc.internal.pageSize.getWidth();
  const getLineHeight = (fontSize: number): number => fontSize / 2;
  const margin = 15;
  const normalFontSize = 16;
  const titleFontSize = 28;
  let currentPage = 1;

  let config: IDocumentConfig = {
    pageHeight: pageHeight,
    pageWidth: pageWidth,
    getLineHeight: getLineHeight,
    margin: margin,
    normalFontSize: normalFontSize,
    titleFontSize: titleFontSize,
    numberOfPages: currentPage,
  };

  let currentHeight = createPdfHeader(doc, config);

  for (let item of data) {
    if (currentHeight > pageHeight - (margin + getLineHeight(normalFontSize))) {
      doc.addPage();
      currentPage++;
      config.numberOfPages = currentPage;
      currentHeight = createPdfHeader(doc, config);
    }
    doc.setFontSize(normalFontSize);
    doc.text(item.name, margin, currentHeight);
    doc.text(`${item.quantity}`, pageWidth - margin, currentHeight, {
      align: 'right',
    });
    currentHeight += getLineHeight(normalFontSize);
  }

  createPdfFooter(doc, config);

  window.open(doc.output('bloburl'));
}

function createPdfHeader(
  doc: jsPDF,
  {
    pageWidth,
    getLineHeight,
    margin,
    titleFontSize,
    normalFontSize,
  }: IDocumentConfig
): number {
  let currentHeight = margin;
  doc.setFontSize(titleFontSize);
  doc.text('Packing List', pageWidth / 2, currentHeight, { align: 'center' });
  currentHeight += getLineHeight(titleFontSize);
  doc.setFontSize(normalFontSize);
  doc.text('Item Name', margin, currentHeight);
  doc.text('Quantity', pageWidth - margin, currentHeight, { align: 'right' });
  currentHeight += getLineHeight(normalFontSize);
  doc.line(margin, currentHeight, pageWidth - margin, currentHeight);
  return currentHeight + getLineHeight(normalFontSize);
}

function createPdfFooter(
  doc: jsPDF,
  {
    pageWidth,
    pageHeight,
    getLineHeight,
    margin,
    normalFontSize,
    numberOfPages,
  }: IDocumentConfig
): void {
  for (let i = 1; i <= numberOfPages; i++) {
    doc.setPage(i);
    doc.line(
      margin,
      pageHeight - margin - getLineHeight(normalFontSize),
      pageWidth - margin,
      pageHeight - margin - getLineHeight(normalFontSize)
    );
    doc.text(`Page ${i} of ${numberOfPages}`, margin, pageHeight - margin);
  }
}

Wow! That’s a lot of changes, so let’s break it down into a couple of different things we did, to avoid a “draw the rest of the owl” kind of situation here. This is all a part of the process - moving fast and loose to get the desired outcome is okay, as long as we take the time to go back and refactor our code once we’ve got it working the way we want it.

Most of the changes we made were in two big steps:

  • Removing duplication in our code by defining all the measurements and size setting we’ll need at the top of our main function.
  • Defining an interface and instantiated a config object so we can pass those settings and measurements to our helper functions.

Now we can use the same measurements for margins, line heights, page dimensions and so on, without having them hardcoded in a bunch of different places. This allows us to say, update our margin and font sizes in just one place and have the whole document adjust automatically!

Recap

Today we learned how to put some text on a page, but in a little different way than the “moving rectangles around” method we’re used to. We learned how to add multiple pages dynamically and keep them numbered, align text in different ways, set different font sizes, and keep our code readable and maintainable.

Not bad for a days work! To learn more about jsPDF, I encourage you to play around with it and explore the official jsPDF docs here. Thanks for reading, and see you next time!