In today’s digital age, PDFs are ubiquitous. From contracts and reports to ebooks and manuals, they’re a fundamental part of how we share and consume information. But what if you need to highlight, comment, or sign a PDF? While dedicated PDF editors offer these features, they can be complex and expensive. Wouldn’t it be great to build your own simple, interactive PDF annotator, specifically tailored to your needs? This tutorial will guide you through creating a basic PDF annotator using TypeScript, empowering you to understand the underlying concepts and customize it further.
Why Build a PDF Annotator?
Creating a PDF annotator is a fantastic learning experience for several reasons:
- Practical Application: You gain hands-on experience with real-world problems.
- Frontend Skills: You’ll dive into HTML, CSS, and JavaScript (with TypeScript).
- PDF Manipulation: You’ll learn how to interact with and modify PDF files programmatically.
- TypeScript Proficiency: You’ll hone your TypeScript skills, including types, interfaces, and classes.
- Customization: You can tailor the annotator to your specific needs.
This tutorial will focus on the core features of annotation: highlighting text and adding simple text comments. We will be using the PDF.js library, a powerful open-source tool for rendering and manipulating PDFs in the browser. Let’s get started!
Setting Up Your Development Environment
Before we begin, ensure you have the following installed:
- Node.js and npm: Used for managing project dependencies. Download from nodejs.org.
- A code editor: (VS Code is recommended)
Create a new project directory and navigate into it using your terminal:
mkdir pdf-annotator
cd pdf-annotator
Initialize a new Node.js project:
npm init -y
Install the necessary dependencies:
npm install pdfjs-dist typescript --save-dev
Create a tsconfig.json file in your project root with the following content. This file configures the TypeScript compiler:
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"src/**/*"
]
}
Create a src directory and within it, create an index.ts file. This is where we’ll write our TypeScript code.
Building the HTML Structure
We’ll start by creating the basic HTML structure for our annotator. Create an index.html file in your project root with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Annotator</title>
<style>
#pdf-container {
width: 100%;
border: 1px solid #ccc;
}
.highlight {
background-color: yellow;
}
</style>
</head>
<body>
<div id="pdf-container"></div>
<script src="dist/index.js"></script>
</body>
</html>
This HTML sets up a container for the PDF and includes the compiled JavaScript file. The basic CSS styles the container and defines the highlight style.
Loading and Rendering the PDF
Now, let’s write the TypeScript code to load and render a PDF. Open src/index.ts and add the following code:
import * as pdfjsLib from 'pdfjs-dist';
const pdfContainer = document.getElementById('pdf-container') as HTMLDivElement | null;
async function renderPDF(pdfPath: string) {
if (!pdfContainer) {
console.error('PDF container not found.');
return;
}
try {
const loadingTask = pdfjsLib.getDocument(pdfPath);
const pdf = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.5 }); // Adjust scale as needed
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
console.error('Canvas context not found.');
continue;
}
canvas.width = viewport.width;
canvas.height = viewport.height;
pdfContainer.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext);
}
} catch (error) {
console.error('Error rendering PDF:', error);
}
}
// Replace 'your-pdf-file.pdf' with the path to your PDF file.
renderPDF('your-pdf-file.pdf');
Let’s break down this code:
- Import pdfjs-dist: We import the PDF.js library.
- Get the PDF container: We retrieve the HTML div where the PDF will be displayed.
- renderPDF function: This asynchronous function loads and renders the PDF.
- getDocument: This function loads the PDF from the specified path.
- Iterate through pages: We loop through each page of the PDF.
- getViewport: We create a viewport for each page, allowing us to control the scaling.
- Create a canvas: We create a canvas element for each page to render the PDF content.
- render: The `page.render()` function renders the PDF page onto the canvas.
Important: You need to replace 'your-pdf-file.pdf' with the actual path to your PDF file. Place your PDF file in the same directory as your index.html file, or adjust the path accordingly.
To compile the TypeScript code, run the following command in your terminal:
tsc
This command will generate a dist/index.js file. Open index.html in your browser. You should see your PDF rendered inside the pdf-container div.
Adding Text Highlighting Functionality
Now, let’s add the ability to highlight text. We’ll need to detect when the user clicks and drags the mouse to select text and then apply a highlight class to the selected text.
Modify the src/index.ts file with the following code. We’ll add event listeners for mouse events and a function to extract text and highlight it.
import * as pdfjsLib from 'pdfjs-dist';
const pdfContainer = document.getElementById('pdf-container') as HTMLDivElement | null;
let startX: number | null = null;
let startY: number | null = null;
let endX: number | null = null;
let endY: number | null = null;
let isMouseDown = false;
async function renderPDF(pdfPath: string) {
if (!pdfContainer) {
console.error('PDF container not found.');
return;
}
try {
const loadingTask = pdfjsLib.getDocument(pdfPath);
const pdf = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.5 }); // Adjust scale as needed
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
console.error('Canvas context not found.');
continue;
}
canvas.width = viewport.width;
canvas.height = viewport.height;
pdfContainer.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext);
// Add event listeners for highlighting
canvas.addEventListener('mousedown', (e) => {
startX = e.offsetX;
startY = e.offsetY;
isMouseDown = true;
});
canvas.addEventListener('mousemove', (e) => {
if (!isMouseDown) return;
endX = e.offsetX;
endY = e.offsetY;
// You can add a visual indication of the selection here (e.g., a rectangle)
});
canvas.addEventListener('mouseup', async (e) => {
isMouseDown = false;
endX = e.offsetX;
endY = e.offsetY;
if (startX !== null && startY !== null && endX !== null && endY !== null) {
await highlightText(page, viewport, startX, startY, endX, endY, canvas);
startX = null;
startY = null;
endX = null;
endY = null;
}
});
}
} catch (error) {
console.error('Error rendering PDF:', error);
}
}
async function highlightText(page: any, viewport: any, startX: number, startY: number, endX: number, endY: number, canvas: HTMLCanvasElement) {
const textContent = await page.getTextContent();
const textItems = textContent.items;
const context = canvas.getContext('2d');
if (!context) return;
for (const item of textItems) {
if ('str' in item && 'transform' in item) {
const [a, b, c, d, e, f] = item.transform;
const x = e;
const y = f;
const width = a * item.width;
const height = d * item.height;
// Convert canvas coordinates to PDF coordinates (approximate)
const pdfStartX = startX / viewport.scale;
const pdfStartY = startY / viewport.scale;
const pdfEndX = endX / viewport.scale;
const pdfEndY = endY / viewport.scale;
// Check if the text item is within the selected area (approximate)
if (
(x <= Math.max(pdfStartX, pdfEndX) && x + width >= Math.min(pdfStartX, pdfEndX) &&
y <= Math.max(pdfStartY, pdfEndY) && y + height >= Math.min(pdfStartY, pdfEndY)) ||
(x <= Math.max(pdfStartX, pdfEndX) && x + width >= Math.min(pdfStartX, pdfEndX) &&
y + height >= Math.min(pdfStartY, pdfEndY) && y <= Math.max(pdfStartY, pdfEndY))
) {
// Highlight the text
context.fillStyle = 'rgba(255, 255, 0, 0.5)'; // Semi-transparent yellow
context.fillRect(x * viewport.scale, (canvas.height - y) * viewport.scale, width * viewport.scale, -height * viewport.scale);
}
}
}
}
renderPDF('your-pdf-file.pdf');
Key changes include:
- Mouse Event Listeners: Added event listeners for
mousedown,mousemove, andmouseupon the canvas. These track the start and end points of the mouse selection. - Highlighting Logic: The
highlightTextfunction extracts text content from the PDF page, calculates the position of each text item and checks if it falls within the selected area. If it does, a semi-transparent yellow rectangle is drawn over the text, effectively highlighting it. - Coordinate Conversions: The code now includes approximate coordinate conversions to handle the viewport scaling.
Recompile the TypeScript code (tsc) and refresh your browser. Now you should be able to click and drag on the PDF to highlight text. The highlight may not be perfectly precise, but it provides a functional starting point.
Adding Text Comments
Let’s add the functionality to add text comments to the PDF. This involves allowing the user to click on a location on the PDF, open a text input, and then display the comment near the clicked location.
Modify the src/index.ts file as follows:
import * as pdfjsLib from 'pdfjs-dist';
const pdfContainer = document.getElementById('pdf-container') as HTMLDivElement | null;
let startX: number | null = null;
let startY: number | null = null;
let endX: number | null = null;
let endY: number | null = null;
let isMouseDown = false;
async function renderPDF(pdfPath: string) {
if (!pdfContainer) {
console.error('PDF container not found.');
return;
}
try {
const loadingTask = pdfjsLib.getDocument(pdfPath);
const pdf = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.5 }); // Adjust scale as needed
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
console.error('Canvas context not found.');
continue;
}
canvas.width = viewport.width;
canvas.height = viewport.height;
pdfContainer.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext);
// Add event listeners for highlighting
canvas.addEventListener('mousedown', (e) => {
startX = e.offsetX;
startY = e.offsetY;
isMouseDown = true;
});
canvas.addEventListener('mousemove', (e) => {
if (!isMouseDown) return;
endX = e.offsetX;
endY = e.offsetY;
// You can add a visual indication of the selection here (e.g., a rectangle)
});
canvas.addEventListener('mouseup', async (e) => {
isMouseDown = false;
endX = e.offsetX;
endY = e.offsetY;
if (startX !== null && startY !== null && endX !== null && endY !== null) {
await highlightText(page, viewport, startX, startY, endX, endY, canvas);
startX = null;
startY = null;
endX = null;
endY = null;
}
});
// Add event listener for adding comments
canvas.addEventListener('click', (e) => {
const x = e.offsetX;
const y = e.offsetY;
addComment(x, y, canvas, viewport);
});
}
} catch (error) {
console.error('Error rendering PDF:', error);
}
}
async function highlightText(page: any, viewport: any, startX: number, startY: number, endX: number, endY: number, canvas: HTMLCanvasElement) {
const textContent = await page.getTextContent();
const textItems = textContent.items;
const context = canvas.getContext('2d');
if (!context) return;
for (const item of textItems) {
if ('str' in item && 'transform' in item) {
const [a, b, c, d, e, f] = item.transform;
const x = e;
const y = f;
const width = a * item.width;
const height = d * item.height;
// Convert canvas coordinates to PDF coordinates (approximate)
const pdfStartX = startX / viewport.scale;
const pdfStartY = startY / viewport.scale;
const pdfEndX = endX / viewport.scale;
const pdfEndY = endY / viewport.scale;
// Check if the text item is within the selected area (approximate)
if (
(x <= Math.max(pdfStartX, pdfEndX) && x + width >= Math.min(pdfStartX, pdfEndX) &&
y <= Math.max(pdfStartY, pdfEndY) && y + height >= Math.min(pdfStartY, pdfEndY)) ||
(x <= Math.max(pdfStartX, pdfEndX) && x + width >= Math.min(pdfStartX, pdfEndX) &&
y + height >= Math.min(pdfStartY, pdfEndY) && y <= Math.max(pdfStartY, pdfEndY))
) {
// Highlight the text
context.fillStyle = 'rgba(255, 255, 0, 0.5)'; // Semi-transparent yellow
context.fillRect(x * viewport.scale, (canvas.height - y) * viewport.scale, width * viewport.scale, -height * viewport.scale);
}
}
}
}
function addComment(x: number, y: number, canvas: HTMLCanvasElement, viewport: any) {
// Create a text input element
const input = document.createElement('input');
input.type = 'text';
input.style.position = 'absolute';
input.style.left = `${x}px`;
input.style.top = `${y}px`;
input.style.zIndex = '10'; // Ensure it appears above the canvas
// Append the input to the container
pdfContainer?.appendChild(input);
// Handle the input's blur event (when the user clicks outside the input)
input.addEventListener('blur', () => {
const commentText = input.value;
if (commentText) {
// Display the comment (you can use a div or span for this)
const comment = document.createElement('div');
comment.textContent = commentText;
comment.style.position = 'absolute';
comment.style.left = `${x + 10}px`; // Adjust position
comment.style.top = `${y + 10}px`; // Adjust position
comment.style.backgroundColor = 'lightgray';
comment.style.padding = '5px';
comment.style.zIndex = '10';
pdfContainer?.appendChild(comment);
}
// Remove the input element
input.remove();
});
// Automatically focus on the input
input.focus();
}
renderPDF('your-pdf-file.pdf');
Key changes include:
- Click Event Listener: An event listener is added to the canvas to detect clicks.
- addComment Function: This function is triggered on a click. It creates a text input element, positions it near the click coordinates, and appends it to the PDF container.
- Blur Event Handling: An event listener is added to the input element for the
blurevent. When the user clicks outside the input (or presses Enter/Tab), this event triggers. - Comment Display: Inside the
blurhandler, if the input has text, adivelement is created to display the comment. Thisdivis positioned near the input and added to the PDF container. - Input Removal: The input element is removed after the comment is displayed.
- Focus: The input element is automatically focused when created.
Recompile the TypeScript code (tsc) and refresh your browser. Now, when you click on the PDF, a text input field should appear. Type your comment and click outside the input (or press Enter/Tab). The comment should then be displayed near the click location.
Addressing Common Mistakes and Improvements
While the above code provides a basic PDF annotator, there are several common mistakes and areas for improvement:
1. Precise Highlighting
The current highlighting is approximate. To improve it, you would need a more sophisticated algorithm to accurately determine the text bounding boxes. This often involves using the text content’s dimensions provided by PDF.js and mapping them to the canvas coordinates. This is a complex topic that would require advanced PDF.js techniques.
2. Coordinate Mapping
The coordinate mapping between the PDF content and the canvas is simplified. For complex PDFs with transformations, the calculations can be more intricate. Consider the use of PDF.js’s TextItem properties for more accurate positioning.
3. Comment Positioning
The comment positioning is basic. Consider adding features like drag-and-drop for comments, or connectors to the highlighted text.
4. Error Handling
Improve error handling to provide more informative messages to the user and prevent unexpected behavior. For example, check if the PDF file exists and can be loaded.
5. Performance
For large PDFs, rendering and highlighting can be slow. Consider optimizing performance by:
- Caching: Cache the rendered pages to avoid re-rendering.
- Lazy Loading: Only render pages as they become visible in the viewport.
- Debouncing/Throttling: Limit the frequency of highlighting updates.
6. User Interface
The user interface is very basic. Consider adding:
- Toolbar: A toolbar with options for highlighting, commenting, and other features.
- Color Picker: Allow users to choose the highlight color.
- Save/Load: Implement functionality to save and load annotations.
- Annotation List: Provide a list of all annotations for easy navigation.
7. Accessibility
Ensure the annotator is accessible to users with disabilities. Provide appropriate ARIA attributes and keyboard navigation.
Key Takeaways and Next Steps
This tutorial has provided a foundational understanding of building a simple PDF annotator with TypeScript and PDF.js. You’ve learned how to load and render a PDF, highlight text, and add text comments. The code is a starting point, and you can build upon it to create a more feature-rich and user-friendly application.
Here are some next steps to enhance your annotator:
- Implement more precise highlighting: Research PDF.js’s text extraction capabilities.
- Add a toolbar with various annotation tools: Implement different annotation types (e.g., underlines, strikethroughs).
- Enable saving and loading annotations: Store annotations in a format like JSON and load them back into the PDF.
- Improve the UI/UX: Create a more intuitive and user-friendly interface.
FAQ
Q: What is PDF.js?
A: PDF.js is a JavaScript library that allows you to render and manipulate PDF documents in web browsers. It’s developed by Mozilla and is open-source.
Q: Can I use this code in a commercial project?
A: Yes, PDF.js is licensed under the Apache License 2.0, which allows for commercial use.
Q: How can I handle different PDF files?
A: The code should work with most standard PDF files. However, very complex or corrupted PDFs might have rendering issues. You can add error handling to catch these cases.
Q: Can I add images or drawings to the PDF?
A: Yes, you can add images and drawings, but it requires more advanced techniques using the PDF.js API. You’d need to learn how to manipulate the canvas context to draw these elements and then integrate them with the PDF rendering process.
Q: How can I debug the code?
A: Use your browser’s developer tools (usually accessed by pressing F12). You can set breakpoints in your TypeScript code, inspect variables, and examine the console for error messages.
By building this basic annotator, you’ve taken a significant step in understanding how to interact with PDFs in web applications. The provided code, though basic, forms a solid foundation upon which you can build a powerful and customized tool. As you delve deeper into the intricacies of PDF.js and TypeScript, you’ll gain a deeper appreciation for the complexities and possibilities of working with PDF documents in the browser. The journey of creating a PDF annotator is not just about writing code; it’s about problem-solving, exploring new technologies, and building something useful. Embrace the challenges, learn from your mistakes, and enjoy the process of bringing your ideas to life.
