diff --git a/lib/image.ts b/lib/image.ts index 3746790..8ed18e4 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -34,13 +34,17 @@ export const PHOTO_QUALITY = 0.8; export const SKIP_BELOW_BYTES = 150 * 1024; /** - * Formats a canvas can decode and re-encode for us. HEIC/HEIF is - * excluded deliberately: iPhone-origin files land here via `accept` - * when a driver picks from the library rather than the camera, and - * Chrome cannot decode them into a canvas. They fall through to - * `compressPhoto` returning the original untouched. + * Formats we try to re-encode. HEIC/HEIF is included (since 2026-10-20): + * iPhones send HEIC, Safari on iOS/macOS can decode it into a canvas, and + * WFDASH's AI and most desktop browsers can't read HEIC at all — the first + * HEIC invoice photos went unread. Where the browser can't decode HEIC + * (Chrome), loadImage fails and the original is uploaded as before. */ -const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']); + +function isHeic(file: File): boolean { + return /^image\/hei[cf]/i.test(file.type) || /\.(heic|heif)$/i.test(file.name); +} function loadImage(url: string): Promise { return new Promise((resolve, reject) => { @@ -62,8 +66,10 @@ function loadImage(url: string): Promise { * "the best file we can produce", so the happy path stays simple. */ export async function compressPhoto(file: File): Promise { - if (file.size <= SKIP_BELOW_BYTES) return file; - if (!REENCODABLE_TYPES.has(file.type)) return file; + const heic = isHeic(file); + // A HEIC is always converted, however small: the point is the format. + if (!heic && file.size <= SKIP_BELOW_BYTES) return file; + if (!heic && !REENCODABLE_TYPES.has(file.type)) return file; // Not available during SSR. Only ever called from a click handler in // the browser, but a guard is cheaper than a confusing crash. @@ -81,10 +87,11 @@ export async function compressPhoto(file: File): Promise { if (img.naturalWidth > 20000 || img.naturalHeight > 20000) return file; const longest = Math.max(img.naturalWidth, img.naturalHeight); - // Only shrink. Upscaling a small photo wastes bytes and detail. - if (longest <= MAX_PHOTO_EDGE) return file; + // Only shrink. Upscaling a small photo wastes bytes and detail. A + // HEIC is still re-encoded (at its own size) to get a JPEG. + if (longest <= MAX_PHOTO_EDGE && !heic) return file; - const scale = MAX_PHOTO_EDGE / longest; + const scale = Math.min(1, MAX_PHOTO_EDGE / longest); const width = Math.max(1, Math.round(img.naturalWidth * scale)); const height = Math.max(1, Math.round(img.naturalHeight * scale)); @@ -106,8 +113,9 @@ export async function compressPhoto(file: File): Promise { if (!blob) return file; // If re-encoding somehow produced something bigger, keep the - // original rather than making the upload worse. - if (blob.size >= file.size) return file; + // original rather than making the upload worse — except for HEIC, + // where a readable JPEG beats a smaller unreadable file. + if (!heic && blob.size >= file.size) return file; const stem = file.name.replace(/\.[^.]+$/, '') || 'pickup'; return new File([blob], `${stem}.jpg`, {