DEV Community

monkeymore studio
monkeymore studio

Posted on

PDF Cropping in the Browser: Building an Interactive Canvas-Based Tool

Introduction

PDF cropping is a common task when you need to remove unwanted margins, headers, footers, or specific areas from a document. In this article, we'll explore how to build a pure browser-side PDF cropping tool with an interactive canvas interface that allows users to visually select crop regions. This implementation runs entirely in the client's browser, ensuring complete privacy and instant processing.

Why Browser-Side PDF Cropping?

Processing PDFs in the browser offers significant advantages:

  1. Privacy First: Sensitive documents never leave the user's device - no server uploads means zero data breach risk
  2. Instant Results: No network latency means immediate processing, even for large PDFs
  3. Visual Feedback: Interactive canvas allows precise, visual crop region selection
  4. Offline Capability: Works without internet after initial page load
  5. Cost Efficient: No server infrastructure needed for PDF processing

Architecture Overview

Our implementation uses a layered architecture with clear separation between UI, state management, and PDF processing:

Core Data Structures

1. Rectangle Type (Normalized Coordinates)

// pdfcoverdrawable.tsx
export type Rect = {
  x: number;      // Top-left X (0-1 normalized)
  y: number;      // Top-left Y (0-1 normalized)
  width: number;  // Width (0-1 normalized)
  height: number; // Height (0-1 normalized)
};
Enter fullscreen mode Exit fullscreen mode

Why Normalized (0-1)?

  • PDF pages have different sizes (A4, Letter, etc.)
  • Normalized coordinates work across any page dimension
  • Canvas pixels → Normalized → PDF points conversion is straightforward

2. Crop Options (PDF Library Format)

// usepdflib.ts
export type CropOptions = {
  left: number;   // Margin from left edge (0-1)
  right: number;  // Margin from right edge (0-1)
  top: number;    // Margin from top edge (0-1)
  bottom: number; // Margin from bottom edge (0-1)
};
Enter fullscreen mode Exit fullscreen mode

Conversion Logic:

// crop.tsx - Converting UI rect to crop margins
const cropOptions = {
  left: rect?.x ?? 0,
  right: 1 - ((rect?.x ?? 0) + (rect?.width ?? 0)),
  top: rect?.y ?? 0,
  bottom: 1 - ((rect?.y ?? 0) + (rect?.height ?? 0)),
};
Enter fullscreen mode Exit fullscreen mode

Interactive Canvas Implementation

The heart of our tool is the PdfCoverDrawable component, which provides a visual interface for selecting crop regions.

Dual Canvas Architecture

// pdfcoverdrawable.tsx
<div className="relative">
  {/* PDF Preview Layer */}
  <canvas ref={handleRef} />

  {/* Drawing Overlay Layer */}
  <canvas 
    className="absolute inset-0 z-10"
    ref={drawLayerRef} 
  />
</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

Mouse Event Handling

// pdfcoverdrawable.tsx - Drawing logic

