Skip to main content

Tutorial: PDF Generation with Embedded Signatures using jsPDF

PDF with embedded signature example

This tutorial walks through how to generate a PDF document with an embedded signature image using the jsPDF library in a React/TypeScript application.


Table of Contents

  1. Prerequisites
  2. Installing jsPDF
  3. Basic PDF Setup
  4. Adding Text Content
  5. Handling Page Breaks
  6. Embedding a Signature Image
  7. Adding Signature Lines
  8. Complete Example
  9. Troubleshooting

Prerequisites

  • Node.js/Bun installed
  • A React or TypeScript project
  • Basic understanding of async/await
  • A signature captured as a base64 data URL (e.g., from react-signature-canvas)

Installing jsPDF

# Using bun
bun add jspdf

# Using npm
npm install jspdf

# Using yarn
yarn add jspdf

Basic PDF Setup

Step 1: Import jsPDF

import { jsPDF } from "jspdf";

Step 2: Create a PDF Document

const doc = new jsPDF({
orientation: "portrait", // or "landscape"
unit: "mm", // millimeters (also: "pt", "px", "in", "cm")
format: "letter", // or "a4", [width, height] for custom
});

Step 3: Get Page Dimensions

const pageWidth = doc.internal.pageSize.getWidth();   // 215.9mm for letter
const pageHeight = doc.internal.pageSize.getHeight(); // 279.4mm for letter
const margin = 25; // 25mm margins
const contentWidth = pageWidth - margin * 2;

Adding Text Content

Setting Font Styles

// Font size (in points)
doc.setFontSize(12);

// Font family and style
doc.setFont("helvetica", "bold"); // bold
doc.setFont("helvetica", "normal"); // regular
doc.setFont("helvetica", "italic"); // italic

// Text color (RGB values 0-255)
doc.setTextColor(0, 51, 98); // Dark blue #003362
doc.setTextColor(16, 24, 40); // Near black #101828
doc.setTextColor(74, 85, 101); // Gray #4a5565

Adding Text

let yPosition = margin; // Start at top margin

// Simple text
doc.text("Hello World", margin, yPosition);
yPosition += 10; // Move down 10mm

// Right-aligned text
const rightText = "Right aligned";
const textWidth = doc.getTextWidth(rightText);
doc.text(rightText, pageWidth - margin - textWidth, yPosition);

Word Wrapping Long Text

const longText = "This is a very long paragraph that needs to wrap...";
const splitLines = doc.splitTextToSize(longText, contentWidth);

for (const line of splitLines) {
doc.text(line, margin, yPosition);
yPosition += 5; // Line height
}

Handling Page Breaks

When adding content, you need to check if there's enough space on the current page.

// Helper function to check and add page if needed
const checkPageBreak = (neededHeight: number) => {
if (yPosition + neededHeight > pageHeight - margin) {
doc.addPage();
yPosition = margin; // Reset to top of new page
}
};

// Usage
checkPageBreak(20); // Need 20mm of space
doc.text("This text will be on a new page if needed", margin, yPosition);

Embedding a Signature Image

Understanding Base64 Data URLs

Signatures from canvas elements are typically in this format:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...

jsPDF's addImage() needs just the base64 data without the prefix.

Step 1: Strip the Data URL Prefix

function prepareImageData(dataUrl: string): { data: string; format: "PNG" | "JPEG" } {
let imgData = dataUrl;
let imgFormat: "PNG" | "JPEG" = "PNG";

if (dataUrl.startsWith("data:image/png;base64,")) {
imgData = dataUrl.replace("data:image/png;base64,", "");
imgFormat = "PNG";
} else if (dataUrl.startsWith("data:image/jpeg;base64,") ||
dataUrl.startsWith("data:image/jpg;base64,")) {
imgData = dataUrl.replace(/data:image\/jpe?g;base64,/, "");
imgFormat = "JPEG";
} else if (dataUrl.startsWith("data:")) {
// Generic handling - split at comma
imgData = dataUrl.split(",")[1] || dataUrl;
}

return { data: imgData, format: imgFormat };
}

Step 2: Add the Image to the PDF

