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 (1)
The normalized-coordinate approach is a good fit for consistent placement. One edge case worth testing is a document that mixes Letter and A4 pages or includes rotated pages. Reading each page size and rotation before converting canvas coordinates to PDF points prevents the number from drifting. Scanned PDFs also do not need OCR for this specific operation because the number is drawn as new visible content; OCR is only needed if the scan must become searchable.