// 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 rect = node.getBoundingClientRect();
  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", () => {
  // Minimum size check (prevent accidental clicks)
  if (Math.abs(currentRect.width) > 5 && Math.abs(currentRect.height) > 5) {
    const totalHeight = drawLayerRef.current?.clientHeight ?? 1;

    // Normalize to 0-1 range for PDF processing
    onDrawRect({
      x: currentRect.x / width,
      width: currentRect.width / width,
      y: currentRect.y / totalHeight,
      height: currentRect.height / totalHeight,
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Visual Feedback

// Dashed preview while dragging
function drawPreviewRect(rect: Rect) {
  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) {
  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

PDF Processing with Web Workers

Worker Architecture

Core Crop Algorithm

// pdflib.worker.js
async function crop(file, options) {
  const { top, left, right, bottom } = options;
  const buffer = await file.arrayBuffer();
  const pdfDoc = await PDFDocument.load(buffer);

  for (const page of pdfDoc.getPages()) {
    // Get current page dimensions
    let { x, y, width, height } = page.getMediaBox();
    let { x: x1, y: y1, width: width1, height: height1 } = page.getCropBox();

    // Convert normalized (0-1) to PDF points
    const newLeft = left * width;
    const newRight = right * width;
    const newTop = top * height;
    const newBottom = bottom * height;

    // Update crop box (visible content area)
    page.setCropBox(
      x1 + newLeft,
      y1 + newBottom,  // PDF Y origin is at bottom
      width1 - newLeft - newRight,
      height1 - newTop - newBottom,
    );

    // Update media box (physical page size)
    page.setMediaBox(
      x + newLeft,
      y + newBottom,
      width - newLeft - newRight,
      height - newTop - newBottom,
    );
  }

  return pdfDoc.save();
}
Enter fullscreen mode Exit fullscreen mode

Understanding PDF Boxes:

  • MediaBox: Defines the physical page size
  • CropBox: Defines the visible area (what we modify)
  • We update both to ensure consistent behavior across PDF viewers

Comlink Integration

// usepdflib.ts
export const usePdflib = () => {
  const workerRef = useRef<Comlink.Remote<WorkerFunctions>>(null);

  useEffect(() => {
    async function initWorker() {
      const worker = new QlibWorker();
      workerRef.current = Comlink.wrap<WorkerFunctions>(worker);
    }
    initWorker();
  }, []);

  const crop = async (file: File, options: CropOptions) => {
    if (!workerRef.current) return null;
    return await workerRef.current.crop(file, options);
  };

  return { crop };
};
Enter fullscreen mode Exit fullscreen mode

Why Comlink?

  • Eliminates boilerplate postMessage code
  • Exposes worker functions as async/await promises
  • Type-safe communication between main thread and worker

Complete User Flow

Main Component Integration

// crop.tsx
export const Crop = () => {
  const [files, setFiles] = useState<File[]>([]);
  const [rect, setRect] = useState<Rect | null>(null);
  const { crop } = usePdflib();
  const t = useTranslations("Crop");

  const mergeInMain = async () => {
    // Convert UI rectangle to crop margins
    const outputFile = await crop(files[0]!, {
      left: rect?.x ?? 0,
      right: 1 - ((rect?.x ?? 0) + (rect?.width ?? 0)),
      top: rect?.y ?? 0,
      bottom: 1 - ((rect?.y ?? 0) + (rect?.height ?? 0)),
    });

    if (outputFile) {
      autoDownloadBlob(new Blob([outputFile]), "cropped.pdf");
    }
  };

  return (
    <PdfPage
      title={t("title")}
      onFiles={setFiles}
      process={mergeInMain}
    >
      <ImageSelector
        file={files[0]}
        onDrawRect={setRect}
        rectangles={rect ? [rect] : []}
      />
    </PdfPage>
  );
};
Enter fullscreen mode Exit fullscreen mode

Responsive Canvas with ResizeObserver

// pdfcoverdrawable.tsx
const resizeObserver = new ResizeObserver((entries) => {
  const entry = entries[0];
  if (entry) {
    const { width: displayWidth, height: displayHeight } = entry.contentRect;

    // Ensure canvas pixel dimensions match display dimensions
    if (node.width !== displayWidth || node.height !== displayHeight) {
      node.width = displayWidth;
      node.height = displayHeight;
    }
  }
});

resizeObserver.observe(node);
Enter fullscreen mode Exit fullscreen mode

Why This Matters:

  • Canvas pixel density affects drawing precision
  • Ensures crisp rendering on high-DPI displays
  • Maintains coordinate accuracy during resize

Technical Highlights

1. Coordinate System Conversion

Canvas Pixels → Normalized (0-1) → PDF Points
     ↓              ↓                ↓
   300px          0.5            306 points
   (on screen)   (relative)      (in PDF)
Enter fullscreen mode Exit fullscreen mode

2. Multi-Page Processing

The worker processes all pages with the same crop margins:

for (const page of pdfDoc.getPages()) {
  // Apply same crop to every page
  page.setCropBox(...);
  page.setMediaBox(...);
}
Enter fullscreen mode Exit fullscreen mode

This ensures consistency across the entire document.

3. File Download Utility

// pdf.ts
export function autoDownloadBlob(blob: Blob, filename: string) {
  const blobUrl = URL.createObjectURL(blob);
  const downloadLink = document.createElement("a");
  downloadLink.href = blobUrl;
  downloadLink.download = filename;
  downloadLink.style.display = "none";
  document.body.appendChild(downloadLink);
  downloadLink.click();
  document.body.removeChild(downloadLink);
  URL.revokeObjectURL(blobUrl);
}
Enter fullscreen mode Exit fullscreen mode

Creates a temporary anchor element to trigger the browser's native download behavior.

Browser Compatibility

This implementation requires:

  • Web Workers - For background PDF processing
  • Canvas API - For visual crop selection
  • ResizeObserver - For responsive canvas sizing
  • ES6+ - Modern JavaScript features

Supported in all modern browsers (Chrome, Firefox, Safari, Edge).

Conclusion

Building a browser-side PDF cropping tool demonstrates the power of modern web technologies. By combining:

  • Canvas API for interactive visual selection
  • Web Workers for background processing
  • pdf-lib for PDF manipulation
  • Comlink for seamless worker communication

We've created a tool that offers:

  • Complete privacy - Files never leave the device
  • Visual precision - Interactive canvas-based selection
  • Instant processing - No network delays
  • Cross-platform - Works on any device with a browser

The normalized coordinate system ensures accurate cropping across different PDF page sizes, while the dual-canvas architecture provides smooth visual feedback during selection.


Ready to crop your PDFs with precision? Try our free online tool at Free Online PDF Tools - it runs entirely in your browser with an interactive canvas interface. Your documents stay private, processing is instant, and no installation required!

Top comments (0)