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:
- Privacy First: Sensitive documents never leave the user's device - no server uploads means zero data breach risk
- Instant Results: No network latency means immediate processing, even for large PDFs
- Visual Feedback: Interactive canvas allows precise, visual crop region selection
- Offline Capability: Works without internet after initial page load
- 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)
};
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)
};
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)),
};
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>
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,
});
}
});
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();
}
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();
}
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 };
};
Why Comlink?
- Eliminates boilerplate
postMessagecode - 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>
);
};
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);
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)
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(...);
}
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);
}
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)