UNPKG

@limetech/lime-elements

Version:
306 lines (305 loc) 10.2 kB
/** * 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