import { texts, format } from '@/texts' const IMAGE_SIGNATURES: Record boolean> = { 'image/jpeg': (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff, 'image/png': (bytes) => [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, index) => bytes[index] === byte), 'image/webp': (bytes) => new TextDecoder().decode(bytes.slice(0, 4)) === 'RIFF' && new TextDecoder().decode(bytes.slice(8, 12)) === 'WEBP', } const HEIC_BRANDS = new Set(['heic', 'heif', 'mif1', 'msf1', 'hevx', 'heim', 'heis', 'hevm', 'hevs']) const HEIC_MIME_TYPES = new Set(['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence']) const IMAGE_PROCESSING_TIMEOUT_MS = 30_000 /** Accept list for `` — includes HEIC so iPhone Photos picker works. */ export const SAFE_IMAGE_ACCEPT = [...Object.keys(IMAGE_SIGNATURES), 'image/heic', 'image/heif', '.heic', '.heif'].join(',') function decodeAscii(bytes: Uint8Array, start: number, end: number): string { return new TextDecoder().decode(bytes.slice(start, end)) } function isHeicBytes(bytes: Uint8Array): boolean { if (bytes.length < 12) return false if (decodeAscii(bytes, 4, 8) !== 'ftyp') return false return HEIC_BRANDS.has(decodeAscii(bytes, 8, 12)) } function isHeicMime(mime: string): boolean { return HEIC_MIME_TYPES.has(mime.toLowerCase()) } function isHeicFileName(name: string): boolean { return /\.hei[cf]$/i.test(name) } /** Prefer magic bytes over `file.type` (often empty on iOS Safari). */ export function detectImageMime(bytes: Uint8Array, declaredType = ''): string | null { for (const [mime, matches] of Object.entries(IMAGE_SIGNATURES)) { if (matches(bytes)) return mime } if (isHeicBytes(bytes) || isHeicMime(declaredType)) return 'image/heic' return null } function extensionForMime(mime: string): string { switch (mime) { case 'image/png': return 'png' case 'image/gif': return 'gif' case 'image/webp': return 'webp' case 'image/heic': return 'heic' case 'image/heif': return 'heif' default: return 'jpg' } } function withMimeType(file: File, mime: string): File { if (file.type === mime) return file const baseName = file.name.replace(/\.[^.]+$/, '') || 'image' const nextName = `${baseName}.${extensionForMime(mime)}` return new File([file], nextName, { type: mime, lastModified: file.lastModified }) } /** * Converts HEIC → JPEG in-browser when possible (Safari/WebKit on iPhone). * Returns `null` — instead of throwing — when the browser can't decode HEIC * (Chrome/Edge/Firefox, notably on Windows), so the caller can fall back to * uploading the original HEIC file for the backend to convert. */ async function convertHeicToJpeg(file: File): Promise { let bitmap: ImageBitmap try { bitmap = await createImageBitmap(file) } catch { return null } try { const canvas = document.createElement('canvas') canvas.width = bitmap.width canvas.height = bitmap.height const ctx = canvas.getContext('2d') if (!ctx) throw new Error(texts.common.imagePrepareFailed) ctx.drawImage(bitmap, 0, 0) const blob = await withImageProcessingTimeout( new Promise((resolve, reject) => { canvas.toBlob( (result) => { if (result) resolve(result) else reject(new Error(texts.common.imageConvertFailed)) }, 'image/jpeg', 0.92 ) }) ) const baseName = file.name.replace(/\.[^.]+$/, '') || 'image' return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg', lastModified: Date.now() }) } finally { bitmap.close() } } function withImageProcessingTimeout(operation: Promise): Promise { return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { reject(new Error(texts.common.imageProcessingTimedOut)) }, IMAGE_PROCESSING_TIMEOUT_MS) operation.then( (value) => { window.clearTimeout(timeout) resolve(value) }, (error: unknown) => { window.clearTimeout(timeout) reject(error instanceof Error ? error : new Error(texts.common.imagePrepareFailed)) } ) }) } function sizeError(file: File, maxSizeMb: number): string | null { if (file.size === 0) return texts.common.imageEmpty if (file.size > maxSizeMb * 1024 * 1024) { return format(texts.common.imageTooLarge, { maxMb: maxSizeMb }) } return null } /** * Normalize an image picked on any device (esp. iPhone) into an upload-ready File: * - sniffs magic bytes when `file.type` is empty * - converts HEIC/HEIF → JPEG when the browser can decode it * * Returns the File, or a Persian error string. */ export async function prepareImageForUpload(file: File, maxSizeMb = 6): Promise { const tooLarge = sizeError(file, maxSizeMb) if (tooLarge) return tooLarge const bytes = new Uint8Array(await withImageProcessingTimeout(file.slice(0, 16).arrayBuffer())) const sniffed = detectImageMime(bytes, file.type) ?? (isHeicFileName(file.name) ? 'image/heic' : null) if (!sniffed) { return texts.common.imageTypeNotAllowed } try { if (sniffed === 'image/heic') { const jpeg = await convertHeicToJpeg(file) // Browser couldn't decode HEIC itself (e.g. Chrome/Edge on Windows) — // send the original file as-is; the backend converts it server-side. if (!jpeg) return withMimeType(file, sniffed) const afterConvert = sizeError(jpeg, maxSizeMb) return afterConvert ?? jpeg } return withMimeType(file, sniffed) } catch (error) { return error instanceof Error ? error.message : texts.common.imagePrepareFailed } } export async function validateImageFile(file: File, maxSizeMb = 6): Promise { const prepared = await prepareImageForUpload(file, maxSizeMb) return typeof prepared === 'string' ? prepared : null }