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 (0)