A photo can look fine in a large editor and still be hard to recognize as a tiny avatar. Zoom in too far and the circular preview cuts off part of the face. Zoom out and the person disappears into the background.
pfpcrop lets you adjust the photo while watching smaller avatar previews. You can download a square image or a circle with transparent corners. The selected photo is processed in your browser.
The cropping code uses plain JavaScript and Canvas, the browser's drawing surface. Below are simplified extracts of the loading, crop math, and export logic. They explain the implementation; they aren't a complete app to paste into an empty HTML file. The file picker, pointer handlers, and page layout still need to be connected.
Start with the photo on the device
Choosing a file doesn't have to upload it. A file input gives JavaScript a File object. Drag and drop and clipboard input can feed the same loader.
Keep the decoded image, its dimensions, and the crop position together:
const state = {
image: null,
width: 0,
height: 0,
centerX: 0,
centerY: 0,
zoom: 1,
type: 'image/png'
};
function loadBlob(blob) {
if (!blob || !blob.type.startsWith('image/')) return;
const url = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
state.image = image;
state.width = image.naturalWidth;
state.height = image.naturalHeight;
state.centerX = state.width / 2;
state.centerY = state.height / 2;
state.zoom = 1;
state.type = blob.type === 'image/jpeg'
? 'image/jpeg'
: 'image/png';
// Reset the visible zoom control and request a redraw here.
};
image.onerror = () => {
URL.revokeObjectURL(url);
// Show a visible error: the browser could not decode the image.
};
image.src = url;
}
The object URL is a temporary reference to the local file. It gives Image something to load without sending the photo to a server. Release that reference when loading succeeds or fails.
The image/ check alone doesn't prove the browser can decode the file. The error handler still needs a message in the interface. In pfpcrop, a failed decode shows an upload error rather than opening an empty editor.
Resetting zoom also matters when someone chooses a second photo. The new image should start with its largest square crop, not inherit the previous photo's zoom.
Keep the crop in the original photo's pixels
The editor may be 480 pixels wide on a desktop and narrower on a phone. Neither size should decide which part of the photo gets downloaded.
Store the crop center in source-image coordinates. For a square crop, the side length is the shorter image dimension divided by the zoom:
function cropSide() {
return Math.min(state.width, state.height) / state.zoom;
}
function sourceRect() {
const side = cropSide();
return {
x: state.centerX - side / 2,
y: state.centerY - side / 2,
side
};
}
function clampCenter() {
const half = cropSide() / 2;
state.centerX = Math.min(
Math.max(state.centerX, half),
state.width - half
);
state.centerY = Math.min(
Math.max(state.centerY, half),
state.height - half
);
}
For example, a 3000 × 2000 photo starts with a 2000 × 2000 crop. At zoom 2, the crop covers 1000 × 1000 source pixels. The zoom control must keep the value at least 1.
Call clampCenter() after dragging or changing the zoom. It keeps the square inside the image, including when someone drags all the way to an edge.
Dragging needs one conversion. Pointer movement is measured in screen pixels; the crop is measured in source pixels:
function movePhoto(deltaX, deltaY, editorSize) {
const displayPixelsPerSourcePixel = editorSize / cropSide();
state.centerX -= deltaX / displayPixelsPerSourcePixel;
state.centerY -= deltaY / displayPixelsPerSourcePixel;
clampCenter();
// Request a redraw after updating the position.
}
Here, editorSize is the displayed square's width in CSS pixels, and the deltas come from successive pointer positions. Subtracting the deltas makes the photo follow the drag beneath the fixed crop area. The pointer handlers also need to track when dragging starts and ends.
Draw the same crop at every preview size
Each preview uses the same source rectangle. A smaller canvas changes the output size without changing the selection.
function drawInto(canvas, clipCircle) {
const context = canvas.getContext('2d');
const crop = sourceRect();
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
if (clipCircle) {
context.beginPath();
context.arc(
canvas.width / 2,
canvas.height / 2,
canvas.width / 2,
0,
Math.PI * 2
);
context.clip();
}
context.imageSmoothingQuality = 'high';
context.drawImage(
state.image,
crop.x, crop.y, crop.side, crop.side,
0, 0, canvas.width, canvas.height
);
context.restore();
}
All canvases passed to this helper are square. Use false for the editor and true for a circular preview. The circle is a clipping region: drawing only changes pixels inside it.
In the cropper, a pending-frame flag groups redraw requests before calling requestAnimationFrame(). Calling that browser function on every pointer event without a flag could still queue several callbacks for the same frame.
Preview sharpness is separate from crop size. The implementation multiplies the canvas's internal width and height by Math.min(window.devicePixelRatio || 1, 2), while keeping its displayed CSS dimensions unchanged. That caps the preview buffer size; it doesn't cap the downloaded image.
Choose the download size separately
The default download uses Math.max(1, Math.round(cropSide())). A crop covering 1200 source pixels produces a 1200 × 1200 file.
Some pages also offer explicit sizes. The Discord cropper has 128 × 128 and 512 × 512 presets, plus a custom integer size from 1 to 4096. An explicit size can be larger than the selected crop, so the interface warns when the image will be enlarged. More output pixels won't recover detail missing from the source.
Pass that chosen size to the export function:
function download(circle, requestedSize = null) {
if (!state.image) return;
const side = requestedSize === null
? Math.max(1, Math.round(cropSide()))
: requestedSize;
if (!Number.isInteger(side) || side < 1) return;
if (requestedSize !== null && side > 4096) return;
const output = document.createElement('canvas');
output.width = side;
output.height = side;
drawInto(output, circle);
const type = circle ? 'image/png' : state.type;
const quality = type === 'image/jpeg' ? 0.92 : undefined;
output.toBlob((blob) => {
if (!blob) return;
const extension = blob.type === 'image/jpeg' ? 'jpg' : 'png';
const shape = circle ? '-circle' : '';
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'pfp-' + side + 'x' + side
+ shape + '.' + extension;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}, type, quality);
}
This helper accepts a validated numeric size from the interface. It leaves the current selection alone and draws it into a new canvas at the requested resolution.
pfpcrop uses PNG for circular downloads because PNG supports transparency and browsers must support it for canvas export. PNG isn't the only image format that supports transparency; it's the format this tool chooses. JPEG doesn't preserve transparent corners.
For square downloads, JPEG input is exported as JPEG; other accepted image types are exported as PNG. The filename must match the encoded format. Naming JPEG data .png doesn't convert it.
The example uses the returned blob's type to choose the extension. It only requests PNG or JPEG, and PNG is the browser's fallback if a requested format isn't supported. A null blob means export failed; a complete interface should report that failure instead of silently returning as this extract does.
What to check before calling it finished
Try a wide photo, then a tall one. Drag each to every edge and change the zoom there. The crop should remain inside the image, and the previews should keep showing the same selection.
Then load another photo. Check that the zoom resets. Export a JPEG square and a PNG circle, inspect the file dimensions, and check that the circle's corners are transparent. If the page offers custom sizes, check invalid input and an output larger than the source crop.
Large images can still use substantial memory after decoding. Keeping processing local doesn't remove the browser's limits, and these extracts don't include a large-file limit or a full error interface.
The privacy claim is about the selected photo. A website can load fonts, scripts, or analytics over the network while processing an image locally; those requests need a separate review.
You can try pfpcrop and compare the small previews while dragging. If you're adapting the code, keep the source rectangle shared between preview and export so the downloaded crop matches what the person selected.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.