Skip to content
Devix Open Source

Guide

Getting the picture out

Every popular cropper gives you a rectangle and stops. Turning that rectangle into a picture is where the bugs are, and it is the same four every time.

The four mistakes, and what is done instead

Drawing the on-screen copy. The image you can see is scaled to fit a box; drawing that into a canvas gives you a crop at screen resolution, which looks soft when it is displayed any larger. Here every render is taken from the decoded original, so a crop of a 12-megapixel photo is as sharp as the photo.

Slicing at a fractional coordinate. drawImage(img, sx, sy, sw, sh, …) with an sx of 40.5 samples half a pixel past the edge, and where there is nothing to sample you get a dark line — the seam that has been an open bug on the most-starred cropper for years. Here the whole image is drawn under a transform and the canvas does the clipping, so there is no fractional slice at all. A test walks every pixel of the output's top and left edges.

Forgetting that round is a mask. A CSS border-radius makes a round preview over a square file. Here the canvas is clipped to an ellipse before the image is drawn, so the corners of the file are genuinely transparent — which a test checks by reading their alpha.

Rotating twice. A photograph from a phone is usually stored sideways with an EXIF tag saying which way up it goes. Browsers apply that tag themselves; a library that also parses EXIF and applies it again produces a picture on its side. There is no EXIF parser here — createImageBitmap is asked for imageOrientation: 'from-image' and does it once.

Sizes

await picker.toFile('photo', { maxWidth: 1600 });          // capped, never enlarged
await picker.toFile('thumb', { width: 128, height: 128 }); // exactly this
await picker.toFile('wide', { width: 640 });               // 640 across, shape kept

maxWidth/maxHeight scale down only. A 300-pixel crop asked to fit 1600 stays 300 pixels: the alternative is inventing detail that was never captured.

The widget shows the size you are about to get, under the stage, as you drag — so nobody discovers the picture was too small after uploading it.

Formats, and how much they cost

await picker.toFile('a', { type: 'image/webp', quality: 0.82 });   // usually the best trade
await picker.toFile('b', { type: 'image/jpeg', quality: 0.9 });    // when something old must read it
await picker.toFile('c', { type: 'image/png' });                   // when transparency matters

With no type at all, a PNG or GIF stays a PNG — the transparency is presumably why it was chosen — and everything else becomes WebP where the browser can write it, falling back to JPEG where it cannot.

Two sizes from one crop

const [full, thumb] = await Promise.all([
  picker.toFile('photo', { maxWidth: 2000, type: 'image/jpeg', quality: 0.9 }),
  picker.toFile('photo-thumb', { width: 200, height: 200, type: 'image/webp' }),
]);

Sending it

const body = new FormData();
body.append('avatar', await picker.toFile());
body.append('crop', JSON.stringify(picker.getCrop()));   // if the server wants to redo it later
await fetch('/profile/avatar', { method: 'POST', body, headers: { 'X-CSRF-TOKEN': token } });
// Laravel
$request->validate(['avatar' => ['required', 'image', 'max:2048']]);
$path = $request->file('avatar')->store('avatars', 'public');

Keeping getCrop() alongside the file is worth it when the original is kept too: it is four numbers in the original's own pixel space, so the same crop can be reproduced at any size later, on a server, without asking the person again.

Handing it to the uploader

import { createFileUploader } from '@devix-labs/file-uploader';

const uploader = createFileUploader(document.querySelector('#files'), { url: '/upload' });
uploader.addFiles([await picker.toFile()]);

Doing it yourself

The rendering is exported, so a server-side crop or a batch job can use the same maths:

import { renderToCanvas, canvasToBlob, load, outputSize } from '@devix-labs/image-cropper/core';

const picture = await load(file);
const canvas = renderToCanvas(picture.source, picture, { x: 100, y: 50, width: 400, height: 400 },
  { zoom: 1, x: 0, y: 0, rotate: 0, flipX: false, flipY: false }, { maxWidth: 512 });
const blob = await canvasToBlob(canvas, 'image/webp', 0.85);
picture.release();

outputSize(crop, options) answers "how big would that be?" without drawing anything.

Updated 15 Sep 2026