Tutorial: PDF Generation with Embedded Signatures using jsPDF
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
- Prerequisites
- Installing jsPDF
- Basic PDF Setup
- Adding Text Content
- Handling Page Breaks
- Embedding a Signature Image
- Adding Signature Lines
- Complete Example
- 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
| Method | Description |
|---|---|
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")
| From | To mm |
|---|---|
| 1 inch | 25.4 mm |
| 1 point | 0.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()ordoc.link()
