export const MAX_IMAGE_BYTES = 1_000_000;
const KEPT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
const MAX_SIDE = 4096;
const VECTOR_SIDE = 1024;
const QUALITY = 0.85;
const MAX_ATTEMPTS = 8;
export class ImageError extends Error {}
const extensions: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp"
};
const renamed = (name: string, type: string) =>
`${name.replace(/\.[^.]*$/, "")}.${extensions[type] ?? "png"}`;
const decode = async (file: File, url: string): Promise<HTMLImageElement> => {
const image = new Image();
image.src = url;
try {
await image.decode();
} catch {
throw new ImageError(`"${file.name}" isn't an image this browser can read.`);
}
return image;
};
const render = (
image: HTMLImageElement,
width: number,
height: number,
type: string
): Promise<Blob> =>
new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return reject(new ImageError("This browser can't resize images."));
if (type === "image/jpeg") {
context.fillStyle = "#fff";
context.fillRect(0, 0, width, height);
}
context.drawImage(image, 0, 0, width, height);
canvas.toBlob(
(blob) =>
blob ? resolve(blob) : reject(new ImageError("The image couldn't be encoded.")),
type,
QUALITY
);
});
export const prepareImage = async (file: File): Promise<File> => {
if (KEPT_TYPES.has(file.type) && file.size <= MAX_IMAGE_BYTES) return file;
if (file.type === "image/gif")
throw new ImageError(
`"${file.name}" is too large, and a gif can't be shrunk without losing its animation.`
);
const url = URL.createObjectURL(file);
try {
const image = await decode(file, url);
const longest = Math.max(image.naturalWidth, image.naturalHeight) || VECTOR_SIDE;
const vector = file.type === "image/svg+xml";
const natural = image.naturalWidth || VECTOR_SIDE;
const naturalHeight = image.naturalHeight || VECTOR_SIDE;
let scale = vector ? VECTOR_SIDE / longest : Math.min(1, MAX_SIDE / longest);
const type = file.type === "image/jpeg" ? "image/jpeg" : "image/webp";
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const blob = await render(
image,
Math.max(1, Math.round(natural * scale)),
Math.max(1, Math.round(naturalHeight * scale)),
type
);
if (blob.size <= MAX_IMAGE_BYTES)
return new File([blob], renamed(file.name, blob.type), { type: blob.type });
scale *= Math.min(0.9, Math.sqrt(MAX_IMAGE_BYTES / blob.size) * 0.95);
}
throw new ImageError(`"${file.name}" is too large to shrink under 1 MB.`);
} finally {
URL.revokeObjectURL(url);
}
};