1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 IMAGE INTAKE FOUNDATION (Phase 1) 3 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4 Sits BETWEEN "user picks/drops/pastes a file" and the EXISTING image 5 search validation/compression pipeline (handleImageFile â _validateImageFile 6 â _compressImageForSearch â POST /api/image-search). 7 8 Contract: 9 ImageIntake.normalize(file) â Promise<File> 10 - Resolves with a File object that is ALWAYS a real, re-encoded 11 JPEG (image/jpeg), regardless of the original format. 12 - Rejects with { code, message } when a format truly cannot be 13 decoded in the current browser (documented per-format below â 14 never silently swallowed; the caller decides how to surface it). 15 16 Nothing downstream of this module is modified. handleImageFile(), 17 _validateImageFile(), _compressImageForSearch(), startImageSearch(), 18 and POST /api/image-search are all untouched â they simply always 19 receive a JPEG File now, which they already fully support today. 20 21 Supported inputs (Phase 1 scope): 22 JPEG, JPG â re-encoded via canvas (EXIF-corrected, alpha n/a) 23 PNG â re-encoded via canvas (alpha flattened onto white) 24 WEBP â re-encoded via canvas (alpha flattened onto white) 25 GIF â first frame only, re-encoded via canvas 26 BMP â re-encoded via canvas 27 AVIF â re-encoded via canvas IF the browser can natively 28 decode AVIF (Chrome/Firefox/Edge â evergreen; Safety 29 16+). No AVIF decoder is bundled â see documented 30 limitation below if decode fails. 31 HEIC, HEIF â decoded via vendored heic2any (WASM-based libheif 32 port), then re-encoded via canvas for a single 33 consistent normalization path. 34 TIFF â decoded via vendored UTIF.js (+ pako for Deflate- 35 compressed TIFFs), then re-encoded via canvas. 36 37 Format detection is done by reading the first bytes of the file 38 (magic-byte sniffing), NOT by trusting file.type / file extension â 39 iOS in particular reports HEIC files with an empty or generic MIME 40 type in some contexts. 41 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 42 43(function (global) { 44 'use strict'; 45 46 var VENDOR_BASE = '/js/vendor/'; 47 var _scriptLoadPromises = {}; 48 49 /** Lazily load a vendor <script> exactly once; safe to call repeatedly. */ 50 function _loadScriptOnce(src) { 51 if (_scriptLoadPromises[src]) return _scriptLoadPromises[src]; 52 _scriptLoadPromises[src] = new Promise(function (resolve, reject) { 53 var el = document.createElement('script'); 54 el.src = src; 55 el.onload = function () { resolve(); }; 56 el.onerror = function () { 57 delete _scriptLoadPromises[src]; 58 reject(new Error('Failed to load ' + src)); 59 }; 60 document.head.appendChild(el); 61 }); 62 return _scriptLoadPromises[src]; 63 } 64 65 // ââ Format detection (magic bytes) âââââââââââââââââââââââââââââââââââââ 66 67 function _readHeaderBytes(file, n) { 68 return new Promise(function (resolve, reject) { 69 var reader = new FileReader(); 70 reader.onload = function () { resolve(new Uint8Array(reader.result)); }; 71 reader.onerror = function () { reject(new Error('Cannot read file header')); }; 72 reader.readAsArrayBuffer(file.slice(0, n)); 73 }); 74 } 75 76 function _bytesToAscii(bytes, start, len) { 77 var s = ''; 78 for (var i = 0; i < len; i++) s += String.fromCharCode(bytes[start + i]); 79 return s; 80 } 81 82 /** 83 * Detect the real image format from magic bytes. 84 * Returns one of: 'jpeg' | 'png' | 'gif' | 'bmp' | 'webp' | 'tiff' | 85 * 'heic' | 'avif' | 'unknown' 86 */ 87 function _detectFormat(bytes) { 88 if (!bytes || bytes.length < 12) return 'unknown'; 89 90 // JPEG: FF D8 FF 91 if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'jpeg'; 92 93 // PNG: 89 50 4E 47 0D 0A 1A 0A
94 if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'png'; 95 96 // GIF: "GIF87a" / "GIF89a" 97 if (_bytesToAscii(bytes, 0, 4) === 'GIF8') return 'gif'; 98 99 // BMP: "BM" 100 if (bytes[0] === 0x42 && bytes[1] === 0x4D) return 'bmp'; 101 102 // WEBP: "RIFF"....."WEBP" 103 if (_bytesToAscii(bytes, 0, 4) === 'RIFF' && _bytesToAscii(bytes, 8, 4) === 'WEBP') return 'webp'; 104 105 // TIFF: little-endian "II*\0" or big-endian "MM\0*" 106 if ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2A && bytes[3] === 0x00) || 107 (bytes[0] === 0x4D && bytes[1] === 0x4D && bytes[2] === 0x00 && bytes[3] === 0x2A)) { 108 return 'tiff'; 109 } 110 111 // ISO-BMFF container (HEIC/HEIF/AVIF): bytes 4-8 = "ftyp", brand at 8-12 112 if (_bytesToAscii(bytes, 4, 4) === 'ftyp') { 113 var brand = _bytesToAscii(bytes, 8, 4).toLowerCase(); 114 var heicBrands = ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1']; 115 var avifBrands = ['avif', 'avis']; 116 if (heicBrands.indexOf(brand) !== -1) return 'heic'; 117 if (avifBrands.indexOf(brand) !== -1) return 'avif'; 118 return 'unknown'; // some other ISO-BMFF brand (e.g. plain MP4) â not an image we support 119 } 120 121 return 'unknown'; 122 } 123 124 // ââ EXIF orientation (JPEG only) ââââââââââââââââââââââââââââââââââââââââ 125 126 /** 127 * Parse the EXIF Orientation tag (0x0112) out of a JPEG ArrayBuffer. 128 * Returns an integer 1-8 (1 = normal / no correction needed). 129 * Returns 1 on any parse failure (safe default â no rotation applied). 130 */ 131 function _readJpegExifOrientation(buffer) { 132 try { 133 var view = new DataView(buffer); 134 if (view.getUint16(0, false) !== 0xFFD8) return 1; // not a JPEG SOI marker 135 136 var offset = 2; 137 var length = view.byteLength; 138 while (offset < length) { 139 var marker = view.getUint16(offset, false); 140 offset += 2; 141 if (marker === 0xFFE1) { // APP1 142 var app1Length = view.getUint16(offset, false); 143 var exifStart = offset + 2; 144 if (_bytesToAscii(new Uint8Array(buffer, exifStart, 4), 0, 4) !== 'Exif') { 145 offset += app1Length; 146 continue; 147 } 148 var tiffOffset = exifStart + 6; // skip "Exif\0\0" 149 var little = view.getUint16(tiffOffset, false) === 0x4949; 150 var firstIfdOffset = view.getUint32(tiffOffset + 4, little); 151 var dirStart = tiffOffset + firstIfdOffset; 152 var entries = view.getUint16(dirStart, little); 153 for (var i = 0; i < entries; i++) { 154 var entryOffset = dirStart + 2 + i * 12; 155 var tag = view.getUint16(entryOffset, little); 156 if (tag === 0x0112) { 157 var value = view.getUint16(entryOffset + 8, little); 158 return (value >= 1 && value <= 8) ? value : 1; 159 } 160 } 161 return 1; // APP1/Exif present but no Orientation tag 162 } else if ((marker & 0xFF00) !== 0xFF00) { 163 break; // not a valid marker â stop scanning 164 } else if (marker === 0xFFDA) { 165 break; // Start Of Scan â no more metadata markers follow 166 } else { 167 offset += view.getUint16(offset, false); // skip this segment 168 } 169 } 170 return 1; 171 } catch (_) { 172 return 1; // never let a malformed EXIF segment break intake 173 } 174 } 175 176 /** Apply the canvas transform matrix for a given EXIF orientation (1-8). */ 177 function _applyOrientationTransform(ctx, orientation, w, h) { 178 switch (orientation) { 179 case 2: ctx.transform(-1, 0, 0, 1, w, 0); break; 180 case 3: ctx.transform(-1, 0, 0, -1, w, h); break; 181 case 4: ctx.transform(1, 0, 0, -1, 0, h); break; 182 case 5: ctx.transform(0, 1, 1, 0, 0, 0); break; 183 case 6: ctx.transform(0, 1, -1, 0, h, 0); break; 184 case 7: ctx.transform(0, -1, -1, 0, h, w); break; 185 case 8: ctx.transform(0, -1, 1, 0, 0, w); break; 186 default: break; // 1 = normal, no transform needed 187 } 188 } 189 190 /** True for orientations that swap the visual width/height (90°/270° rotations). */ 191 function _orientationSwapsDims(orientation) { 192 return orientation >= 5 && orientation <= 8; 193 } 194 195 // ââ Canvas normalization core âââââââââââââââââââââââââââââââââââââââââââ 196 // Every supported format funnels through here exactly once: draws onto a 197 // WHITE-filled canvas (flattens any alpha transparency), applies EXIF 198 // rotation/flip when relevant, then re-encodes as a real JPEG. Re-encoding 199 // via canvas.toBlob() naturally drops all source metadata (EXIF, ICC 200 // profiles, XMP, etc.) since canvas only ever holds raw pixel data â
201 // satisfying "remove metadata" without any separate stripping step. 202 203 function _drawToJpegBlob(drawFn, srcW, srcH, orientation) { 204 return new Promise(function (resolve, reject) { 205 var swap = _orientationSwapsDims(orientation); 206 var canvas = document.createElement('canvas'); 207 canvas.width = swap ? srcH : srcW; 208 canvas.height = swap ? srcW : srcH; 209 var ctx = canvas.getContext('2d'); 210 211 // Flatten transparency onto white BEFORE any orientation transform, 212 // so the fill always covers the final (rotated) canvas dimensions. 213 ctx.fillStyle = '#FFFFFF'; 214 ctx.fillRect(0, 0, canvas.width, canvas.height); 215 216 ctx.save(); 217 _applyOrientationTransform(ctx, orientation, srcW, srcH); 218 drawFn(ctx); 219 ctx.restore(); 220 221 if (canvas.toBlob) { 222 canvas.toBlob(function (blob) { 223 if (!blob) { reject(new Error('Canvas re-encode produced no data')); return; } 224 resolve(blob); 225 }, 'image/jpeg', 0.92); 226 } else { 227 // Extremely old browsers only â toDataURL fallback. 228 var dataUrl = canvas.toDataURL('image/jpeg', 0.92); 229 var bin = atob(dataUrl.split(',')[1]); 230 var arr = new Uint8Array(bin.length); 231 for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); 232 resolve(new Blob([arr], { type: 'image/jpeg' })); 233 } 234 }); 235 } 236 237 function _blobToFile(blob, originalName) { 238 var base = (originalName || 'image').replace(/\.[^.]+$/, ''); 239 var name = base + '.jpg'; 240 try { 241 return new File([blob], name, { type: 'image/jpeg', lastModified: Date.now() }); 242 } catch (_) { 243 // Safari/old-browser File constructor fallback: attach fields to the Blob. 244 blob.name = name; 245 blob.lastModifiedDate = new Date(); 246 return blob; 247 } 248 } 249 250 // ââ Path A: formats the browser's own <img>/canvas can decode natively ââ 251 // (jpeg, png, gif [first frame], bmp, webp, avif-if-supported) 252 253 function _normalizeViaImageElement(file, orientation) { 254 return new Promise(function (resolve, reject) { 255 var url = URL.createObjectURL(file); 256 var img = new Image(); 257 img.onload = function () { 258 URL.revokeObjectURL(url); 259 var w = img.naturalWidth, h = img.naturalHeight; 260 if (!w || !h) { reject(_err('INTAKE_DECODE_FAILED', 'Image decoded to 0Ã0 dimensions.')); return; } 261 _drawToJpegBlob(function (ctx) { ctx.drawImage(img, 0, 0, w, h); }, w, h, orientation) 262 .then(function (blob) { resolve(_blobToFile(blob, file.name)); }) 263 .catch(reject); 264 }; 265 img.onerror = function () { 266 URL.revokeObjectURL(url); 267 reject(_err('INTAKE_DECODE_UNSUPPORTED', 268 'This browser could not decode the image. See format limitation notes.')); 269 }; 270 img.src = url; 271 }); 272 } 273 274 // ââ Path B: HEIC / HEIF via vendored heic2any (WASM libheif port) âââââââ 275 276 function _normalizeHeic(file) { 277 return _loadScriptOnce(VENDOR_BASE + 'heic2any.min.js').then(function () { 278 if (typeof global.heic2any !== 'function') { 279 return Promise.reject(_err('INTAKE_LIB_UNAVAILABLE', 'heic2any failed to initialize.')); 280 } 281 return global.heic2any({ blob: file, toType: 'image/jpeg', quality: 0.92 }).then(function (result) { 282 var jpegBlob = Array.isArray(result) ? result[0] : result; 283 // Re-run through the standard canvas pass: guarantees the SAME 284 // white-flatten + metadata-strip contract as every other format, 285 // rather than trusting heic2any's own JPEG bytes as the final output. 286 return _normalizeViaImageElement(jpegBlob, 1); 287 }); 288 }); 289 } 290 291 // ââ Path C: TIFF via vendored UTIF.js (+ pako for Deflate compression) ââ 292 293 function _normalizeTiff(file) { 294 return Promise.all([ 295 _loadScriptOnce(VENDOR_BASE + 'pako.min.js'), 296 _loadScriptOnce(VENDOR_BASE + 'UTIF.js'), 297 ]).then(function () { 298 return new Promise(function (resolve, reject) { 299 var reader = new FileReader(); 300 reader.onerror = function () { reject(_err('INTAKE_DECODE_FAILED', 'Cannot read TIFF file.')); }; 301 reader.onload = function () { 302 try { 303 var UTIF = global.UTIF; 304 var buffer = reader.result;
305 var ifds = UTIF.decode(buffer); 306 if (!ifds || !ifds.length) { 307 reject(_err('INTAKE_DECODE_FAILED', 'No image directories found in TIFF.')); 308 return; 309 } 310 var ifd = ifds[0]; 311 UTIF.decodeImage(buffer, ifd); 312 var rgba = UTIF.toRGBA8(ifd); 313 var w = ifd.width, h = ifd.height; 314 if (!w || !h) { 315 reject(_err('INTAKE_DECODE_FAILED', 'TIFF decoded to 0Ã0 dimensions.')); 316 return; 317 } 318 319 var orientation = 1; 320 if (ifd.t274 && ifd.t274.length) { 321 var v = ifd.t274[0]; 322 if (v >= 1 && v <= 8) orientation = v; 323 } 324 325 // Paint the decoded RGBA buffer onto an intermediate canvas first 326 // (putImageData ignores transforms/fills), then draw THAT canvas 327 // through the shared white-flatten + orientation pipeline. 328 var srcCanvas = document.createElement('canvas'); 329 srcCanvas.width = w; 330 srcCanvas.height = h; 331 var srcCtx = srcCanvas.getContext('2d'); 332 var imageData = srcCtx.createImageData(w, h); 333 imageData.data.set(rgba); 334 srcCtx.putImageData(imageData, 0, 0); 335 336 _drawToJpegBlob(function (ctx) { ctx.drawImage(srcCanvas, 0, 0, w, h); }, w, h, orientation) 337 .then(function (blob) { resolve(_blobToFile(blob, file.name)); }) 338 .catch(reject); 339 } catch (err) { 340 reject(_err('INTAKE_DECODE_FAILED', 'TIFF decode error: ' + err.message)); 341 } 342 }; 343 reader.readAsArrayBuffer(file); 344 }); 345 }); 346 } 347 348 function _err(code, message) { 349 var e = new Error(message); 350 e.code = code; 351 return e; 352 } 353 354 // ââ Public entry point ââââââââââââââââââââââââââââââââââââââââââââââââââ 355 356 /** 357 * Normalize any supported input file into a single, real JPEG File. 358 * @param {File} file 359 * @returns {Promise<File>} 360 */ 361 function normalize(file) { 362 if (!file) return Promise.reject(_err('NO_FILE', 'No file provided.')); 363 364 return _readHeaderBytes(file, 32).then(function (bytes) { 365 var format = _detectFormat(bytes); 366 367 switch (format) { 368 case 'jpeg': { 369 return new Promise(function (resolve, reject) { 370 var fr = new FileReader(); 371 fr.onload = function () { 372 var orientation = _readJpegExifOrientation(fr.result); 373 _normalizeViaImageElement(file, orientation).then(resolve, reject); 374 }; 375 fr.onerror = function () { 376 // Cannot read for EXIF â proceed with no rotation rather than fail. 377 _normalizeViaImageElement(file, 1).then(resolve, reject); 378 }; 379 fr.readAsArrayBuffer(file); 380 }); 381 } 382 383 case 'png': 384 case 'gif': 385 case 'bmp': 386 case 'webp': 387 case 'avif': 388 // No EXIF orientation concept for these formats in practice. 389 return _normalizeViaImageElement(file, 1); 390 391 case 'heic': 392 return _normalizeHeic(file); 393 394 case 'tiff': 395 return _normalizeTiff(file); 396 397 case 'unknown': 398 default: 399 return Promise.reject(_err('INTAKE_UNSUPPORTED_FORMAT', 400 'Unrecognized image format (no matching file signature).')); 401 } 402 }); 403 } 404 405 global.ImageIntake = { normalize: normalize, _detectFormat: _detectFormat }; 406})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.