@limetech/lime-elements
Version:
306 lines (305 loc) • 10.2 kB
JavaScript
/**
* Image resize utilities
*
* Overview
* --------
* This module provides a small, dependency-free utility to resize images on the client
* (in the browser) before uploading. It works by decoding an input `File` to an
* `ImageBitmap` (or falling back to an `HTMLImageElement`), drawing it onto a
* `Canvas`/`OffscreenCanvas` with the requested strategy (cover/contain), and
* then exporting the result to a new `File` with your preferred MIME type and
* quality.
*
* Why resize client-side?
* - Faster perceived uploads and lower bandwidth usage
* - Consistent avatar sizes and formats (e.g., JPEG 400x400)
* - No server-side transformation required for common cases
*
* Fit strategies
* - `cover` (default): The image is scaled to cover the target rectangle, and
* the excess parts are center-cropped. Good for avatars.
* - `contain`: The image is scaled to fit entirely within the target rectangle
* without cropping, letterboxing if needed. Good when you must preserve the
* entire image.
*
* Decoding & EXIF orientation
* EXIF orientation is a piece of metadata stored inside image files
* (usually JPEGs) that tells image renderer software how the image should be displayed
* i.e., whether it should be rotated or flipped. This meta data is normally added
* to photos by digital cameras and phones.
* - When available, `createImageBitmap(file, { imageOrientation: 'from-image' })`
* is used to automatically respect EXIF orientation.
* - If not available or it fails (e.g., unsupported format), we fall back to
* decoding via an `HTMLImageElement`.
*
* OffscreenCanvas
* - If the environment supports `OffscreenCanvas`, it will be used for the draw
* and encode operations for better performance in some cases. Otherwise, a
* regular `HTMLCanvasElement` is used.
*
* HEIC/HEIF notes
* - All major browsers except Safari lack native HEIC/HEIF decoding.
* In such cases the `resizeImage` function will throw when decoding fails.
* The caller should catch and fall back
* to using the original file or handle conversion on the server.
* - If we need guaranteed client-side HEIC->JPEG conversion, we must add a small
* library or WASM module; this utility intentionally avoids extra dependencies.
*
* Output type & quality
* - Default output is `image/jpeg` with `quality=0.85`, which is typically
* appropriate for avatars. You can switch to `image/png` to preserve
* transparency.
* - The output filename extension is adjusted to match the chosen MIME type by
* default (e.g., `.jpg` or `.png`). You can override naming via the `rename`
* option.
*
* Error handling
* - Throws if canvas/context cannot be created or if canvas->blob conversion fails.
* - Decoding failures (unsupported type) will throw; caller can try/catch and
* fall back to the original file.
*
* Performance tips
* - Keep target sizes reasonable (e.g., 256–1024 px) to avoid long processing
* times on modest devices.
* - JPEG with quality 0.8–0.9 often strikes a good balance between size and
* perceived quality for photos/avatars.
*
* Usage examples
* --------------
* Basic usage:
* ```ts
* import { resizeImage } from '@limetech/lime-elements/util/image-resize';
*
* const processed = await resizeImage(file, {
* width: 400,
* height: 400,
* fit: 'cover', // default; center-crops
* type: 'image/jpeg', // default
* quality: 0.85, // default
* });
* // Upload `processed` instead of the original file
* ```
*
* With custom naming:
* ```ts
* const processed = await resizeImage(file, {
* width: 800,
* height: 800,
* fit: 'contain',
* type: 'image/png',
* rename: (name) => name.replace(/\.[^.]+$/, '') + '_resized.png',
* });
* ```
*
* In a Stencil component (simplified):
* ```tsx
* private async handleFilesSelected(file: File) {
* try {
* const resized = await resizeImage(file, { width: 400, height: 400 });
* // build your FileInfo and emit
* } catch {
* // fall back to original
* }
* }
* ```
*/
// (Removed exported ResizeFit to avoid forcing a public symbol.)
/**
* Resize an image file on the client using Canvas/OffscreenCanvas.
* Returns a new File with the requested format and dimensions.
*
* Contract
* - Input: image `File`
* - Output: resized image as a new `File` with updated `type`, name, and size
* - Errors: may throw on decode failure or canvas export failure
*
* @beta
* @param file - The image file to resize.
* @param options - Configuration for the resize operation.
*/
export async function resizeImage(file, options) {
const { width, height, fit = 'cover', type = 'image/jpeg', quality = 0.85, rename = (name) => renameWithType(name, type), } = options;
const source = await loadSource(file);
const { sx, sy, sw, sh, dx, dy, dw, dh } = computeRects(source.width, source.height, width, height, fit);
const canvas = createCanvas(width, height);
const ctx = get2dContext(canvas);
ctx.clearRect(0, 0, width, height);
ctx.drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh);
const blob = await canvasToBlob(canvas, type, quality);
const name = rename(file.name);
return new File([blob], name, { type });
}
/** Whether OffscreenCanvas is available in the current environment. */
function supportsOffscreen() {
try {
return typeof globalThis.OffscreenCanvas === 'function';
}
catch (_a) {
return false;
}
}
/**
* Create either an OffscreenCanvas or a regular canvas for drawing.
* @param width - Target width
* @param height - Target height
*/
function createCanvas(width, height) {
if (supportsOffscreen()) {
return new globalThis.OffscreenCanvas(width, height);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
/**
* Get the 2D rendering context, throwing a descriptive error if unavailable.
* @param canvas - The canvas to get context from
*/
function get2dContext(canvas) {
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
throw new Error('2D canvas context not available');
}
return ctx;
}
/**
* Convert the canvas content to a Blob, supporting both canvas types.
* @param canvas - The source canvas
* @param type - Output MIME type
* @param quality - JPEG quality (0..1)
*/
function canvasToBlob(canvas, type, quality) {
if ('convertToBlob' in canvas) {
return canvas.convertToBlob({ type, quality });
}
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to create blob from canvas'));
return;
}
resolve(blob);
}, type, quality);
});
}
/**
* Load the image into a decodable source (ImageBitmap preferred).
* @param file - The input file to decode
*/
async function loadSource(file) {
var _a, _b;
if (typeof globalThis.createImageBitmap === 'function') {
try {
return await globalThis.createImageBitmap(file, {
imageOrientation: 'from-image',
});
}
catch (error) {
// Log for debugging in development, but continue with fallback
const isDev = ((_b = (_a = globalThis.process) === null || _a === void 0 ? void 0 : _a.env) === null || _b === void 0 ? void 0 : _b.NODE_ENV) !== 'production';
if (isDev &&
typeof console !== 'undefined' &&
typeof console.debug === 'function') {
console.debug('createImageBitmap failed, falling back to HTMLImageElement:', error);
}
}
}
return await loadImageElement(file);
}
/**
* Decode an image file via HTMLImageElement when ImageBitmap is unavailable.
* @param file - The input file to decode
*/
async function loadImageElement(file) {
var _a;
const url = URL.createObjectURL(file);
try {
const img = new Image();
img.decoding = 'sync';
img.src = url;
await ((_a = img.decode) === null || _a === void 0 ? void 0 : _a.call(img).catch(() => undefined));
if (!img.complete) {
await new Promise((resolve, reject) => {
const cleanup = () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
};
const onLoad = () => {
cleanup();
resolve();
};
const onError = (e) => {
cleanup();
reject(e);
};
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
});
}
return img;
}
finally {
URL.revokeObjectURL(url);
}
}
/**
* Compute source and destination rectangles for drawImage based on the fit mode.
*
* Returns sx, sy, sw, sh for the source crop/area and dx, dy, dw, dh for the
* destination rectangle on the target canvas.
*
* @param sw - Source width
* @param sh - Source height
* @param tw - Target width
* @param th - Target height
* @param fit - Fit mode (cover/contain)
*/
function computeRects(sw, sh, tw, th, fit) {
const sRatio = sw / sh;
const tRatio = tw / th;
if (fit === 'cover') {
// scale source to cover target, then center-crop
let cropW;
let cropH;
if (sRatio > tRatio) {
// source is wider than target: crop width
cropH = sh;
cropW = sh * tRatio;
}
else {
// source is taller than target: crop height
cropW = sw;
cropH = sw / tRatio;
}
const sx = (sw - cropW) / 2;
const sy = (sh - cropH) / 2;
return { sx, sy, sw: cropW, sh: cropH, dx: 0, dy: 0, dw: tw, dh: th };
}
// contain: fit inside, letterbox if needed
let drawW;
let drawH;
if (sRatio > tRatio) {
drawW = tw;
drawH = tw / sRatio;
}
else {
drawH = th;
drawW = th * sRatio;
}
const dx = (tw - drawW) / 2;
const dy = (th - drawH) / 2;
return { sx: 0, sy: 0, sw, sh, dx, dy, dw: drawW, dh: drawH };
}
/**
* Update filename extension to match the desired MIME type.
* @param name - Original filename
* @param type - Output MIME type
*/
function renameWithType(name, type) {
const ext = type === 'image/png' ? 'png' : 'jpg';
const idx = name.lastIndexOf('.');
const base = idx > 0 ? name.slice(0, idx) : name;
return `${base}.${ext}`;
}
//# sourceMappingURL=image-resize.js.map