EXIF Viewer: Instantly View Image Metadata Online. Alternative to EXIFTool, Metadata2Go, JIMPL, OnlineEXIFViewer, and EXIFPilot.

EXIFEditor.io - Free Online EXIF Metadata Editor Logo

photo_camera Upload Image(s)

Supported File types help_outline

map Location(s)

tag Metadata

No EXIF data found. Please upload an image file and associated EXIF data will be displayed here. Certain metadata can also be edited from this view, depending on the file type.

location_on Did you know?
Most smartphones embed your GPS location in every photo you take!
explore Digital Breadcrumbs:
Some cameras record the exact GPS coordinates and even the direction you were facing when you took a photo!
info Fun Fact:
EXIF stands for Exchangeable Image File Format and was introduced in 1995.
visibility Hidden Details:
EXIF data can reveal if a photo was edited in Photoshop—or even which phone app was used to snap it!
integration_instructions Full EXIF Reference Table expand_more
lock Privacy Policy expand_more

EXIFEditor.io vs Alternatives

Looking for an alternative to Metadata2Go, ExifTool, Jimpl, or Exif.tools? EXIFEditor.io is a free, privacy-first online EXIF editor and metadata viewer. Instantly view, edit, and remove image metadata (EXIF, IPTC, XMP) in your browser—no upload required.

  • EXIFEditor.io – Fast, private, no uploads, works in your browser.
  • Metadata2Go – Online metadata viewer and analyzer.
  • ExifTool – Powerful command-line EXIF tool for advanced users.
  • Jimpl – Simple online EXIF viewer and editor.
  • Exif.tools – Free online EXIF data viewer.

EXIFEditor.io is not affiliated with Metadata2Go, ExifTool, Jimpl, or Exif.tools. All trademarks are property of their respective owners.

