DEV Community

monkeymore studio
monkeymore studio

Posted on

Adding Page Numbers to PDFs: A Visual Canvas-Based Approach

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:

  1. Privacy Concerns: Documents uploaded to external servers
  2. Complex Interfaces: Desktop software with steep learning curves
  3. Limited Control: Predefined positions that may not match your layout
  4. 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)
};
Enter fullscreen mode Exit fullscreen mode

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[];
};
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Algorithm Breakdown:

  1. Load PDF: Parse existing PDF document
  2. Embed Font: Use TimesRoman standard font
  3. Iterate Pages: Process each page sequentially
  4. Coordinate Conversion:
    • Canvas Y (top-down) → PDF Y (bottom-up): newY = (1 - y) * pageHeight
    • Center calculation: x + width/2, y + height/2
  5. Draw Text: Center page number in selected rectangle
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Technical Highlights

1. Visual Precision

Users can position page numbers exactly where they want:

┌─────────────────────┐
│                     │
│    [Page Content]   │
│                     │
│                     │
│    ┌─────────┐      │
│    │   1     │ ◄── User draws this box
│    └─────────┘      │
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

4. Font and Styling

Current implementation uses fixed styling:

{
  font: timesRomanFont,        // Times Roman
  size: 20,                    // 20pt
  color: rgb(0, 0.53, 0.71),   // Blue
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
edchapman profile image
Edward Chapman •

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.