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.