/** * Image processing utility functions */ /** * Create ImageBitmap from data URL (for OffscreenCanvas) * @param dataUrl Image data URL * @returns Created ImageBitmap object */ export async function createImageBitmapFromUrl(dataUrl: string): Promise { const response = await fetch(dataUrl); const blob = await response.blob(); return await createImageBitmap(blob); } /** * Stitch multiple image parts (dataURL) onto a single canvas * @param parts Array of image parts, each containing dataUrl and y coordinate * @param totalWidthPx Total width (pixels) * @param totalHeightPx Total height (pixels) * @returns Stitched canvas */ export async function stitchImages( parts: { dataUrl: string; y: number }[], totalWidthPx: number, totalHeightPx: number, ): Promise { const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to get canvas context'); } ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); for (const part of parts) { try { const img = await createImageBitmapFromUrl(part.dataUrl); const sx = 0; const sy = 0; const sWidth = img.width; let sHeight = img.height; const dy = part.y; if (dy + sHeight > totalHeightPx) { sHeight = totalHeightPx - dy; } if (sHeight <= 0) continue; ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight); } catch (error) { console.error('Error stitching image part:', error, part); } } return canvas; } /** * Crop image (from dataURL) to specified rectangle and resize * @param originalDataUrl Original image data URL * @param cropRectPx Crop rectangle (physical pixels) * @param dpr Device pixel ratio * @param targetWidthOpt Optional target output width (CSS pixels) * @param targetHeightOpt Optional target output height (CSS pixels) * @returns Cropped canvas */ export async function cropAndResizeImage( originalDataUrl: string, cropRectPx: { x: number; y: number; width: number; height: number }, dpr: number = 1, targetWidthOpt?: number, targetHeightOpt?: number, ): Promise { const img = await createImageBitmapFromUrl(originalDataUrl); let sx = cropRectPx.x; let sy = cropRectPx.y; let sWidth = cropRectPx.width; let sHeight = cropRectPx.height; // Ensure crop area is within image boundaries if (sx < 0) { sWidth += sx; sx = 0; } if (sy < 0) { sHeight += sy; sy = 0; } if (sx + sWidth > img.width) { sWidth = img.width - sx; } if (sy + sHeight > img.height) { sHeight = img.height - sy; } if (sWidth <= 0 || sHeight <= 0) { throw new Error('Invalid calculated crop size (<=0). Element may not be visible or fully captured.'); } const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth; const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight; const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to get canvas context'); } ctx.drawImage( img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx, ); return canvas; } /** * Convert canvas to data URL * @param canvas Canvas * @param format Image format * @param quality JPEG quality (0-1) * @returns Data URL */ export async function canvasToDataURL( canvas: OffscreenCanvas, format: string = 'image/png', quality?: number, ): Promise { const blob = await canvas.convertToBlob({ type: format, quality: format === 'image/jpeg' ? quality : undefined, }); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); }