src/lib/image-crop.ts
Rama main · réplica de lectura del repositorio original.
import type { Area } from "react-easy-crop";
/**
* A failure that happened in this browser rather than at the API - bytes that
* would not decode, a canvas the platform refused to give.
*
* It exists so callers can tell the two apart. `getApiErrorMessage` reads only an
* axios response's `detail` and returns its fallback for everything else, so
* routing one of these through it would replace a precise message with a generic
* one. Catch this type and show `message` directly.
*/
export class ImageCropError extends Error {}
/**
* Resolves once `src` has decoded, or rejects with an `ImageCropError`.
*
* Exported because a crop dialog needs this answer *before* it opens:
* `react-easy-crop` has no failure callback of its own, so bytes it cannot decode
* leave it showing an empty frame forever rather than reporting anything.
*/
export function decodeImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () =>
reject(new ImageCropError("That file could not be read as an image."));
image.src = src;
});
}
/**
* How wide a canvas to draw a crop of `cropWidthPx` natural pixels onto.
*
* Capped at `maxWidth` and otherwise the crop's own width, so a small source is
* never blown up on the way out. Upscaling here would cost bytes and invent
* detail that is not in the picture.
*
* Rounded down and floored at 1: `croppedAreaPixels` is fractional, and a canvas
* dimension of 0 throws rather than producing an empty image.
*/
export function cropExportWidth(cropWidthPx: number, maxWidth: number): number {
if (!Number.isFinite(cropWidthPx)) return maxWidth;
return Math.max(1, Math.min(maxWidth, Math.floor(cropWidthPx)));
}
interface CropToBlobOptions {
/** The widest canvas to draw onto; a narrower crop is exported at its own size. */
maxWidth: number;
/**
* The encoding to ask the canvas for.
*
* **Never `image/jpeg`.** `toBlob("image/jpeg")` has no alpha channel to put
* transparency in and the canvas spec composites it onto **black**, so a card
* with a transparent corner comes back with a black wedge in it.
*
* A type the browser cannot encode is not an error: the canvas spec says to
* fall back to `image/png`. That is why callers name the stored file from
* `Blob.type` rather than from what they asked for - Safari before 16.4 has no
* WebP encoder and silently hands back a PNG.
*/
type: "image/png" | "image/webp";
/** Encoder quality for a lossy `type`; ignored for PNG. */
quality?: number;
}
/**
* Draws `area` of `imageSrc` onto a canvas of the same shape and returns it as a
* Blob.
*
* `area` is `react-easy-crop`'s `croppedAreaPixels`: natural pixels of the source
* image, already in the cropper's `aspect` because that is what it crops to. The
* canvas keeps that shape, so the exported bytes *are* the aspect ratio the app
* draws them in and no consumer has to letterbox them.
*
* Not unit-tested, unlike `cropExportWidth` above: jsdom has no canvas
* implementation, so `getContext("2d")` returns nothing there and a test would
* only be exercising a mock of the API it is meant to be checking.
*/
export async function cropToBlob(
imageSrc: string,
area: Area,
{ maxWidth, type, quality }: CropToBlobOptions,
): Promise<Blob> {
const image = await decodeImage(imageSrc);
const width = cropExportWidth(area.width, maxWidth);
// From the crop's own shape rather than from a passed-in aspect, so a rounding
// difference between the two cannot letterbox the export by a pixel.
const height = Math.max(
1,
Math.round((width * area.height) / Math.max(area.width, 1)),
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new ImageCropError("This browser could not prepare the image.");
}
context.drawImage(
image,
area.x,
area.y,
area.width,
area.height,
0,
0,
width,
height,
);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else
reject(
new ImageCropError("This browser could not prepare the image."),
);
},
type,
quality,
);
});
}
/**
* The filename to store a cropped export under, named from what the canvas
* actually produced rather than from what was asked for - see
* `CropToBlobOptions.type`.
*/
export function croppedFilename(stem: string, blob: Blob): string {
const extension = blob.type === "image/webp" ? "webp" : "png";
return `${stem}.${extension}`;
}