'; document.getElementById('exifPlaceholder').style.display = 'block'; return; } exifDataDiv.innerHTML = ''; let hasData = false; for (const ifd in exif) { if (ifd === 'thumbnail') continue; const entries = exif[ifd]; for (const tag in entries) { hasData = true; const name = tagNames[ifd][tag]?.name || `${ifd}.${tag}`; const tagInfo = piexif.TAGS?.[ifd]?.[tag]; // Pass ref for GPS lat/long let ref = undefined; if (ifd === "GPS" && tag == "2") ref = exif.GPS?.[1]; if (ifd === "GPS" && tag == "4") ref = exif.GPS?.[3]; console.debug(`[SHOWEXIF] ${ifd}:${tag} (${name}) =`, formatExifValueForDisplay(entries[tag], tagInfo, ifd, tag)); if (tagInfo?.type === 7) { console.warn(`Skipping binary tag (UNDEFINED): ${tagInfo.name} (${ifd}:${tag})`); continue; } createExifInput(ifd, tag, entries[tag], name, tagInfo, ref); } } document.querySelectorAll('#exifData input').forEach(validateInput); document.getElementById('exifPlaceholder').style.display = hasData ? 'none' : 'block'; } function createExifInput(ifd, tag, value, labelText, tagInfo, gpsRef) { const inputId = `${ifd}-${tag}`; if (!originalExif[ifd]) originalExif[ifd] = {}; const isGpsRef = (ifd === "GPS" && (tag == "1" || tag == "3")); if (isGpsRef) { // Do not render this field at all return; } // Row: grid with two columns, align items start, border-bottom for separation const row = document.createElement('div'); row.className = 'grid grid-cols-12 gap-x-2 items-start border-b'; row.id = `wrapper-${inputId}`; // Label (left column) const label = document.createElement('span'); label.className = 'col-span-4 font-semibold text-xs text-gray-700 dark:text-gray-200 truncate flex items-start py-1'; label.textContent = labelText; // Value wrapper (right column, flex for input + display + delete) const valueWrapper = document.createElement('div'); valueWrapper.className = 'col-span-8 flex items-center text-right gap-2'; // Special handling for GPSLatitude and GPSLongitude const isGpsCoord = (ifd === "GPS" && (tag == "2" || tag == "4")); const isGpsAltitude = (ifd === "GPS" && tag == "6"); // Helper: Convert EXIF rational array to decimal (with sign) function gpsArrayToDecimal(arr, ref) { if (!Array.isArray(arr) || arr.length !== 3) return ''; function toFloat(val) { if (Array.isArray(val) && val.length === 2 && val[1] !== 0) return val[0] / val[1]; if (typeof val === "number") return val; return 0; } const deg = toFloat(arr[0]); const min = toFloat(arr[1]); const sec = toFloat(arr[2]); let dec = deg + min / 60 + sec / 3600; if (ref === "S" || ref === "W") dec = -dec; return (dec).toFixed(7).replace(/\.?0+$/, ""); } // Helper: Convert decimal to EXIF rational array function decimalToGpsArray(decimal) { let d = Math.abs(Number(decimal)); if (isNaN(d)) return [[0,1],[0,1],[0,1]]; const deg = Math.floor(d); const minFloat = (d - deg) * 60; const min = Math.floor(minFloat); const secFloat = (minFloat - min) * 60; const secDen = 10000000; const secNum = Math.round(secFloat * secDen); return [ [deg, 1], [min, 1], [secNum, secDen] ]; } let input; let isLongText = false; if ( (ifd === "0th" && tag == "270") || (tagInfo?.type === 2 && typeof value === "string" && value.length > 40) ) { isLongText = true; } const isFNumberDecimal = (ifd === "Exif" && (tag == "33437" || tag == "37378" || tag == "34868")); // FNumber, ApertureValue, MaxApertureValue const isDigitalZoomRatio = (ifd === "Exif" && tag == "41988"); // DigitalZoomRatio const isMaxApertureValue = (ifd === "Exif" && tag == "34868"); // MaxApertureValue const isFocalLengthDecimal = (ifd === "Exif" && tag == "37386"); // FocalLength const isDecimal4dp = // ExposureBiasValue, ExposureTime, BrightnessValue, CompressedBitsPerPixel (ifd === "Exif" && ( tag == "37380" || // ExposureBiasValue tag == "33434" || // ExposureTime tag == "37379" || // BrightnessValue tag == "37122" // CompressedBitsPerPixel )); const isResolution = (ifd === "0th" && (tag == "282" || tag == "283")); // XResolution, YResolution const isShutterSpeedValue = (ifd === "Exif" && tag == "37377"); // ShutterSpeedValue if (isMaxApertureValue) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { input.value = (value[0] / value[1]).toFixed(1); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isFNumberDecimal) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { input.value = (value[0] / value[1]).toFixed(1); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isDigitalZoomRatio) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { input.value = (value[0] / value[1]).toFixed(2); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isShutterSpeedValue) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { const apex = value[0] / value[1]; const seconds = 1 / Math.pow(2, apex); if (seconds >= 1) { input.value = seconds.toFixed(1); } else { input.value = `1/${Math.round(1 / seconds)}`; } } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isFocalLengthDecimal) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { input.value = (value[0] / value[1]).toFixed(2); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isDecimal4dp) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { input.value = (value[0] / value[1]).toFixed(4); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isGpsCoord) { input = document.createElement('input'); input.type = 'number'; input.step = 'any'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; // Set input value as decimal, using ref for sign let ref = gpsRef; if (typeof ref === "string") ref = ref.replace(/\0/g, ""); input.value = gpsArrayToDecimal(value, ref); } else if (isGpsAltitude) { input = document.createElement('input'); input.type = 'text'; // Use text to hide up/down buttons input.inputMode = 'decimal'; // For mobile decimal keyboard input.pattern = '[0-9]*[.,]?[0-9]*'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; // Show as decimal meters if (Array.isArray(value) && value.length === 2 && value[1] !== 0) { input.value = (value[0] / value[1]).toFixed(2).replace(/\.00$/, ""); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else if (isResolution) { input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; if (Array.isArray(value) && value.length === 2 && value[1] === 1) { input.value = value[0]; // Show just the numerator if denominator is 1 } else if (Array.isArray(value) && value.length === 2) { input.value = value.join('/'); } else { input.value = value != null ? value : ''; } valueWrapper.appendChild(input); } else { if (isLongText) { input = document.createElement('textarea'); input.rows = 2; input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none resize-y'; input.style.overflowWrap = "break-word"; input.style.wordBreak = "break-word"; input.style.whiteSpace = "pre-wrap"; input.style.maxWidth = "100%"; } else { input = document.createElement('input'); input.className = 'w-full bg-transparent text-xs text-right px-2 rounded focus:outline-none'; } } input.dataset.ifd = ifd; input.dataset.tag = tag; input.dataset.type = tagInfo?.type; input.id = inputId; // Always use the raw value for editing, except for GPS coords and FNumber/Aperture/MaxAperture (handled above) if ( !isGpsCoord && !isGpsAltitude && !isFNumberDecimal && !isMaxApertureValue && !isDigitalZoomRatio && !isDecimal4dp && !isFocalLengthDecimal && !isResolution && !isShutterSpeedValue ) { if (Array.isArray(value)) { input.value = value.join('/'); } else { input.value = value != null ? value : ''; } } // Show human-friendly value as a placeholder or next to the input const displayValue = formatExifValueForDisplay(value, tagInfo, ifd, tag); input.placeholder = displayValue !== input.value ? displayValue : ''; // Only show displaySpan for fields that are NOT Make, Model, MakerNote, XResolution, YResolution, GPSLatitude, GPSLongitude let showDisplaySpan = true; if ( (ifd === "0th" && (tag == "271" || tag == "272" || tag == "282" || tag == "283" || tag == "306")) || // <-- Added tag 306 (DateTime) (ifd === "Exif" && ( tag == "37500" || tag == "33437" || tag == "37378" || tag == "34868" || tag == "41988" || tag == "37380" || tag == "33434" || tag == "37379" || tag == "37122" || tag == "37386" || tag == "34855" || tag == "37521" || tag == "37121" || tag == "36867" || tag == "36868" || tag == "37377" )) || (ifd === "GPS" && (tag == "2" || tag == "4" || tag == "6" || tag == "27" || tag == "29")) || (ifd === "Interop" && tag == "1") ) { showDisplaySpan = false; } // Optionally, show as a next to the input for clarity: const displaySpan = document.createElement('span'); displaySpan.className = 'ml-2 text-gray-400 text-xs font-mono'; if (displayValue !== input.value && showDisplaySpan) displaySpan.textContent = displayValue; // Set input type and placeholder for if (!isLongText && !isGpsCoord) { if (tagInfo?.type === 5 || tagInfo?.type === 10) { input.type = "text"; } else if (tagInfo?.type === 1) { input.type = "number"; input.min = 0; input.max = 255; } else if (tagInfo?.type === 3) { input.type = "number"; input.min = 0; input.max = 65535; } else if (tagInfo?.type === 4) { input.type = "number"; input.min = 0; input.max = 4294967295; } else if (tagInfo?.type === 9) { input.type = "number"; input.min = -2147483648; input.max = 2147483647; } else { input.type = "text"; } } // Validation styling function updateValidation() { let isValid = true; if (isGpsCoord) { const v = parseFloat(input.value); isValid = !isNaN(v) && Math.abs(v) <= 180; } else { isValid = validateInput(input); } input.classList.remove('border-red-500', 'border-green-500', 'text-red-600', 'text-black'); if (isValid) { input.classList.add('border-green-500', 'text-black'); } else { input.classList.add('border-red-500', 'text-red-600'); } return isValid; } input.addEventListener('input', () => { try { if (isGpsCoord) { let v = parseFloat(input.value); const valid = !isNaN(v) && Math.abs(v) <= 180; if (valid) { const arr = decimalToGpsArray(v); originalExif[ifd][tag] = arr; if (!originalExif.GPS) originalExif.GPS = {}; if (tag == "2") { originalExif.GPS[1] = (v >= 0 ? "N" : "S") + "\0"; } else if (tag == "4") { originalExif.GPS[3] = (v >= 0 ? "E" : "W") + "\0"; } console.debug(`[GPS INPUT] ${labelText}:`, input.value, '| EXIF array:', arr, '| REF:', tag == "2" ? originalExif.GPS[1] : originalExif.GPS[3]); input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { // Do NOT write a value if invalid delete originalExif[ifd][tag]; console.warn(`[GPS INPUT] Invalid value for ${labelText}:`, input.value); input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else if (isGpsAltitude) { let v = parseFloat(input.value); const valid = !isNaN(v); if (valid) { // Store as rational [num, den] const precision = 1000; const num = Math.round(v * precision); const den = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(num, den); originalExif[ifd][tag] = [num / divisor, den / divisor]; input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { delete originalExif[ifd][tag]; input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else if (isFNumberDecimal) { let v = parseFloat(input.value); const valid = !isNaN(v) && v > 0; if (valid) { const precision = 1000; const num = Math.round(v * precision); const den = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(num, den); originalExif[ifd][tag] = [num / divisor, den / divisor]; input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { delete originalExif[ifd][tag]; input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else if (isResolution) { let v = input.value; if (typeof v === "string" && v.includes("/")) { const [numStr, denStr] = v.split("/"); const num = parseInt(numStr.trim(), 10); const den = parseInt(denStr.trim(), 10); if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) { originalExif[ifd][tag] = [num, den]; } else { delete originalExif[ifd][tag]; } } else { const num = parseFloat(v); if (!isNaN(num)) { originalExif[ifd][tag] = [num, 1]; } else { delete originalExif[ifd][tag]; } } updateValidation(); } else if (isShutterSpeedValue) { let v = input.value.trim(); let apex = null; if (v.includes('/')) { // Parse as 1/x const [num, den] = v.split('/').map(Number); if (num === 1 && den > 0) { apex = Math.log2(den); } } else { // Parse as decimal seconds const seconds = parseFloat(v); if (!isNaN(seconds) && seconds > 0) { apex = -Math.log2(seconds); } } if (apex !== null && isFinite(apex)) { const precision = 1000000; const num = Math.round(apex * precision); const den = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(num, den); originalExif[ifd][tag] = [num / divisor, den / divisor]; input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { delete originalExif[ifd][tag]; input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else if (isDigitalZoomRatio) { let v = parseFloat(input.value); const valid = !isNaN(v) && v >= 0; if (valid) { const precision = 1000; const num = Math.round(v * precision); const den = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(num, den); originalExif[ifd][tag] = [num / divisor, den / divisor]; input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { delete originalExif[ifd][tag]; input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else if (isDecimal4dp) { let v = parseFloat(input.value); const valid = !isNaN(v); if (valid) { const precision = 10000; const num = Math.round(v * precision); const den = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(num, den); originalExif[ifd][tag] = [num / divisor, den / divisor]; input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { delete originalExif[ifd][tag]; input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else if (isFocalLengthDecimal) { let v = parseFloat(input.value); const valid = !isNaN(v) && v > 0; if (valid) { const precision = 100; const num = Math.round(v * precision); const den = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(num, den); originalExif[ifd][tag] = [num / divisor, den / divisor]; input.classList.remove('border-red-500', 'text-red-600'); input.classList.add('border-green-500', 'text-black'); } else { delete originalExif[ifd][tag]; input.classList.remove('border-green-500', 'text-black'); input.classList.add('border-red-500', 'text-red-600'); } } else { updateValidation(); if (!originalExif[ifd]) originalExif[ifd] = {}; originalExif[ifd][tag] = input.value; } } catch (err) { console.error('[INPUT ERROR]', err); } }); // Initial validation updateValidation(); // Delete button (icon, small, right of input) const deleteBtn = document.createElement('button'); deleteBtn.className = 'ml-2 text-red-600 hover:text-red-800 material-icons p-1 rounded focus:outline-none mt-0'; deleteBtn.innerText = 'delete'; deleteBtn.title = 'Remove this field'; deleteBtn.style.fontSize = '1em'; deleteBtn.addEventListener('click', () => { delete originalExif[ifd][tag]; if (isGpsCoord) { // Also clear the corresponding REF field if (tag == "2") delete originalExif.GPS[1]; if (tag == "4") delete originalExif.GPS[3]; } row.remove(); const remainingInputs = exifDataDiv.querySelectorAll('input,textarea').length; if (remainingInputs === 0) { document.getElementById('exifPlaceholder').style.display = 'block'; downloadBtn.classList.add('hidden'); } }); valueWrapper.appendChild(input); if (displaySpan.textContent) valueWrapper.appendChild(displaySpan); valueWrapper.appendChild(deleteBtn); // Layout: label | valueWrapper (input + display + delete) row.appendChild(label); row.appendChild(valueWrapper); exifDataDiv.appendChild(row); document.getElementById('exifPlaceholder').style.display = 'none'; } const TYPE_MAP = { BYTE: 1, ASCII: 2, SHORT: 3, LONG: 4, RATIONAL: 5, UNDEFINED: 7, SLONG: 9, SRATIONAL: 10 }; function coerceExifValue(val, tagInfo, ifd, tag) { try { if (val == null) { console.debug(`[COERCE] Empty value for ${tagInfo?.name} (${tagInfo?.type})`); return ""; } let type = tagInfo.type; if (typeof type === "string") { type = TYPE_MAP[type.toUpperCase()] ?? NaN; } let originalVal = val; if (typeof val === "string") { val = val.replace(",", "/").trim(); } // Special handling for GPSLatitudeRef and GPSLongitudeRef if (ifd === "GPS" && (tag == "1" || tag == "3")) { // Only allow "N" or "S" for tag 1, "E" or "W" for tag 3 let v = String(originalVal ?? "").toUpperCase().replace(/[^A-Z]/g, ""); if (tag == "1") { if (v !== "N" && v !== "S") v = "N"; } else if (tag == "3") { if (v !== "E" && v !== "W") v = "E"; } return v + "\0"; } console.debug(`[COERCE] Tag: ${tagInfo?.name} (${type}) | Raw:`, originalVal, '| Normalized:', val); switch (type) { case 1: case 3: case 4: case 9: { const num = parseInt(val, 10); if (!Number.isFinite(num)) return null; return num; } case 5: case 10: { // Accept GPS arrays: [[deg,1],[min,1],[sec,den]] if (Array.isArray(val) && val.length === 3 && val.every( n => Array.isArray(n) && n.length === 2 && n.every(x => typeof x === 'number' && Number.isFinite(x)) )) { return val; } // Accept [num, den] rational if (Array.isArray(val) && val.length === 2 && val.every(n => typeof n === 'number' && Number.isFinite(n))) { return val; } // Accept string "num/den" if (typeof val === "string" && val.includes("/")) { const [numStr, denStr] = val.split("/"); const num = parseInt(numStr.trim(), 10); const den = parseInt(denStr.trim(), 10); if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null; return [num, den]; } // Accept decimal as numerator/denominator const numFloat = parseFloat(val); if (Number.isFinite(numFloat)) { // Convert decimal to fraction with up to 6 decimal places const precision = 1000000; const numerator = Math.round(numFloat * precision); const denominator = precision; function gcd(a, b) { return b ? gcd(b, a % b) : a; } const divisor = gcd(numerator, denominator); return [numerator / divisor, denominator / divisor]; } return null; } case 2: // ASCII let ascii = String(originalVal ?? ""); ascii = ascii.replace(/[\r\n]+$/g, '').trimEnd(); // Remove trailing newlines and spaces if (!ascii.endsWith('\0')) ascii += '\0'; return ascii; case 7: // UNDEFINED return String(originalVal ?? ""); default: console.warn(`[COERCE FAIL] Unhandled EXIF tag type`, type, tagInfo?.name, val); return null; } } catch (e) { console.warn("[COERCE] Exception for", tagInfo?.name, val, e); return null; } } // --- Download handler with extra logging --- downloadBtn.addEventListener('click', () => { if (!currentImageData) { console.error('No image data available for download.'); alert('Please upload an image first.'); return; } if (!currentImageData.startsWith("data:image/jpeg")) { alert("EXIF editing is currently supported only for JPEG files. Please use a .jpg image."); return; } let newExif = { '0th': {}, 'Exif': {}, 'GPS': {}, '1st': {} }; let allValid = true; document.querySelectorAll('#exifData input, #exifData textarea').forEach(input => { const ifd = input.dataset.ifd; const tag = input.dataset.tag; const tagInfo = piexif.TAGS?.[ifd]?.[tag]; const rawVal = input.value; const coerced = coerceExifValue(rawVal, tagInfo, ifd, tag); const isValid = coerced != null; input.classList.remove('border-red-500', 'border-green-500'); input.classList.add(isValid ? 'border-green-500' : 'border-red-500'); input.title = isValid ? 'Valid format' : `Invalid value for ${tagInfo.name}`; if (!isValid) { allValid = false; console.warn(`[INVALID] ${ifd}:${tag} (${tagInfo.name}) - Value:`, rawVal, 'Type:', tagInfo.type, '| Coerced:', coerced); return; } if (!newExif[ifd]) newExif[ifd] = {}; newExif[ifd][tag] = coerced; console.log(`[OK] ${ifd}:${tag} (${tagInfo.name}) - Type: ${tagInfo.type} - Value:`, coerced, '| typeof:', typeof coerced); }); if (!allValid) { alert("Please fix invalid EXIF fields (highlighted in red) before downloading."); return; } let sanitizedExif = { "0th": {}, "Exif": {}, "GPS": {}, "1st": {} }; for (const ifd in newExif) { for (const tag in newExif[ifd]) { const tagInfo = piexif.TAGS?.[ifd]?.[tag]; const value = newExif[ifd][tag]; // Validate type (but do NOT re-coerce) let valid = true; let type = tagInfo.type; if (typeof type === "string") { type = TYPE_MAP[type.toUpperCase()] ?? NaN; } switch (type) { case 1: case 3: case 4: case 9: valid = typeof value === 'number' && Number.isFinite(value); break; case 5: case 10: valid = ( // Accept [num, den] rational (Array.isArray(value) && value.length === 2 && value.every(n => typeof n === 'number' && Number.isFinite(n))) // Accept GPS arrays: [[deg,1],[min,1],[sec,den]] || (Array.isArray(value) && value.length === 3 && value.every( n => Array.isArray(n) && n.length === 2 && n.every(x => typeof x === 'number' && Number.isFinite(x)) )) ); break; case 2: // ASCII valid = typeof value === 'string'; // allow empty string break; case 7: // UNDEFINED valid = typeof value === 'string'; // allow empty string break; default: valid = false; } console.debug(`[SANITIZE] ifd: ${ifd}, tag: ${tag}, tagInfo:`, tagInfo, '| value:', value, '| typeof:', typeof value, '| valid:', valid); if (!valid) { console.warn(`[DROP] ${ifd}:${tag} (${tagInfo?.name}) - Invalid type. Value:`, value, 'Expected type:', tagInfo.type); continue; } if (!sanitizedExif[ifd]) sanitizedExif[ifd] = {}; sanitizedExif[ifd][tag] = value; console.log(`[WRITE] ${ifd}:${tag} (${tagInfo.name}) - Type: ${tagInfo.type} - Value:`, value, '| typeof:', typeof value); } } try { console.log("[DOWNLOAD] Sanitized EXIF before dump:", JSON.stringify(sanitizedExif, null, 2)); if (sanitizedExif.GPS) { console.log("[DEBUG] sanitizedExif.GPS before patch:", JSON.stringify(sanitizedExif.GPS, null, 2)); // --- Patch: Ensure GPS[2] and GPS[4] are always 3-element arrays (deg, min, sec) --- // If user entered a decimal or a 2-element rational, convert to EXIF GPS array function decimalToGpsArray(decimal) { let d = Math.abs(Number(decimal)); if (isNaN(d)) return [[0,1],[0,1],[0,1]]; const deg = Math.floor(d); const minFloat = (d - deg) * 60; const min = Math.floor(minFloat); const secFloat = (minFloat - min) * 60; const secDen = 10000000; const secNum = Math.round(secFloat * secDen); return [ [deg, 1], [min, 1], [secNum, secDen] ]; } // Patch Latitude // Patch Latitude if (sanitizedExif.GPS[2]) { let latArr = sanitizedExif.GPS[2]; let decimalLat = null; // If it's a 2-element rational, convert to 3-element array if (Array.isArray(latArr) && latArr.length === 2 && typeof latArr[0] === "number" && typeof latArr[1] === "number") { decimalLat = latArr[0] / latArr[1]; latArr = decimalToGpsArray(decimalLat); sanitizedExif.GPS[2] = latArr; console.log("[PATCHED] GPS[2] converted to 3-element array:", latArr); } else if (Array.isArray(latArr) && latArr.length === 3) { // Try to reconstruct decimal from array (for Ref) decimalLat = (latArr[0][0] / latArr[0][1]) + (latArr[1][0] / latArr[1][1]) / 60 + (latArr[2][0] / latArr[2][1]) / 3600; } // Set Ref if missing or wrong if (decimalLat !== null) { sanitizedExif.GPS[1] = (decimalLat < 0 ? "S" : "N") + "\0"; // Always store as positive sanitizedExif.GPS[2] = latArr.map(r => [Math.abs(r[0]), r[1]]); console.log("[DEBUG] Set GPS[1] (LatitudeRef):", sanitizedExif.GPS[1]); console.log("[DEBUG] Patched GPS[2] (abs):", sanitizedExif.GPS[2]); } } // Patch Longitude if (sanitizedExif.GPS[4]) { let lngArr = sanitizedExif.GPS[4]; let decimalLng = null; if (Array.isArray(lngArr) && lngArr.length === 2 && typeof lngArr[0] === "number" && typeof lngArr[1] === "number") { decimalLng = lngArr[0] / lngArr[1]; lngArr = decimalToGpsArray(decimalLng); sanitizedExif.GPS[4] = lngArr; console.log("[PATCHED] GPS[4] converted to 3-element array:", lngArr); } else if (Array.isArray(lngArr) && lngArr.length === 3) { decimalLng = (lngArr[0][0] / lngArr[0][1]) + (lngArr[1][0] / lngArr[1][1]) / 60 + (lngArr[2][0] / lngArr[2][1]) / 3600; } if (decimalLng !== null) { sanitizedExif.GPS[3] = (decimalLng < 0 ? "W" : "E") + "\0"; sanitizedExif.GPS[4] = lngArr.map(r => [Math.abs(r[0]), r[1]]); console.log("[DEBUG] Set GPS[3] (LongitudeRef):", sanitizedExif.GPS[3]); console.log("[DEBUG] Patched GPS[4] (abs):", sanitizedExif.GPS[4]); } } console.log("[DEBUG] sanitizedExif.GPS after patch:", JSON.stringify(sanitizedExif.GPS, null, 2)); } console.log("[DEBUG] sanitizedExif.GPS after patch:", JSON.stringify(sanitizedExif.GPS, null, 2)); const exifBytes = piexif.dump(sanitizedExif); console.log("[DOWNLOAD] piexif.dump output:", exifBytes); const newDataURL = piexif.insert(exifBytes, currentImageData); const blob = dataURLtoBlob(newDataURL); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'edited_image.jpg'; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error('[EXPORT ERROR] Download failed:', err); if (window.piexif && piexif.dump) { try { // Try to dump again and log the error console.log("[DEBUG] Final sanitizedExif.GPS before dump:", JSON.stringify(sanitizedExif.GPS, null, 2)); if (sanitizedExif.GPS) { // Patch GPSLatitude if (Array.isArray(sanitizedExif.GPS[2]) && sanitizedExif.GPS[2].length === 2) { // Convert decimal to [deg,min,sec] const decimal = sanitizedExif.GPS[2][0] / sanitizedExif.GPS[2][1]; sanitizedExif.GPS[2] = decimalToGpsArray(decimal); } // Patch GPSLongitude if (Array.isArray(sanitizedExif.GPS[4]) && sanitizedExif.GPS[4].length === 2) { const decimal = sanitizedExif.GPS[4][0] / sanitizedExif.GPS[4][1]; sanitizedExif.GPS[4] = decimalToGpsArray(decimal); } } piexif.dump(sanitizedExif); } catch (dumpErr) { console.error('[EXPORT ERROR] piexif.dump failed:', dumpErr, sanitizedExif); } } alert('EXIF export failed. See console for details.'); } }); function dataURLtoBlob(dataurl) { const arr = dataurl.split(','); const mime = arr[0].match(/:(.*?);/)[1]; const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new Blob([u8arr], { type: mime }); } resetBtn.addEventListener('click', () => { imageInput.value = ''; preview.src = ''; preview.classList.add('hidden'); previewContainer.classList.add('hidden'); exifDataDiv.innerHTML = ''; rawMetadataPre.style.display = 'none'; rawMetadataPre.textContent = ''; hexDumpPre.style.display = 'none'; hexDumpPre.textContent = ''; downloadBtn.classList.add('hidden'); resetBtn.classList.add('hidden'); currentImageData = null; originalExif = {}; document.getElementById('exifPlaceholder').style.display = 'block'; document.querySelectorAll('.exif-preset').forEach(btn => { btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed'); btn.title = "Only available for JPEG images"; }); debugPrefillButtons('resetBtn click'); // Collapse and clear file info setFileInfoEnabled(false); document.getElementById('dropAreaContainer').classList.remove('shrunk'); exifSearch.disabled = true; exifSearch.classList.add('bg-gray-100', 'text-gray-400', 'cursor-not-allowed'); updateActionButtonsVisibility(false); applyTilePreferences(); }); const toggleDark = document.getElementById('toggleDark'); if (toggleDark) { toggleDark.addEventListener('click', () => { body.classList.toggle('dark-mode'); }); } document.addEventListener('DOMContentLoaded', () => { console.log('[DEBUG] DOMContentLoaded fired'); const exifPresetButtons = document.querySelectorAll('.exif-preset'); console.log(`[DEBUG] Found ${exifPresetButtons.length} .exif-preset buttons`); exifPresetButtons.forEach((btn, idx) => { // Always disable and style on load btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed'); btn.title = "Only available for JPEG images"; //console.log(`[DEBUG] Button #${idx} "${btn.textContent.trim()}" initialized as disabled`); // Remove any previous click listeners (for hot reload/debugging) }); // Re-select buttons after cloneNode (removes old listeners) document.querySelectorAll('.exif-preset').forEach((btn, idx) => { btn.addEventListener('click', function (e) { //console.log(`[DEBUG] Button #${idx} "${btn.textContent.trim()}" clicked`); console.log(`[LOG] .exif-preset button clicked: idx=${idx}, text="${btn.textContent.trim()}", disabled=${btn.disabled}`); if (btn.disabled) { //console.log(`[DEBUG] Button #${idx} is disabled, ignoring click`); return; } const ifd = btn.dataset.ifd; const tag = btn.dataset.tag; const labelText = tagNames[ifd]?.[tag]?.name || `${ifd}.${tag}`; const tagInfo = piexif.TAGS?.[ifd]?.[tag]; console.log('[DEBUG] Prefill click details:', { ifd, tag, labelText, tagInfo }); const inputId = `${ifd}-${tag}`; if (document.getElementById(inputId)) { console.log(`[DEBUG] Input for ${inputId} already exists, not adding again`); return; } let defaultValue = ''; if (tagInfo) { let type = tagInfo.type; if (typeof type === "string") type = TYPE_MAP[type.toUpperCase()] ?? NaN; switch (type) { case 1: case 3: case 4: case 9: defaultValue = 0; break; case 5: case 10: // Special: GPSLatitude (2) and GPSLongitude (4) must be [[0,1],[0,1],[0,1]] if (ifd === "GPS" && (tag == "2" || tag == "4")) { defaultValue = [[0,1],[0,1],[0,1]]; } else { defaultValue = [0, 1]; } break; case 2: defaultValue = ''; break; case 7: defaultValue = ''; break; default: defaultValue = ''; } console.log(`[DEBUG] Using default value for type ${type}:`, defaultValue); } else { console.log('[DEBUG] No tagInfo found, using empty string as default value'); } if (!originalExif[ifd]) originalExif[ifd] = {}; originalExif[ifd][tag] = defaultValue; console.log('[DEBUG] Updated originalExif:', JSON.stringify(originalExif)); if (typeof createExifInput === 'function') { createExifInput(ifd, tag, defaultValue, labelText, tagInfo); console.log('[DEBUG] Called createExifInput'); } else { console.error('[DEBUG] createExifInput is not defined or not a function!'); } const exifPlaceholder = document.getElementById('exifPlaceholder'); if (exifPlaceholder) { exifPlaceholder.style.display = 'none'; console.log('[DEBUG] exifPlaceholder hidden'); } if (downloadBtn) { downloadBtn.classList.remove('hidden'); console.log('[DEBUG] downloadBtn shown'); } if (resetBtn) { resetBtn.classList.remove('hidden'); console.log('[DEBUG] resetBtn shown'); } }); }); //debugPrefillButtons('DOMContentLoaded'); // Collapsible EXIF Reference Table const exifReferencePanel = document.getElementById('exifReferencePanel'); const toggleExifReference = document.getElementById('toggleExifReference'); const exifReferenceChevron = document.getElementById('exifReferenceChevron'); if (toggleExifReference && exifReferencePanel && exifReferenceChevron) { toggleExifReference.addEventListener('click', () => { const isHidden = exifReferencePanel.classList.toggle('hidden'); exifReferenceChevron.classList.toggle('rotate-180', !isHidden); toggleExifReference.classList.toggle('open', !isHidden); // Only load the table once, when first opened if (!tableLoaded && !isHidden) { fetch('exif-reference-table.html') .then(res => res.text()) .then(html => { exifReferencePanel.innerHTML = html; tableLoaded = true; }) .catch(err => { exifReferencePanel.innerHTML = '
Failed to load EXIF reference table.
'; }); } }); } // --- Expand and scroll to EXIF Reference Panel when link is clicked --- document.querySelectorAll('a[href="#exifReferencePanel"]').forEach(link => { link.addEventListener('click', function (e) { e.preventDefault(); const panel = document.getElementById('exifReferencePanel'); const toggle = document.getElementById('toggleExifReference'); const chevron = document.getElementById('exifReferenceChevron'); if (panel && toggle && chevron) { // If panel is hidden, trigger the toggle to expand and load if (panel.classList.contains('hidden')) { toggle.click(); // Wait for animation/content load, then scroll setTimeout(() => { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 350); // 350ms to allow for animation and fetch } else { // Already visible, just scroll panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } }); }); applyTilePreferences(); }); document.addEventListener('dragover', function (e) { e.preventDefault(); e.stopPropagation(); }); document.addEventListener('drop', function (e) { e.preventDefault(); e.stopPropagation(); }); function debugPrefillButtons(context) { console.log(`--- Prefill Button State: ${context} ---`); document.querySelectorAll('.exif-preset').forEach((btn, i) => { console.log( `Button #${i}: text="${btn.textContent.trim()}", disabled=${btn.disabled}, classes="${btn.className}", title="${btn.title}"` ); }); } function attachExifPresetListeners() { document.querySelectorAll('.exif-preset').forEach((btn, idx) => { // Remove previous listeners by cloning const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.addEventListener('click', function (e) { //console.log(`[DEBUG] Button #${idx} "${newBtn.textContent.trim()}" clicked`); console.log(`[LOG] .exif-preset button clicked: idx=${idx}, text="${newBtn.textContent.trim()}", disabled=${newBtn.disabled}`); if (newBtn.disabled) { //console.log(`[DEBUG] Button #${idx} is disabled, ignoring click`); return; } const ifd = newBtn.dataset.ifd; const tag = newBtn.dataset.tag; const labelText = tagNames[ifd]?.[tag]?.name || `${ifd}.${tag}`; const tagInfo = piexif.TAGS?.[ifd]?.[tag]; console.log('[DEBUG] Prefill click details:', { ifd, tag, labelText, tagInfo }); const inputId = `${ifd}-${tag}`; if (document.getElementById(inputId)) { console.log(`[DEBUG] Input for ${inputId} already exists, not adding again`); return; } let defaultValue = ''; if (tagInfo) { let type = tagInfo.type; if (typeof type === "string") type = TYPE_MAP[type.toUpperCase()] ?? NaN; switch (type) { case 1: case 3: case 4: case 9: defaultValue = 0; break; case 5: case 10: // Special: GPSLatitude (2) and GPSLongitude (4) must be [[0,1],[0,1],[0,1]] if (ifd === "GPS" && (tag == "2" || tag == "4")) { defaultValue = [[0,1],[0,1],[0,1]]; } else { defaultValue = [0, 1]; } break; case 2: defaultValue = ''; break; case 7: defaultValue = ''; break; default: defaultValue = ''; } console.log(`[DEBUG] Using default value for type ${type}:`, defaultValue); } else { console.log('[DEBUG] No tagInfo found, using empty string as default value'); } if (!originalExif[ifd]) originalExif[ifd] = {}; originalExif[ifd][tag] = defaultValue; console.log('[DEBUG] Updated originalExif:', JSON.stringify(originalExif)); if (typeof createExifInput === 'function') { createExifInput(ifd, tag, defaultValue, labelText, tagInfo); console.log('[DEBUG] Called createExifInput'); } else { console.error('[DEBUG] createExifInput is not defined or not a function!'); } const exifPlaceholder = document.getElementById('exifPlaceholder'); if (exifPlaceholder) { exifPlaceholder.style.display = 'none'; console.log('[DEBUG] exifPlaceholder hidden'); } if (downloadBtn) { downloadBtn.classList.remove('hidden'); console.log('[DEBUG] downloadBtn shown'); } if (resetBtn) { resetBtn.classList.remove('hidden'); console.log('[DEBUG] resetBtn shown'); } }); }); } function getExifPlaceholder(tagInfo, labelText) { if (!tagInfo) return "Enter value"; let type = tagInfo.type; if (typeof type === "string") type = TYPE_MAP[type.toUpperCase()] ?? NaN; // Examples for common EXIF types switch (type) { case 1: // BYTE return "e.g. 0-255"; case 2: // ASCII if (/date|time/i.test(labelText)) return "e.g. 2024:05:30 12:34:56"; if (/desc|comment/i.test(labelText)) return "e.g. Description text"; if (/artist|author/i.test(labelText)) return "e.g. John Doe"; if (/make/i.test(labelText)) return "e.g. Canon"; if (/model/i.test(labelText)) return "e.g. EOS 5D"; return "e.g. Text"; case 3: // SHORT return "e.g. 0-65535"; case 4: // LONG return "e.g. 0-4294967295"; case 5: // RATIONAL if (/resolution/i.test(labelText)) return "e.g. 72/1"; if (/exposure/i.test(labelText)) return "e.g. 1/125"; return "e.g. numerator/denominator"; case 7: // UNDEFINED return "e.g. (raw data)"; case 9: // SLONG return "e.g. -2147483648 to 2147483647"; case 10: // SRATIONAL return "e.g. -1/100"; default: return "Enter value"; } } function formatExifValueForDisplay(value, tagInfo, ifd, tag) { if (!tagInfo) return String(value); let type = tagInfo.type; if (typeof type === "string") type = TYPE_MAP[type.toUpperCase()] ?? NaN; const name = tagInfo.name || ""; // Helper for rational to float function rationalToFloat(val) { if (Array.isArray(val) && val.length === 2 && val[1] !== 0) { return val[0] / val[1]; } return NaN; } // Helper for formatting floats function formatFloat(val, decimals = 2) { return Number(val).toFixed(decimals).replace(/\.00$/, ""); } // Helper for showing 1/x for shutter speed/exposure time function showFractionOrFloat(val, decimals = 6) { if (Array.isArray(val) && val.length === 2 && val[1] !== 0) { if (val[0] === 1 && val[1] > 1 && val[1] < 100000) return `1/${val[1]}`; return formatFloat(val[0] / val[1], decimals); } return String(val); } // --- FILE/GENERIC FIELDS --- if (/File Size/i.test(name) && typeof value === "number") { return (value / 1024).toFixed(0) + " kB"; } if (/File Modify Date|File Access Date|File Inode Change Date/i.test(name) && typeof value === "string") { return value.replace(/:/g, "/").replace(/^(\d{4})\/(\d{2})\/(\d{2})/, "$1/$2/$3"); } if (/File Permissions/i.test(name)) return value; if (/File Type Extension/i.test(name)) return value; if (/MIME Type/i.test(name)) return value; if (/Encoding Process/i.test(name)) return value; if (/Bits Per Sample/i.test(name)) return value; if (/Color Components/i.test(name)) return value; if (/Y Cb Cr Sub Sampling/i.test(name)) return value; // --- EXIF FIELDS --- if (/Orientation/i.test(name)) { const map = { 1: "Horizontal (normal)", 3: "Rotate 180°", 6: "Rotate 90° CW", 8: "Rotate 270° CW" }; return map[value] || value; } if (/XResolution|YResolution/i.test(name) && Array.isArray(value) && value.length === 2) { if (value[1] === 1) return value[0]; return formatFloat(rationalToFloat(value), 0); } if (/Resolution Unit/i.test(name)) { const map = { 2: "inches", 3: "cm" }; return map[value] || value; } if (/YCbCrPositioning/i.test(name)) { const map = { 1: "Centered", 2: "Co-sited" }; return map[value] || value; } if (/Exposure Time/i.test(name) && Array.isArray(value) && value.length === 2) { return showFractionOrFloat(value, 0); } // F Number, Aperture Value, Max Aperture Value: always show as float, 1 decimal if ((/F Number|Aperture Value|Max Aperture Value/i.test(name)) && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 1); } if (/Exposure Program/i.test(name)) { const map = { 0: "Not defined", 1: "Manual", 2: "Program AE", 3: "Aperture-priority AE", 4: "Shutter speed priority AE", 5: "Creative (slow speed)", 6: "Action (high speed)", 7: "Portrait", 8: "Landscape" }; return map[value] || value; } if (/ISO/i.test(name)) return value; if (/Exif Version/i.test(name)) return value; if (/Date Time Original|Create Date|Modify Date|Date Time Digitized/i.test(name) && typeof value === "string") { return value.replace(/:/g, "/").replace(/^(\d{4})\/(\d{2})\/(\d{2})/, "$1/$2/$3"); } if (/Components Configuration/i.test(name) && typeof value === "string") { return value.split("").map(c => { if (c.charCodeAt(0) >= 32 && c.charCodeAt(0) <= 126) return c; if (c.charCodeAt(0) === 0) return "-"; return ""; }).join(", "); } if (/Compressed Bits Per Pixel/i.test(name) && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 9); } if (/Shutter Speed Value/i.test(name) && Array.isArray(value) && value.length === 2) { return showFractionOrFloat(value, 0); } if (/Brightness Value/i.test(name) && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 6); } if (/Exposure Compensation/i.test(name) && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 0); } if (/Metering Mode/i.test(name)) { const map = { 0: "Unknown", 1: "Average", 2: "Center-weighted average", 3: "Spot", 4: "Multi-spot", 5: "Multi-segment", 6: "Partial", 255: "Other" }; return map[value] || value; } if (/Light Source/i.test(name)) { const map = { 0: "Unknown", 1: "Daylight", 2: "Fluorescent", 3: "Tungsten", 4: "Flash", 9: "Fine weather", 10: "Cloudy", 11: "Shade", 12: "Daylight fluorescent", 13: "Day white fluorescent", 14: "Cool white fluorescent", 15: "White fluorescent", 17: "Standard light A", 18: "Standard light B", 19: "Standard light C", 20: "D55", 21: "D65", 22: "D75", 23: "D50", 24: "ISO studio tungsten", 255: "Other" }; return map[value] || value; } if (/Flash/i.test(name)) { return value === 0 ? "No Flash" : "Flash Fired"; } // Focal Length: always show as float + " mm" if (/Focal Length/i.test(name) && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 2) + " mm"; } // --- MAKER NOTE: Show as readable text, not hex --- if (/MakerNote/i.test(name)) { // If it's a Uint8Array or array of bytes, show as hex if (value instanceof Uint8Array || (Array.isArray(value) && typeof value[0] === "number")) { // Show first 32 bytes as hex, then "..." const hex = Array.from(value).slice(0, 32).map(b => b.toString(16).padStart(2, '0')).join(' '); return `(${value.length} bytes) ${hex}${value.length > 32 ? ' ...' : ''}`; } // If it's a string, remove nulls and non-printables if (typeof value === "string") { // Remove nulls and non-printable chars except basic punctuation return value.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim() || "(binary data)"; } // Otherwise, show as JSON try { return JSON.stringify(value).replace(/[\x00-\x1F\x7F-\x9F]/g, ''); } catch { return "(unreadable MakerNote)"; } } if (/Sub Sec Time Original/i.test(name)) return value; if (/Flashpix Version/i.test(name)) return value; if (/Color Space/i.test(name)) { if (value === 1) return "sRGB"; if (value === 65535) return "Uncalibrated"; return value; } if (/Exif Image Width|Exif Image Height|Image Width|Image Height/i.test(name)) return value; if (/Interop Index/i.test(name) && typeof value === "string") { if (value.startsWith("R98")) return "R98 - DCF basic file (sRGB)"; return value; } if (/Interop Version/i.test(name)) return value; if (/Custom Rendered/i.test(name)) { const map = { 0: "Normal", 1: "Custom" }; return map[value] || value; } if (/Exposure Mode/i.test(name)) { const map = { 0: "Auto", 1: "Manual", 2: "Auto bracket" }; return map[value] || value; } if (/White Balance/i.test(name)) { return value === 0 ? "Auto" : value === 1 ? "Manual" : value; } // Digital Zoom Ratio: always show as float, 1 decimal if (/Digital Zoom Ratio/i.test(name) && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 1); } if (/Focal Length In35mm Format/i.test(name)) return value + " mm"; if (/Scene Capture Type/i.test(name)) { const map = { 0: "Standard", 1: "Landscape", 2: "Portrait", 3: "Night Scene" }; return map[value] || value; } if (/Gain Control/i.test(name)) { const map = { 0: "None", 1: "Low gain up", 2: "High gain up", 3: "Low gain down", 4: "High gain down" }; return map[value] || value; } if (/Contrast|Saturation|Sharpness/i.test(name)) { const map = { 0: "Normal", 1: "Low", 2: "High" }; return map[value] || value; } // --- GPS FIELDS --- if (ifd === "GPS") { if (["2", "4"].includes(String(tag)) && Array.isArray(value) && value.length === 3) { const toFloat = ([num, den]) => den ? num / den : 0; const [deg, min, sec] = value; const decimal = toFloat(deg) + toFloat(min) / 60 + toFloat(sec) / 3600; return decimal.toFixed(7).replace(/\.?0+$/, ""); } if (String(tag) === "6" && Array.isArray(value) && value.length === 2) { return formatFloat(rationalToFloat(value), 1) + " m"; } if (String(tag) === "5") { return value === 1 ? "Below Sea Level" : "Above Sea Level"; } if (String(tag) === "1") return value === "N" ? "North" : "South"; if (String(tag) === "3") return value === "E" ? "East" : "West"; if (String(tag) === "7" && Array.isArray(value) && value.length === 3) { const [h, m, s] = value.map(rationalToFloat); return `${String(Math.floor(h)).padStart(2, "0")}:${String(Math.floor(m)).padStart(2, "0")}:${String(Math.floor(s)).padStart(2, "0")}`; } if (String(tag) === "27" && typeof value === "string") { return value.replace(/\0/g, ""); } if (String(tag) === "29" && typeof value === "string") { return value.replace(/:/g, "/"); } } // --- COMPOSITE FIELDS --- if (/Aperture/i.test(name) && typeof value === "number") return formatFloat(value, 1); if (/Image Size/i.test(name)) return value; if (/Megapixels/i.test(name)) return value; if (/Scale Factor35efl/i.test(name)) return value; if (/Shutter Speed Value/i.test(name) && Array.isArray(value) && value.length === 2) { // Convert APEX to seconds: 1 / (2^APEX) const apex = value[0] / value[1]; const seconds = 1 / Math.pow(2, apex); if (seconds >= 1) { return seconds.toFixed(1) + " s"; } else { // Show as 1/x if possible const frac = Math.round(1 / seconds); return `1/${frac}`; } } if (/Sub Sec Date Time Original/i.test(name)) return value; if (/GPS Altitude/i.test(name) && typeof value === "string") return value; if (/GPS Date Time/i.test(name)) return value; if (/GPS Latitude/i.test(name) && typeof value === "string") return value; if (/GPS Longitude/i.test(name) && typeof value === "string") return value; if (/Circle Of Confusion/i.test(name)) return value; if (/FOV/i.test(name)) return value; if (/Focal Length35efl/i.test(name)) return value; if (/GPS Position/i.test(name)) return value; if (/Hyperfocal Distance/i.test(name)) return value; if (/Light Value/i.test(name)) return value; // --- FALLBACKS --- if (Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") { return value[1] === 1 ? value[0] : formatFloat(rationalToFloat(value), 6); } if (type === 2 && typeof value === "string" && value.endsWith('\0')) { return value.slice(0, -1); } // For UNDEFINED (type 7) and other binary, show as readable text if possible if (type === 7 && typeof value === "string") { let clean = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); const printable = clean.replace(/[^\x20-\x7E\r\n\t]/g, ""); if (printable.length / clean.length > 0.8) { return printable; } if (typeof window !== "undefined" && window.TextDecoder) { try { const bytes = Uint8Array.from(value, c => c.charCodeAt(0)); const decoded = new TextDecoder("utf-8", { fatal: false }).decode(bytes); const decodedClean = decoded.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); if (decodedClean.length / decoded.length > 0.8) return decodedClean; } catch (e) {} } return value.replace(/[^\x20-\x7E\r\n\t]/g, "."); } return String(value); } // Helper to format bytes function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; } // Populate file info after image loads function showFileInfo(file, img, exif) { console.log('[DEBUG] showFileInfo called', { file, img, exif }); document.getElementById('fileType').textContent = file?.type || ''; document.getElementById('fileSize').textContent = file ? formatBytes(file.size) : ''; document.getElementById('imageSize').textContent = img.naturalWidth && img.naturalHeight ? `${img.naturalWidth} × ${img.naturalHeight}` : ''; document.getElementById('mimeType').textContent = file?.type || ''; document.getElementById('lastModified').textContent = file?.lastModified ? new Date(file.lastModified).toLocaleString() : ''; // Bits per sample (from EXIF if available) let bits = ''; if (exif?.['0th']?.[258]) { // Tag 258: BitsPerSample bits = Array.isArray(exif['0th'][258]) ? exif['0th'][258].join(', ') : exif['0th'][258]; } document.getElementById('bitsPerSample').textContent = bits; // Color space (from EXIF if available) let color = ''; if (exif?.Exif?.[40961]) { // Tag 40961: ColorSpace color = exif.Exif[40961] === 1 ? 'sRGB' : (exif.Exif[40961] === 65535 ? 'Uncalibrated' : exif.Exif[40961]); } document.getElementById('colorSpace').textContent = color; // Caption (ImageDescription, tag 270) let caption = ''; if (exif?.['0th']?.[270]) { caption = formatExifValueForDisplay(exif['0th'][270], piexif.TAGS['0th'][270]); } document.getElementById('caption').textContent = caption; }
`; asciiRow += `${(code >= 32 && code <= 126) ? row[j] : '.'}`; } while (hexRow.length < 48 * 2) hexRow += ' '; hex += hexRow + ' ' + asciiRow + '\n'; } } else { hex = 'No EXIF metadata found in this image.'; } hexDumpPre.innerHTML = hex; return; } // PNG logic if (fileType.startsWith('image/png') || dataURL.startsWith('data:image/png')) { // Convert dataURL to ArrayBuffer const arr = dataURL.split(','); const bstr = atob(arr[1]); const buf = new Uint8Array(bstr.length); for (let i = 0; i < bstr.length; i++) buf[i] = bstr.charCodeAt(i); // Parse PNG chunks const data = new DataView(buf.buffer); let offset = 8; // skip PNG signature let hex = ''; while (offset < data.byteLength) { const length = data.getUint32(offset); const type = String.fromCharCode( data.getUint8(offset + 4), data.getUint8(offset + 5), data.getUint8(offset + 6), data.getUint8(offset + 7) ); // Only dump metadata chunks (not IDAT/IEND) if (type !== 'IDAT' && type !== 'IEND') { const chunkBytes = buf.slice(offset, offset + 12 + length); hex += `${type} (${length} bytes)\n`; for (let i = 0; i < chunkBytes.length; i += 16) { let row = chunkBytes.slice(i, i + 16); let hexRow = ''; let asciiRow = ''; for (let j = 0; j < row.length; j++) { const code = row[j]; const hexByte = code.toString(16).padStart(2, '0'); hexRow += `${hexByte} `; asciiRow += (code >= 32 && code <= 126) ? String.fromCharCode(code) : '.'; } while (hexRow.length < 48) hexRow += ' '; hex += hexRow + ' ' + asciiRow + '\n'; } hex += '\n'; } offset += 12 + length; } if (!hex) hex = 'No PNG metadata chunks found.'; hexDumpPre.innerHTML = hex; return; } // GIF logic (header, logical screen descriptor, global color table, first extension) if ( (fileType && fileType.startsWith('image/gif')) || (dataURL && dataURL.startsWith('data:image/gif')) ) { const arr = dataURL.split(','); const bstr = atob(arr[1]); const buf = new Uint8Array(bstr.length); for (let i = 0; i < bstr.length; i++) buf[i] = bstr.charCodeAt(i); let hex = ''; let offset = 0; // Header (6 bytes) hex += `GIF Header (6 bytes)\n`; hex += dumpHex(buf, offset, 6) + '\n'; offset += 6; // Logical Screen Descriptor (7 bytes) hex += `Logical Screen Descriptor (7 bytes)\n`; hex += dumpHex(buf, offset, 7) + '\n'; // Parse global color table size const packed = buf[offset]; const hasGCT = !!(packed & 0x80); const gctSize = hasGCT ? 3 * (2 ** ((packed & 0x07) + 1)) : 0; offset += 7; // Global Color Table if (hasGCT && gctSize > 0 && (offset + gctSize) <= buf.length) { hex += `Global Color Table (${gctSize} bytes)\n`; hex += dumpHex(buf, offset, gctSize) + '\n'; offset += gctSize; } // First Extension Block (optional, e.g. Application Extension, Comment Extension) // We'll show up to the next 64 bytes for context if (offset < buf.length) { let extLen = Math.min(64, buf.length - offset); hex += `First Extension Block(s) or Image Descriptor (up to ${extLen} bytes)\n`; hex += dumpHex(buf, offset, extLen) + '\n'; } hexDumpPre.innerHTML = hex; return; } // Fallback hexDumpPre.innerHTML = 'No metadata hex dump available for this file type.'; } // Helper function for hex/ascii dump function dumpHex(buf, start, len) { let out = ''; for (let i = start; i < start + len; i += 16) { let row = buf.slice(i, Math.min(i + 16, start + len)); let hexRow = ''; let asciiRow = ''; for (let j = 0; j < row.length; j++) { const code = row[j]; const hexByte = code.toString(16).padStart(2, '0'); hexRow += `${hexByte} `; asciiRow += (code >= 32 && code <= 126) ? String.fromCharCode(code) : '.'; } while (hexRow.length < 48) hexRow += ' '; out += hexRow + ' ' + asciiRow + '\n'; } return out; }