export interface PixelCropArea { x: number; y: number; width: number; height: number; } export interface CropImageExportOptions { fileName?: string; quality?: number; rotation?: number; type?: string; } export class CropImageLoadError extends Error { public readonly message: string; private readonly __brand!: void; constructor(cause?: unknown) { super('Failed to load source image for cropping.', { cause }); this.message = 'Failed to load source image for cropping.'; this.name = 'CropImageLoadError'; } } export class CropImageEnvironmentError extends Error { public readonly message: string; private readonly __brand!: void; constructor() { super('Canvas is not supported in this environment.'); this.message = 'Canvas is not supported in this environment.'; this.name = 'CropImageEnvironmentError'; } } export class CropImageExportError extends Error { public readonly message: string; private readonly __brand!: void; constructor() { super('Failed to export cropped image.'); this.message = 'Failed to export cropped image.'; this.name = 'CropImageExportError'; } } export class CropImageUnknownError extends Error { public readonly message: string; private readonly __brand!: void; constructor(cause?: unknown) { super('Unexpected error while cropping image.', { cause }); this.message = 'Unexpected error while cropping image.'; this.name = 'CropImageUnknownError'; } } export type CropImageError = | CropImageLoadError | CropImageEnvironmentError | CropImageExportError | CropImageUnknownError; export type CropResult = { success: true; data: T } | { success: false; error: CropImageError }; function loadImage(source: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = 'anonymous'; image.addEventListener('load', () => resolve(image)); image.addEventListener('error', () => reject(new CropImageLoadError())); image.src = source; }); } function getRadianAngle(degrees: number) { return (degrees * Math.PI) / 180; } function rotateSize(width: number, height: number, rotation: number) { const radians = getRadianAngle(rotation); return { width: Math.abs(Math.cos(radians) * width) + Math.abs(Math.sin(radians) * height), height: Math.abs(Math.sin(radians) * width) + Math.abs(Math.cos(radians) * height), }; } async function _cropImageToCanvas(imageSource: string, cropArea: PixelCropArea, options: CropImageExportOptions = {}) { const image = await loadImage(imageSource); const rotation = options.rotation ?? 0; const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { throw new CropImageEnvironmentError(); } const rotatedSize = rotateSize(image.naturalWidth, image.naturalHeight, rotation); const rotatedCanvasWidth = Math.ceil(rotatedSize.width); const rotatedCanvasHeight = Math.ceil(rotatedSize.height); canvas.width = rotatedCanvasWidth; canvas.height = rotatedCanvasHeight; context.translate(rotatedCanvasWidth / 2, rotatedCanvasHeight / 2); context.rotate(getRadianAngle(rotation)); context.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2); context.setTransform(1, 0, 0, 1, 0, 0); const exportCanvas = document.createElement('canvas'); const exportContext = exportCanvas.getContext('2d'); if (!exportContext) { throw new CropImageEnvironmentError(); } const sourceX = Math.round(cropArea.x); const sourceY = Math.round(cropArea.y); const sourceWidth = Math.max(1, Math.round(cropArea.width)); const sourceHeight = Math.max(1, Math.round(cropArea.height)); exportCanvas.width = sourceWidth; exportCanvas.height = sourceHeight; exportContext.drawImage( canvas, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, exportCanvas.width, exportCanvas.height ); return exportCanvas; } async function _cropImageToBlob(imageSource: string, cropArea: PixelCropArea, options: CropImageExportOptions = {}) { const exportCanvas = await _cropImageToCanvas(imageSource, cropArea, options); const blob = await new Promise(resolve => { exportCanvas.toBlob(resolve, options.type ?? 'image/jpeg', options.quality ?? 0.92); }); if (!blob) { throw new CropImageExportError(); } return blob; } async function _cropImageToFile(imageSource: string, cropArea: PixelCropArea, options: CropImageExportOptions = {}) { const blob = await _cropImageToBlob(imageSource, cropArea, options); return new File([blob], options.fileName ?? 'cropped-image.jpg', { type: blob.type, }); } function toError(error: unknown): CropImageError { if ( error instanceof CropImageLoadError || error instanceof CropImageEnvironmentError || error instanceof CropImageExportError || error instanceof CropImageUnknownError ) { return error; } return new CropImageUnknownError(error); } export async function cropImageToCanvas( imageSource: string, cropArea: PixelCropArea, options: CropImageExportOptions = {} ): Promise> { try { return { success: true, data: await _cropImageToCanvas(imageSource, cropArea, options) }; } catch (error) { return { success: false, error: toError(error) }; } } export async function cropImageToBlob( imageSource: string, cropArea: PixelCropArea, options: CropImageExportOptions = {} ): Promise> { try { return { success: true, data: await _cropImageToBlob(imageSource, cropArea, options) }; } catch (error) { return { success: false, error: toError(error) }; } } export async function cropImageToFile( imageSource: string, cropArea: PixelCropArea, options: CropImageExportOptions = {} ): Promise> { try { return { success: true, data: await _cropImageToFile(imageSource, cropArea, options) }; } catch (error) { return { success: false, error: toError(error) }; } }