const signatureData = "data:image/png;base64,iVBORw0KGgo..."; // From signature pad

if (signatureData) {
try {
const { data, format } = prepareImageData(signatureData);

doc.addImage(
data, // Base64 image data (without prefix)
format, // "PNG" or "JPEG"
margin, // X position (mm from left)
yPosition, // Y position (mm from top)
60, // Width in mm
25, // Height in mm
undefined, // Alias (optional)
"FAST" // Compression: "FAST", "MEDIUM", "SLOW"
);

yPosition += 28; // Move below the image
} catch (error) {
console.error("Failed to add signature image:", error);
// Add fallback text
doc.setFont("helvetica", "italic");
doc.text("[Signature on file]", margin, yPosition);
yPosition += 8;
}
}

Image Sizing Tips

  • Maintain aspect ratio: Signature pads are usually wider than tall (e.g., 400x200 pixels)
  • Good PDF sizes: 60mm wide x 25mm tall works well for signatures
  • Use compression: "FAST" reduces file size with minimal quality loss

Adding Signature Lines

After the signature image, add a line and labels:

// Draw a horizontal line
doc.setDrawColor(16, 24, 40); // Line color (RGB)
doc.line(
margin, // Start X
yPosition, // Start Y
margin + 80, // End X (80mm line)
yPosition // End Y (same as start for horizontal)
);
yPosition += 5;

// Add signer name below the line
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(16, 24, 40);
doc.text("John Doe", margin, yPosition);
yPosition += 5;

// Add date
doc.setTextColor(74, 85, 101);
doc.text("Signed on January 21, 2026", margin, yPosition);

Complete Example

Here's a complete function that generates a PDF with content and signature:

import { jsPDF } from "jspdf";

interface GeneratePdfInput {
title: string;
content: string;
recipientName: string;
signatureData: string | null; // Base64 data URL
signerName: string | null;
signedAt: Date | null;
}

export async function generateSignedPdf(input: GeneratePdfInput): Promise<void> {
const { title, content, recipientName, signatureData, signerName, signedAt } = input;

// Create PDF document
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "letter",
});

const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 25;
const contentWidth = pageWidth - margin * 2;
let yPosition = margin;

// Helper for page breaks
const checkPageBreak = (neededHeight: number) => {
if (yPosition + neededHeight > pageHeight - margin) {
doc.addPage();
yPosition = margin;
}
};

// === HEADER ===
doc.setFontSize(20);
doc.setFont("helvetica", "bold");
doc.setTextColor(0, 51, 98);
doc.text("Company Name", margin, yPosition);
yPosition += 10;

// Date (right-aligned)
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(74, 85, 101);
const dateStr = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
doc.text(dateStr, pageWidth - margin - doc.getTextWidth(dateStr), margin);

// Recipient
doc.setFontSize(11);
doc.setTextColor(16, 24, 40);
doc.text(recipientName, margin, yPosition);
yPosition += 15;

// === TITLE ===
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(0, 51, 98);
doc.text(title, margin, yPosition);
yPosition += 10;

// === CONTENT ===
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(55, 65, 81);

const paragraphs = content.split("\n\n");
for (const paragraph of paragraphs) {
if (!paragraph.trim()) continue;

checkPageBreak(10);
const lines = doc.splitTextToSize(paragraph.trim(), contentWidth);
for (const line of lines) {
checkPageBreak(5);
doc.text(line, margin, yPosition);
yPosition += 5;
}
yPosition += 3; // Paragraph spacing
}

