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_onDid you know?
Most smartphones embed your GPS location in every photo you take!
exploreDigital Breadcrumbs:
Some cameras record the exact GPS coordinates and even the direction you were facing when you took a
photo!
infoFun Fact:
EXIF stands for Exchangeable Image File Format and was introduced in 1995.
visibilityHidden Details:
EXIF data can reveal if a photo was edited in Photoshop—or even which phone app was used to snap
it!
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;
}