Introduction
Page numbers are essential for document navigation, especially in multi-page PDFs. Whether you're preparing a report, thesis, or presentation, properly positioned page numbers improve readability and professionalism. In this article, we'll explore how to build a pure browser-side PDF page numbering tool with an intuitive visual interface that lets users draw exactly where they want page numbers to appear.
Why Browser-Side Page Numbering?
Traditional PDF tools often require server uploads or complex desktop software:
- Privacy Concerns: Documents uploaded to external servers
- Complex Interfaces: Desktop software with steep learning curves
- Limited Control: Predefined positions that may not match your layout
- Network Dependencies: Requires internet for processing
Browser-side visual positioning solves these issues:
- Documents stay on your device
- Intuitive click-and-drag interface
- Precise visual placement
- Works offline after loading
Architecture Overview
Our implementation combines visual canvas interaction with background PDF processing:
Core Data Structures
1. Rectangle Type (Normalized Coordinates)
// _components/pdfcoverdrawable.tsx
export type Rect = {
x: number; // Normalized X position (0-1)
y: number; // Normalized Y position (0-1)
width: number; // Normalized width (0-1)
height: number; // Normalized height (0-1)
};
Why Normalized (0-1)?
- PDF pages have different sizes (A4, Letter, etc.)
- Normalized coordinates scale correctly across any page dimension
- Canvas pixels → Normalized → PDF points conversion is straightforward
2. Drawing State
// _components/pdfcoverdrawable.tsx
type DropState = {
isDrawing: boolean;
startX: number;
startY: number;
currentRect: Rect | null;
rectangles: Rect[];
};
Implementation
1. Entry Point - Page Component
// pagenumber/page.tsx
import { type Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { seoConfig } from "../_components/seo-config";
import { Organize } from "@/app/[locale]/_components/qpdf/pagenumber";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const seo =
seoConfig[locale as keyof typeof seoConfig]?.pagenumber ||
seoConfig["en-us"].pagenumber;
return {
title: seo.title,
description: seo.description,
};
}
export default async function Page() {
return <Organize />;
}
2. Main Component - Page Number Organizer
// _components/qpdf/pagenumber.tsx
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { PdfPage } from "../pdfpage";
import { usePdflib } from "@/hooks/usepdflib";
import { autoDownloadBlob } from "@/utils/pdf";
import type { Rect } from "../pdfcoverdrawable";
export const Organize = () => {
const [files, setFiles] = useState<File[]>([]);
const [rect, setRect] = useState<Rect | null>(null);
const { pagenumber } = usePdflib();
const t = useTranslations("PageNumber");
const mergeInMain = async () => {
if (files.length === 0 || !rect) return;
// Add page numbers using selected position
const outputFile = await pagenumber(files[0]!, rect);
if (outputFile) {
autoDownloadBlob(new Blob([outputFile]), "addnumber.pdf");
}
};
const onPdfFiles = (files: File[]) => {
console.log("Files selected");
files.forEach((e) => console.log(e.name));
setFiles(files);
};
return (
<PdfPage
title={t("title")}
onFiles={onPdfFiles}
process={mergeInMain}
processDisabled={!rect}
>
<div className="text-sm text-gray-600 mb-4">
{t("instruction")}
</div>
</PdfPage>
);
};
3. Visual Positioning - Dual Canvas System
The PdfCoverDrawable component provides an interactive canvas for selecting the page number position:
// _components/pdfcoverdrawable.tsx
<div className="relative">
{/* PDF Preview Layer */}
<canvas ref={handleRef} />
{/* Drawing Interaction Layer */}
<canvas
ref={drawLayerRef}
className="absolute inset-0 z-10"
/>
</div>
Why Two Canvases?
- Bottom layer: Renders the PDF page preview (static)
- Top layer: Handles drawing interactions (dynamic)
- Separation prevents redrawing the PDF on every mouse move
4. Mouse Event Handling
// _components/pdfcoverdrawable.tsx
// Start drawing on mouse down
node.addEventListener("mousedown", (e) => {
const rect = node.getBoundingClientRect();
drawState.current = {
startX: e.clientX - rect.left,
startY: e.clientY - rect.top,
isDrawing: true,
currentRect: {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
width: 0,
height: 0,
},
rectangles: rectangles,
};
});
// Update rectangle on mouse move
node.addEventListener("mousemove", (e) => {
if (!isDrawing) return;
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
currentRect.width = currentX - startX;
currentRect.height = currentY - startY;
// Clear and redraw
clearCanvas();
redrawRectangles();
drawPreviewRect(currentRect); // Dashed line
});
// Finalize on mouse up
node.addEventListener("mouseup", () => {
if (isDrawing && currentRect) {
// Minimum size check (prevent accidental clicks)
if (Math.abs(currentRect.width) > 5 && Math.abs(currentRect.height) > 5) {
drawState.current.rectangles.push({ ...currentRect });
// Normalize to 0-1 range for PDF processing
const totalHeight = drawLayerRef.current?.clientHeight ?? 1;
onDrawRect({
x: currentRect.x / width,
width: currentRect.width / width,
y: currentRect.y / totalHeight,
height: currentRect.height / totalHeight,
});
}
}
});
5. Visual Feedback
// Dashed preview while dragging
function drawPreviewRect(rect: Rect) {
const ctx = drawLayerRef.current.getContext("2d");
ctx.beginPath();
ctx.strokeStyle = "#3b82f6"; // Blue
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]); // Dashed pattern
ctx.rect(rect.x, rect.y, rect.width, rect.height);
ctx.stroke();
}
// Solid rectangle after release
function drawSolidRect(rect: Rect) {
const ctx = drawLayerRef.current.getContext("2d");
ctx.beginPath();
ctx.strokeStyle = "#1e40af"; // Darker blue
ctx.lineWidth = 2;
ctx.setLineDash([]); // Solid line
ctx.rect(rect.x, rect.y, rect.width, rect.height);
ctx.stroke();
}
6. PDF Processing in Worker
// pdflib.worker.js
async function pagenumber(file, rect) {
const { x, y, width, height } = rect;
const existingPdfBytes = await file.arrayBuffer();
// Load existing PDF
const pdfDoc = await PDFDocument.load(existingPdfBytes);
// Embed standard font
const timesRomanFont = await pdfDoc.embedFont(StandardFonts.TimesRoman);
const textWidth = 20;
const textHeight = textWidth * 1.2;
// Add page numbers to all pages
for (const [index, page] of pdfDoc.getPages().entries()) {
const { width: pageWidth, height: pageHeight } = page.getSize();
// Convert normalized coordinates to PDF coordinates
// Center of the selected rectangle
const centerX = (x + width / 2) * pageWidth;
const centerY = (1 - (y + height / 2)) * pageHeight; // Flip Y for PDF-lib
console.log("Adding page number", rect, pageWidth, pageHeight, centerX, centerY);
// Draw page number centered in the selected area
page.drawText((index + 1) + "", {
x: centerX - textWidth / 2, // Center horizontally
y: centerY - textHeight / 2, // Center vertically
size: 20,
font: timesRomanFont,
color: rgb(0, 0.53, 0.71), // Blue color
});
}
// Save and return
const pdfBytes = await pdfDoc.save();
return pdfBytes;
}
Algorithm Breakdown:
- Load PDF: Parse existing PDF document
- Embed Font: Use TimesRoman standard font
- Iterate Pages: Process each page sequentially
-
Coordinate Conversion:
- Canvas Y (top-down) → PDF Y (bottom-up):
newY = (1 - y) * pageHeight - Center calculation:
x + width/2,y + height/2
- Canvas Y (top-down) → PDF Y (bottom-up):
- Draw Text: Center page number in selected rectangle
- Save: Export modified PDF
7. Coordinate System Transformation
Canvas Coordinates PDF Coordinates
(0,0) ───────→ X Y
│ ↑
↓ │
Y (0,0) ───────→ X
Top-Left Bottom-Left
Conversion: pdfY = (1 - canvasY) * pageHeight
Processing Flow
Complete User Flow
flowchart TD
Start([Open Tool]) --> Upload[Upload PDF]
Upload --> Render[Render First Page]
Render --> Draw[Draw Rectangle]
Draw --> Preview[Show Dashed Preview]
Preview --> Release[Release Mouse]
Release --> Confirm[Show Solid Rectangle]
Confirm --> Valid{Valid Size?}
Valid -->|No| Redraw[Redraw]
Redraw --> Draw
Valid -->|Yes> Enable[Enable Process Button]
Enable --> Click[Click Add Numbers]
Click --> Worker[Send to Worker]
Worker --> Process[Process All Pages]
Process --> Download[Auto-Download]
Download --> End([Done])
style Start fill:#e1f5fe
style End fill:#e8f5e9
style Worker fill:#fff3e0
Technical Highlights
1. Visual Precision
Users can position page numbers exactly where they want:
┌─────────────────────┐
│ │
│ [Page Content] │
│ │
│ │
│ ┌─────────┐ │
│ │ 1 │ ◄── User draws this box
│ └─────────┘ │
└─────────────────────┘
2. Coordinate Normalization
Pixel coordinates are normalized for PDF scaling:
// Canvas pixels → Normalized (0-1)
const normalizedRect = {
x: pixelX / canvasWidth,
y: pixelY / canvasHeight,
width: pixelWidth / canvasWidth,
height: pixelHeight / canvasHeight,
};
// Normalized → PDF points
const pdfX = normalizedX * pageWidth;
const pdfY = (1 - normalizedY) * pageHeight; // Flip Y
3. Centering Logic
Page numbers are centered in the selected rectangle:
const centerX = (x + width / 2) * pageWidth;
const centerY = (1 - (y + height / 2)) * pageHeight;
page.drawText(pageNumber, {
x: centerX - textWidth / 2,
y: centerY - textHeight / 2,
});
4. Font and Styling
Current implementation uses fixed styling:
{
font: timesRomanFont, // Times Roman
size: 20, // 20pt
color: rgb(0, 0.53, 0.71), // Blue
}
Browser Compatibility
Requirements:
- Canvas API - For visual positioning
- Web Workers - For background processing
- ES6+ - Modern JavaScript features
- File API - For reading PDFs
Supported in all modern browsers.
Conclusion
Building a visual PDF page numbering tool demonstrates how modern web technologies can create intuitive document editing experiences. By combining:
- Canvas API for visual positioning
- Dual-canvas architecture for smooth interactions
- pdf-lib for PDF text drawing
- Web Workers for background processing
- Comlink for seamless communication
We've created a tool that offers:
- Visual precision - Click and drag to position
- Immediate feedback - Dashed preview while drawing
- Consistent placement - Same position on all pages
- Complete privacy - Documents never leave the device
- Cross-platform - Works on any modern browser
The visual approach eliminates the guesswork of coordinate-based positioning, making PDF page numbering accessible to all users.
Ready to add page numbers to your PDFs? Try our free online tool at Free Online PDF Tools - just draw a box where you want the numbers to appear, and we'll add them to every page instantly, all in your browser!


Top comments (0)