// === CLIENT SIGNATURE SECTION ===
if (signatureData || signerName || signedAt) {
checkPageBreak(50);
yPosition += 10;

// Section header
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(16, 24, 40);
doc.text("Client Signature", margin, yPosition);
yPosition += 8;

// Add signature image
if (signatureData) {
try {
let imgData = signatureData;
let imgFormat: "PNG" | "JPEG" = "PNG";

if (signatureData.startsWith("data:image/png;base64,")) {
imgData = signatureData.replace("data:image/png;base64,", "");
} else if (signatureData.startsWith("data:image/jpeg;base64,")) {
imgData = signatureData.replace(/data:image\/jpe?g;base64,/, "");
imgFormat = "JPEG";
} else if (signatureData.startsWith("data:")) {
imgData = signatureData.split(",")[1] || signatureData;
}

doc.addImage(imgData, imgFormat, margin, yPosition, 60, 25, undefined, "FAST");
yPosition += 28;
} catch (error) {
console.error("Failed to add signature:", error);
doc.setFont("helvetica", "italic");
doc.setTextColor(107, 114, 128);
doc.text("[Signature on file]", margin, yPosition);
yPosition += 8;
}
}

// Signature line
doc.setDrawColor(16, 24, 40);
doc.line(margin, yPosition, margin + 80, yPosition);
yPosition += 5;

// Signer name
if (signerName) {
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(16, 24, 40);
doc.text(signerName, margin, yPosition);
yPosition += 5;
}

// Signed date
if (signedAt) {
doc.setTextColor(74, 85, 101);
const signedDateStr = new Date(signedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
doc.text(`Signed on ${signedDateStr}`, margin, yPosition);
yPosition += 5;
}
}

// === PRACTITIONER SIGNATURE (Empty for manual signing) ===
checkPageBreak(40);
yPosition += 15;

doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(16, 24, 40);
doc.text("Authorized Representative", margin, yPosition);
yPosition += 25; // Space for manual signature

// Signature line
doc.setDrawColor(16, 24, 40);
doc.line(margin, yPosition, margin + 80, yPosition);
yPosition += 5;

// Label
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(74, 85, 101);
doc.text("Name & Title", margin, yPosition);
yPosition += 10;

// Date line
doc.line(margin, yPosition, margin + 50, yPosition);
yPosition += 5;
doc.text("Date", margin, yPosition);

// === SAVE PDF ===
const fileName = `${title.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
doc.save(fileName);
}

Troubleshooting

Common Issues

1. "Invalid image format" error

Cause: The base64 data still has the data URL prefix.

Solution: Strip the prefix before calling addImage():

imgData = signatureData.replace("data:image/png;base64,", "");

2. Signature appears stretched or squished

Cause: Wrong aspect ratio in addImage() dimensions.

Solution: Calculate dimensions based on original aspect ratio:

const originalWidth = 400;  // Canvas width in pixels
const originalHeight = 200; // Canvas height in pixels
const pdfWidth = 60; // Desired width in mm
const pdfHeight = (originalHeight / originalWidth) * pdfWidth;

3. Content runs off the page

Cause: Not checking for page breaks.

Solution: Always use checkPageBreak() before adding content:

checkPageBreak(10); // Check if 10mm of space is available
doc.text("My text", margin, yPosition);

4. PDF file is very large

Cause: Uncompressed images.

Solution: Use "FAST" compression:

doc.addImage(data, "PNG", x, y, w, h, undefined, "FAST");

5. Fonts look different than expected

Cause: jsPDF only includes a few built-in fonts.

Solution: Use built-in fonts:

  • helvetica (normal, bold, italic, bolditalic)
  • times (normal, bold, italic, bolditalic)
  • courier (normal, bold, italic, bolditalic)

For custom fonts, you need to embed them (more complex).


Quick Reference

Common Methods

MethodDescription
doc.text(text, x, y)Add text at position
doc.setFontSize(size)Set font size in points
doc.setFont(family, style)Set font family and style
doc.setTextColor(r, g, b)Set text color
doc.addImage(data, format, x, y, w, h)Add image
doc.line(x1, y1, x2, y2)Draw line
doc.addPage()Add new page
doc.save(filename)Download PDF
doc.splitTextToSize(text, maxWidth)Word wrap text
doc.getTextWidth(text)Get text width

Units Conversion (when using "mm")

FromTo mm
1 inch25.4 mm
1 point0.353 mm
1 pixel (96dpi)0.265 mm

Next Steps

  • Add headers/footers: Use doc.internal.getNumberOfPages() and loop through pages
  • Add page numbers: Add text at the bottom of each page
  • Add watermarks: Use doc.setGState() for transparency
  • Add clickable links: Use doc.textWithLink() or doc.link()

Written by

Sean Stuart Urgel
Senior Software Engineer @ Casper Studios