User:Kevin Payravi/QuickEXIF.js

// ==UserScript==
// @name         Commons EXIF Editor
// @description  Edit select EXIF metadata directly on Wikimedia Commons file pages and re-upload with modified data
// @namespace    https://commons.wikimedia.org/
// @match        https://commons.wikimedia.org/wiki/File:*
// @author       Kevin Payravi / WikiPortraits
// ==/UserScript==

// This script uses the piexifjs library for EXIF manipulation
// https://github.com/hMatoba/piexifjs
// Exported 2026-01-31
// "End of piexifjs" comment marks the end of the library code
/* piexifjs

The MIT License (MIT)

Copyright (c) 2014, 2015 hMatoba(https://github.com/hMatoba)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

(function () {
    "use strict";
    var that = {};
    that.version = "1.0.4";

    that.remove = function (jpeg) {
        var b64 = false;
        if (jpeg.slice(0, 2) == "\xff\xd8") {
        } else if (jpeg.slice(0, 23) == "data:image/jpeg;base64," || jpeg.slice(0, 22) == "data:image/jpg;base64,") {
            jpeg = atob(jpeg.split(",")[1]);
            b64 = true;
        } else {
            throw new Error("Given data is not jpeg.");
        }

        var segments = splitIntoSegments(jpeg);
        var newSegments = segments.filter(function (seg) {
            return !(seg.slice(0, 2) == "\xff\xe1" &&
                seg.slice(4, 10) == "Exif\x00\x00");
        });

        var new_data = newSegments.join("");
        if (b64) {
            new_data = "data:image/jpeg;base64," + btoa(new_data);
        }

        return new_data;
    };


    that.insert = function (exif, jpeg) {
        var b64 = false;
        if (exif.slice(0, 6) != "\x45\x78\x69\x66\x00\x00") {
            throw new Error("Given data is not exif.");
        }
        if (jpeg.slice(0, 2) == "\xff\xd8") {
        } else if (jpeg.slice(0, 23) == "data:image/jpeg;base64," || jpeg.slice(0, 22) == "data:image/jpg;base64,") {
            jpeg = atob(jpeg.split(",")[1]);
            b64 = true;
        } else {
            throw new Error("Given data is not jpeg.");
        }

        var exifStr = "\xff\xe1" + pack(">H", [exif.length + 2]) + exif;
        var segments = splitIntoSegments(jpeg);
        var new_data = mergeSegments(segments, exifStr);
        if (b64) {
            new_data = "data:image/jpeg;base64," + btoa(new_data);
        }

        return new_data;
    };


    that.load = function (data) {
        var input_data;
        if (typeof (data) == "string") {
            if (data.slice(0, 2) == "\xff\xd8") {
                input_data = data;
            } else if (data.slice(0, 23) == "data:image/jpeg;base64," || data.slice(0, 22) == "data:image/jpg;base64,") {
                input_data = atob(data.split(",")[1]);
            } else if (data.slice(0, 4) == "Exif") {
                input_data = data.slice(6);
            } else {
                throw new Error("'load' gots invalid file data.");
            }
        } else {
            throw new Error("'load' gots invalid type argument.");
        }

        var exifDict = {};
        var exif_dict = {
            "0th": {},
            "Exif": {},
            "GPS": {},
            "Interop": {},
            "1st": {},
            "thumbnail": null
        };
        var exifReader = new ExifReader(input_data);
        if (exifReader.tiftag === null) {
            return exif_dict;
        }

        if (exifReader.tiftag.slice(0, 2) == "\x49\x49") {
            exifReader.endian_mark = "<";
        } else {
            exifReader.endian_mark = ">";
        }

        var pointer = unpack(exifReader.endian_mark + "L",
            exifReader.tiftag.slice(4, 8))[0];
        exif_dict["0th"] = exifReader.get_ifd(pointer, "0th");

        var first_ifd_pointer = exif_dict["0th"]["first_ifd_pointer"];
        delete exif_dict["0th"]["first_ifd_pointer"];

        if (34665 in exif_dict["0th"]) {
            pointer = exif_dict["0th"][34665];
            exif_dict["Exif"] = exifReader.get_ifd(pointer, "Exif");
        }
        if (34853 in exif_dict["0th"]) {
            pointer = exif_dict["0th"][34853];
            exif_dict["GPS"] = exifReader.get_ifd(pointer, "GPS");
        }
        if (40965 in exif_dict["Exif"]) {
            pointer = exif_dict["Exif"][40965];
            exif_dict["Interop"] = exifReader.get_ifd(pointer, "Interop");
        }
        if (first_ifd_pointer != "\x00\x00\x00\x00") {
            pointer = unpack(exifReader.endian_mark + "L",
                first_ifd_pointer)[0];
            exif_dict["1st"] = exifReader.get_ifd(pointer, "1st");
            if ((513 in exif_dict["1st"]) && (514 in exif_dict["1st"])) {
                var end = exif_dict["1st"][513] + exif_dict["1st"][514];
                var thumb = exifReader.tiftag.slice(exif_dict["1st"][513], end);
                exif_dict["thumbnail"] = thumb;
            }
        }

        return exif_dict;
    };


    that.dump = function (exif_dict_original) {
        var TIFF_HEADER_LENGTH = 8;

        var exif_dict = copy(exif_dict_original);
        var header = "Exif\x00\x00\x4d\x4d\x00\x2a\x00\x00\x00\x08";
        var exif_is = false;
        var gps_is = false;
        var interop_is = false;
        var first_is = false;

        var zeroth_ifd,
            exif_ifd,
            interop_ifd,
            gps_ifd,
            first_ifd;

        if ("0th" in exif_dict) {
            zeroth_ifd = exif_dict["0th"];
        } else {
            zeroth_ifd = {};
        }

        if ((("Exif" in exif_dict) && (Object.keys(exif_dict["Exif"]).length)) ||
            (("Interop" in exif_dict) && (Object.keys(exif_dict["Interop"]).length))) {
            zeroth_ifd[34665] = 1;
            exif_is = true;
            exif_ifd = exif_dict["Exif"];
            if (("Interop" in exif_dict) && Object.keys(exif_dict["Interop"]).length) {
                exif_ifd[40965] = 1;
                interop_is = true;
                interop_ifd = exif_dict["Interop"];
            } else if (Object.keys(exif_ifd).indexOf(that.ExifIFD.InteroperabilityTag.toString()) > -1) {
                delete exif_ifd[40965];
            }
        } else if (Object.keys(zeroth_ifd).indexOf(that.ImageIFD.ExifTag.toString()) > -1) {
            delete zeroth_ifd[34665];
        }

        if (("GPS" in exif_dict) && (Object.keys(exif_dict["GPS"]).length)) {
            zeroth_ifd[that.ImageIFD.GPSTag] = 1;
            gps_is = true;
            gps_ifd = exif_dict["GPS"];
        } else if (Object.keys(zeroth_ifd).indexOf(that.ImageIFD.GPSTag.toString()) > -1) {
            delete zeroth_ifd[that.ImageIFD.GPSTag];
        }

        if (("1st" in exif_dict) &&
            ("thumbnail" in exif_dict) &&
            (exif_dict["thumbnail"] != null)) {
            first_is = true;
            exif_dict["1st"][513] = 1;
            exif_dict["1st"][514] = 1;
            first_ifd = exif_dict["1st"];
        }

        var zeroth_set = _dict_to_bytes(zeroth_ifd, "0th", 0);
        var zeroth_length = (zeroth_set[0].length + exif_is * 12 + gps_is * 12 + 4 +
            zeroth_set[1].length);

        var exif_set,
            exif_bytes = "",
            exif_length = 0,
            gps_set,
            gps_bytes = "",
            gps_length = 0,
            interop_set,
            interop_bytes = "",
            interop_length = 0,
            first_set,
            first_bytes = "",
            thumbnail;
        if (exif_is) {
            exif_set = _dict_to_bytes(exif_ifd, "Exif", zeroth_length);
            exif_length = exif_set[0].length + interop_is * 12 + exif_set[1].length;
        }
        if (gps_is) {
            gps_set = _dict_to_bytes(gps_ifd, "GPS", zeroth_length + exif_length);
            gps_bytes = gps_set.join("");
            gps_length = gps_bytes.length;
        }
        if (interop_is) {
            var offset = zeroth_length + exif_length + gps_length;
            interop_set = _dict_to_bytes(interop_ifd, "Interop", offset);
            interop_bytes = interop_set.join("");
            interop_length = interop_bytes.length;
        }
        if (first_is) {
            var offset = zeroth_length + exif_length + gps_length + interop_length;
            first_set = _dict_to_bytes(first_ifd, "1st", offset);
            thumbnail = _get_thumbnail(exif_dict["thumbnail"]);
            if (thumbnail.length > 64000) {
                throw new Error("Given thumbnail is too large. max 64kB");
            }
        }

        var exif_pointer = "",
            gps_pointer = "",
            interop_pointer = "",
            first_ifd_pointer = "\x00\x00\x00\x00";
        if (exif_is) {
            var pointer_value = TIFF_HEADER_LENGTH + zeroth_length;
            var pointer_str = pack(">L", [pointer_value]);
            var key = 34665;
            var key_str = pack(">H", [key]);
            var type_str = pack(">H", [TYPES["Long"]]);
            var length_str = pack(">L", [1]);
            exif_pointer = key_str + type_str + length_str + pointer_str;
        }
        if (gps_is) {
            var pointer_value = TIFF_HEADER_LENGTH + zeroth_length + exif_length;
            var pointer_str = pack(">L", [pointer_value]);
            var key = 34853;
            var key_str = pack(">H", [key]);
            var type_str = pack(">H", [TYPES["Long"]]);
            var length_str = pack(">L", [1]);
            gps_pointer = key_str + type_str + length_str + pointer_str;
        }
        if (interop_is) {
            var pointer_value = (TIFF_HEADER_LENGTH +
                zeroth_length + exif_length + gps_length);
            var pointer_str = pack(">L", [pointer_value]);
            var key = 40965;
            var key_str = pack(">H", [key]);
            var type_str = pack(">H", [TYPES["Long"]]);
            var length_str = pack(">L", [1]);
            interop_pointer = key_str + type_str + length_str + pointer_str;
        }
        if (first_is) {
            var pointer_value = (TIFF_HEADER_LENGTH + zeroth_length +
                exif_length + gps_length + interop_length);
            first_ifd_pointer = pack(">L", [pointer_value]);
            var thumbnail_pointer = (pointer_value + first_set[0].length + 24 +
                4 + first_set[1].length);
            var thumbnail_p_bytes = ("\x02\x01\x00\x04\x00\x00\x00\x01" +
                pack(">L", [thumbnail_pointer]));
            var thumbnail_length_bytes = ("\x02\x02\x00\x04\x00\x00\x00\x01" +
                pack(">L", [thumbnail.length]));
            first_bytes = (first_set[0] + thumbnail_p_bytes +
                thumbnail_length_bytes + "\x00\x00\x00\x00" +
                first_set[1] + thumbnail);
        }

        var zeroth_bytes = (zeroth_set[0] + exif_pointer + gps_pointer +
            first_ifd_pointer + zeroth_set[1]);
        if (exif_is) {
            exif_bytes = exif_set[0] + interop_pointer + exif_set[1];
        }

        return (header + zeroth_bytes + exif_bytes + gps_bytes +
            interop_bytes + first_bytes);
    };


    function copy(obj) {
        return JSON.parse(JSON.stringify(obj));
    }


    function _get_thumbnail(jpeg) {
        var segments = splitIntoSegments(jpeg);
        while (("\xff\xe0" <= segments[1].slice(0, 2)) && (segments[1].slice(0, 2) <= "\xff\xef")) {
            segments = [segments[0]].concat(segments.slice(2));
        }
        return segments.join("");
    }


    function _pack_byte(array) {
        return pack(">" + nStr("B", array.length), array);
    }


    function _pack_short(array) {
        return pack(">" + nStr("H", array.length), array);
    }


    function _pack_long(array) {
        return pack(">" + nStr("L", array.length), array);
    }


    function _value_to_bytes(raw_value, value_type, offset) {
        var four_bytes_over = "";
        var value_str = "";
        var length,
            new_value,
            num,
            den;

        if (value_type == "Byte") {
            length = raw_value.length;
            if (length <= 4) {
                value_str = (_pack_byte(raw_value) +
                    nStr("\x00", 4 - length));
            } else {
                value_str = pack(">L", [offset]);
                four_bytes_over = _pack_byte(raw_value);
            }
        } else if (value_type == "Short") {
            length = raw_value.length;
            if (length <= 2) {
                value_str = (_pack_short(raw_value) +
                    nStr("\x00\x00", 2 - length));
            } else {
                value_str = pack(">L", [offset]);
                four_bytes_over = _pack_short(raw_value);
            }
        } else if (value_type == "Long") {
            length = raw_value.length;
            if (length <= 1) {
                value_str = _pack_long(raw_value);
            } else {
                value_str = pack(">L", [offset]);
                four_bytes_over = _pack_long(raw_value);
            }
        } else if (value_type == "Ascii") {
            new_value = raw_value + "\x00";
            length = new_value.length;
            if (length > 4) {
                value_str = pack(">L", [offset]);
                four_bytes_over = new_value;
            } else {
                value_str = new_value + nStr("\x00", 4 - length);
            }
        } else if (value_type == "Rational") {
            if (typeof (raw_value[0]) == "number") {
                length = 1;
                num = raw_value[0];
                den = raw_value[1];
                new_value = pack(">L", [num]) + pack(">L", [den]);
            } else {
                length = raw_value.length;
                new_value = "";
                for (var n = 0; n < length; n++) {
                    num = raw_value[n][0];
                    den = raw_value[n][1];
                    new_value += (pack(">L", [num]) +
                        pack(">L", [den]));
                }
            }
            value_str = pack(">L", [offset]);
            four_bytes_over = new_value;
        } else if (value_type == "SRational") {
            if (typeof (raw_value[0]) == "number") {
                length = 1;
                num = raw_value[0];
                den = raw_value[1];
                new_value = pack(">l", [num]) + pack(">l", [den]);
            } else {
                length = raw_value.length;
                new_value = "";
                for (var n = 0; n < length; n++) {
                    num = raw_value[n][0];
                    den = raw_value[n][1];
                    new_value += (pack(">l", [num]) +
                        pack(">l", [den]));
                }
            }
            value_str = pack(">L", [offset]);
            four_bytes_over = new_value;
        } else if (value_type == "Undefined") {
            length = raw_value.length;
            if (length > 4) {
                value_str = pack(">L", [offset]);
                four_bytes_over = raw_value;
            } else {
                value_str = raw_value + nStr("\x00", 4 - length);
            }
        }

        var length_str = pack(">L", [length]);

        return [length_str, value_str, four_bytes_over];
    }

    function _dict_to_bytes(ifd_dict, ifd, ifd_offset) {
        var TIFF_HEADER_LENGTH = 8;
        var tag_count = Object.keys(ifd_dict).length;
        var entry_header = pack(">H", [tag_count]);
        var entries_length;
        if (["0th", "1st"].indexOf(ifd) > -1) {
            entries_length = 2 + tag_count * 12 + 4;
        } else {
            entries_length = 2 + tag_count * 12;
        }
        var entries = "";
        var values = "";
        var key;

        // Sort keys to ensure TIFF compliance (SAFE FIX: ensures explicit order matches appended pointers)
        var keys = Object.keys(ifd_dict).map(function (x) { return parseInt(x); });
        keys.sort(function (a, b) { return a - b; });

        for (var i = 0; i < keys.length; i++) {
            key = keys[i];

            if ((ifd == "0th") && ([34665, 34853].indexOf(key) > -1)) {
                continue;
            } else if ((ifd == "Exif") && (key == 40965)) {
                continue;
            } else if ((ifd == "1st") && ([513, 514].indexOf(key) > -1)) {
                continue;
            }

            var raw_value = ifd_dict[key];
            var key_str = pack(">H", [key]);
            var value_type = TAGS[ifd][key]["type"];
            var type_str = pack(">H", [TYPES[value_type]]);

            if (typeof (raw_value) == "number") {
                raw_value = [raw_value];
            }
            var offset = TIFF_HEADER_LENGTH + entries_length + ifd_offset + values.length;
            var b = _value_to_bytes(raw_value, value_type, offset);
            var length_str = b[0];
            var value_str = b[1];
            var four_bytes_over = b[2];

            entries += key_str + type_str + length_str + value_str;
            values += four_bytes_over;

            // Pad values to ensure word alignment (even offsets)
            if (values.length % 2 !== 0) {
                values += "\x00";
            }
        }

        return [entry_header + entries, values];
    }



    function ExifReader(data) {
        var segments,
            app1;
        if (data.slice(0, 2) == "\xff\xd8") { // JPEG
            segments = splitIntoSegments(data);
            app1 = getExifSeg(segments);
            if (app1) {
                this.tiftag = app1.slice(10);
            } else {
                this.tiftag = null;
            }
        } else if (["\x49\x49", "\x4d\x4d"].indexOf(data.slice(0, 2)) > -1) { // TIFF
            this.tiftag = data;
        } else if (data.slice(0, 4) == "Exif") { // Exif
            this.tiftag = data.slice(6);
        } else {
            throw new Error("Given file is neither JPEG nor TIFF.");
        }
    }

    ExifReader.prototype = {
        get_ifd: function (pointer, ifd_name) {
            var ifd_dict = {};
            var tag_count = unpack(this.endian_mark + "H",
                this.tiftag.slice(pointer, pointer + 2))[0];
            var offset = pointer + 2;
            var t;
            if (["0th", "1st"].indexOf(ifd_name) > -1) {
                t = "Image";
            } else {
                t = ifd_name;
            }

            for (var x = 0; x < tag_count; x++) {
                pointer = offset + 12 * x;
                var tag = unpack(this.endian_mark + "H",
                    this.tiftag.slice(pointer, pointer + 2))[0];
                var value_type = unpack(this.endian_mark + "H",
                    this.tiftag.slice(pointer + 2, pointer + 4))[0];
                var value_num = unpack(this.endian_mark + "L",
                    this.tiftag.slice(pointer + 4, pointer + 8))[0];
                var value = this.tiftag.slice(pointer + 8, pointer + 12);

                var v_set = [value_type, value_num, value];
                if (tag in TAGS[t]) {
                    ifd_dict[tag] = this.convert_value(v_set);
                }
            }

            if (ifd_name == "0th") {
                pointer = offset + 12 * tag_count;
                ifd_dict["first_ifd_pointer"] = this.tiftag.slice(pointer, pointer + 4);
            }

            return ifd_dict;
        },

        convert_value: function (val) {
            var data = null;
            var t = val[0];
            var length = val[1];
            var value = val[2];
            var pointer;

            if (t == 1) { // BYTE
                if (length > 4) {
                    pointer = unpack(this.endian_mark + "L", value)[0];
                    data = unpack(this.endian_mark + nStr("B", length),
                        this.tiftag.slice(pointer, pointer + length));
                } else {
                    data = unpack(this.endian_mark + nStr("B", length), value.slice(0, length));
                }
            } else if (t == 2) { // ASCII
                if (length > 4) {
                    pointer = unpack(this.endian_mark + "L", value)[0];
                    data = this.tiftag.slice(pointer, pointer + length - 1);
                } else {
                    data = value.slice(0, length - 1);
                }
            } else if (t == 3) { // SHORT
                if (length > 2) {
                    pointer = unpack(this.endian_mark + "L", value)[0];
                    data = unpack(this.endian_mark + nStr("H", length),
                        this.tiftag.slice(pointer, pointer + length * 2));
                } else {
                    data = unpack(this.endian_mark + nStr("H", length),
                        value.slice(0, length * 2));
                }
            } else if (t == 4) { // LONG
                if (length > 1) {
                    pointer = unpack(this.endian_mark + "L", value)[0];
                    data = unpack(this.endian_mark + nStr("L", length),
                        this.tiftag.slice(pointer, pointer + length * 4));
                } else {
                    data = unpack(this.endian_mark + nStr("L", length),
                        value);
                }
            } else if (t == 5) { // RATIONAL
                pointer = unpack(this.endian_mark + "L", value)[0];
                if (length > 1) {
                    data = [];
                    for (var x = 0; x < length; x++) {
                        data.push([unpack(this.endian_mark + "L",
                            this.tiftag.slice(pointer + x * 8, pointer + 4 + x * 8))[0],
                        unpack(this.endian_mark + "L",
                            this.tiftag.slice(pointer + 4 + x * 8, pointer + 8 + x * 8))[0]
                        ]);
                    }
                } else {
                    data = [unpack(this.endian_mark + "L",
                        this.tiftag.slice(pointer, pointer + 4))[0],
                    unpack(this.endian_mark + "L",
                        this.tiftag.slice(pointer + 4, pointer + 8))[0]
                    ];
                }
            } else if (t == 7) { // UNDEFINED BYTES
                if (length > 4) {
                    pointer = unpack(this.endian_mark + "L", value)[0];
                    data = this.tiftag.slice(pointer, pointer + length);
                } else {
                    data = value.slice(0, length);
                }
            } else if (t == 9) { // SLONG
                if (length > 1) {
                    pointer = unpack(this.endian_mark + "L", value)[0];
                    data = unpack(this.endian_mark + nStr("l", length),
                        this.tiftag.slice(pointer, pointer + length * 4));
                } else {
                    data = unpack(this.endian_mark + nStr("l", length),
                        value);
                }
            } else if (t == 10) { // SRATIONAL
                pointer = unpack(this.endian_mark + "L", value)[0];
                if (length > 1) {
                    data = [];
                    for (var x = 0; x < length; x++) {
                        data.push([unpack(this.endian_mark + "l",
                            this.tiftag.slice(pointer + x * 8, pointer + 4 + x * 8))[0],
                        unpack(this.endian_mark + "l",
                            this.tiftag.slice(pointer + 4 + x * 8, pointer + 8 + x * 8))[0]
                        ]);
                    }
                } else {
                    data = [unpack(this.endian_mark + "l",
                        this.tiftag.slice(pointer, pointer + 4))[0],
                    unpack(this.endian_mark + "l",
                        this.tiftag.slice(pointer + 4, pointer + 8))[0]
                    ];
                }
            } else {
                throw new Error("Exif might be wrong. Got incorrect value " +
                    "type to decode. type:" + t);
            }

            if ((data instanceof Array) && (data.length == 1)) {
                return data[0];
            } else {
                return data;
            }
        },
    };


    if (typeof window !== "undefined" && typeof window.btoa === "function") {
        var btoa = window.btoa;
    }
    if (typeof btoa === "undefined") {
        var btoa = function (input) {
            var output = "";
            var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
            var i = 0;
            var keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

            while (i < input.length) {

                chr1 = input.charCodeAt(i++);
                chr2 = input.charCodeAt(i++);
                chr3 = input.charCodeAt(i++);

                enc1 = chr1 >> 2;
                enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
                enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
                enc4 = chr3 & 63;

                if (isNaN(chr2)) {
                    enc3 = enc4 = 64;
                } else if (isNaN(chr3)) {
                    enc4 = 64;
                }

                output = output +
                    keyStr.charAt(enc1) + keyStr.charAt(enc2) +
                    keyStr.charAt(enc3) + keyStr.charAt(enc4);

            }

            return output;
        };
    }


    if (typeof window !== "undefined" && typeof window.atob === "function") {
        var atob = window.atob;
    }
    if (typeof atob === "undefined") {
        var atob = function (input) {
            var output = "";
            var chr1, chr2, chr3;
            var enc1, enc2, enc3, enc4;
            var i = 0;
            var keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

            input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");

            while (i < input.length) {

                enc1 = keyStr.indexOf(input.charAt(i++));
                enc2 = keyStr.indexOf(input.charAt(i++));
                enc3 = keyStr.indexOf(input.charAt(i++));
                enc4 = keyStr.indexOf(input.charAt(i++));

                chr1 = (enc1 << 2) | (enc2 >> 4);
                chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
                chr3 = ((enc3 & 3) << 6) | enc4;

                output = output + String.fromCharCode(chr1);

                if (enc3 != 64) {
                    output = output + String.fromCharCode(chr2);
                }
                if (enc4 != 64) {
                    output = output + String.fromCharCode(chr3);
                }

            }

            return output;
        };
    }


    function getImageSize(imageArray) {
        var segments = slice2Segments(imageArray);
        var seg,
            width,
            height,
            SOF = [192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207];

        for (var x = 0; x < segments.length; x++) {
            seg = segments[x];
            if (SOF.indexOf(seg[1]) >= 0) {
                height = seg[5] * 256 + seg[6];
                width = seg[7] * 256 + seg[8];
                break;
            }
        }
        return [width, height];
    }


    function pack(mark, array) {
        if (!(array instanceof Array)) {
            throw new Error("'pack' error. Got invalid type argument.");
        }
        if ((mark.length - 1) != array.length) {
            throw new Error("'pack' error. " + (mark.length - 1) + " marks, " + array.length + " elements.");
        }

        var littleEndian;
        if (mark[0] == "<") {
            littleEndian = true;
        } else if (mark[0] == ">") {
            littleEndian = false;
        } else {
            throw new Error("");
        }
        var packed = "";
        var p = 1;
        var val = null;
        var c = null;
        var valStr = null;

        while (c = mark[p]) {
            if (c.toLowerCase() == "b") {
                val = array[p - 1];
                if ((c == "b") && (val < 0)) {
                    val += 0x100;
                }
                if ((val > 0xff) || (val < 0)) {
                    throw new Error("'pack' error.");
                } else {
                    valStr = String.fromCharCode(val);
                }
            } else if (c == "H") {
                val = array[p - 1];
                if ((val > 0xffff) || (val < 0)) {
                    throw new Error("'pack' error.");
                } else {
                    valStr = String.fromCharCode(Math.floor((val % 0x10000) / 0x100)) +
                        String.fromCharCode(val % 0x100);
                    if (littleEndian) {
                        valStr = valStr.split("").reverse().join("");
                    }
                }
            } else if (c.toLowerCase() == "l") {
                val = array[p - 1];
                if ((c == "l") && (val < 0)) {
                    val += 0x100000000;
                }
                if ((val > 0xffffffff) || (val < 0)) {
                    throw new Error("'pack' error.");
                } else {
                    valStr = String.fromCharCode(Math.floor(val / 0x1000000)) +
                        String.fromCharCode(Math.floor((val % 0x1000000) / 0x10000)) +
                        String.fromCharCode(Math.floor((val % 0x10000) / 0x100)) +
                        String.fromCharCode(val % 0x100);
                    if (littleEndian) {
                        valStr = valStr.split("").reverse().join("");
                    }
                }
            } else {
                throw new Error("'pack' error.");
            }

            packed += valStr;
            p += 1;
        }

        return packed;
    }

    function unpack(mark, str) {
        if (typeof (str) != "string") {
            throw new Error("'unpack' error. Got invalid type argument.");
        }
        var l = 0;
        for (var markPointer = 1; markPointer < mark.length; markPointer++) {
            if (mark[markPointer].toLowerCase() == "b") {
                l += 1;
            } else if (mark[markPointer].toLowerCase() == "h") {
                l += 2;
            } else if (mark[markPointer].toLowerCase() == "l") {
                l += 4;
            } else {
                throw new Error("'unpack' error. Got invalid mark.");
            }
        }

        if (l != str.length) {
            throw new Error("'unpack' error. Mismatch between symbol and string length. " + l + ":" + str.length);
        }

        var littleEndian;
        if (mark[0] == "<") {
            littleEndian = true;
        } else if (mark[0] == ">") {
            littleEndian = false;
        } else {
            throw new Error("'unpack' error.");
        }
        var unpacked = [];
        var strPointer = 0;
        var p = 1;
        var val = null;
        var c = null;
        var length = null;
        var sliced = "";

        while (c = mark[p]) {
            if (c.toLowerCase() == "b") {
                length = 1;
                sliced = str.slice(strPointer, strPointer + length);
                val = sliced.charCodeAt(0);
                if ((c == "b") && (val >= 0x80)) {
                    val -= 0x100;
                }
            } else if (c == "H") {
                length = 2;
                sliced = str.slice(strPointer, strPointer + length);
                if (littleEndian) {
                    sliced = sliced.split("").reverse().join("");
                }
                val = sliced.charCodeAt(0) * 0x100 +
                    sliced.charCodeAt(1);
            } else if (c.toLowerCase() == "l") {
                length = 4;
                sliced = str.slice(strPointer, strPointer + length);
                if (littleEndian) {
                    sliced = sliced.split("").reverse().join("");
                }
                val = sliced.charCodeAt(0) * 0x1000000 +
                    sliced.charCodeAt(1) * 0x10000 +
                    sliced.charCodeAt(2) * 0x100 +
                    sliced.charCodeAt(3);
                if ((c == "l") && (val >= 0x80000000)) {
                    val -= 0x100000000;
                }
            } else {
                throw new Error("'unpack' error. " + c);
            }

            unpacked.push(val);
            strPointer += length;
            p += 1;
        }

        return unpacked;
    }

    function nStr(ch, num) {
        var str = "";
        for (var i = 0; i < num; i++) {
            str += ch;
        }
        return str;
    }

    function splitIntoSegments(data) {
        if (data.slice(0, 2) != "\xff\xd8") {
            throw new Error("Given data isn't JPEG.");
        }

        var head = 2;
        var segments = ["\xff\xd8"];
        while (true) {
            if (data.slice(head, head + 2) == "\xff\xda") {
                segments.push(data.slice(head));
                break;
            } else {
                var length = unpack(">H", data.slice(head + 2, head + 4))[0];
                var endPoint = head + length + 2;
                segments.push(data.slice(head, endPoint));
                head = endPoint;
            }

            if (head >= data.length) {
                throw new Error("Wrong JPEG data.");
            }
        }
        return segments;
    }


    function getExifSeg(segments) {
        var seg;
        for (var i = 0; i < segments.length; i++) {
            seg = segments[i];
            if (seg.slice(0, 2) == "\xff\xe1" &&
                seg.slice(4, 10) == "Exif\x00\x00") {
                return seg;
            }
        }
        return null;
    }


    function mergeSegments(segments, exif) {
        var hasExifSegment = false;
        var additionalAPP1ExifSegments = [];

        segments.forEach(function (segment, i) {
            // Replace first occurence of APP1:Exif segment
            if (segment.slice(0, 2) == "\xff\xe1" &&
                segment.slice(4, 10) == "Exif\x00\x00"
            ) {
                if (!hasExifSegment) {
                    segments[i] = exif;
                    hasExifSegment = true;
                } else {
                    additionalAPP1ExifSegments.unshift(i);
                }
            }
        });

        // Remove additional occurences of APP1:Exif segment
        additionalAPP1ExifSegments.forEach(function (segmentIndex) {
            segments.splice(segmentIndex, 1);
        });

        if (!hasExifSegment && exif) {
            segments = [segments[0], exif].concat(segments.slice(1));
        }

        return segments.join("");
    }


    function toHex(str) {
        var hexStr = "";
        for (var i = 0; i < str.length; i++) {
            var h = str.charCodeAt(i);
            var hex = ((h < 10) ? "0" : "") + h.toString(16);
            hexStr += hex + " ";
        }
        return hexStr;
    }


    var TYPES = {
        "Byte": 1,
        "Ascii": 2,
        "Short": 3,
        "Long": 4,
        "Rational": 5,
        "Undefined": 7,
        "SLong": 9,
        "SRational": 10
    };


    var TAGS = {
        'Image': {
            11: {
                'name': 'ProcessingSoftware',
                'type': 'Ascii'
            },
            254: {
                'name': 'NewSubfileType',
                'type': 'Long'
            },
            255: {
                'name': 'SubfileType',
                'type': 'Short'
            },
            256: {
                'name': 'ImageWidth',
                'type': 'Long'
            },
            257: {
                'name': 'ImageLength',
                'type': 'Long'
            },
            258: {
                'name': 'BitsPerSample',
                'type': 'Short'
            },
            259: {
                'name': 'Compression',
                'type': 'Short'
            },
            262: {
                'name': 'PhotometricInterpretation',
                'type': 'Short'
            },
            263: {
                'name': 'Threshholding',
                'type': 'Short'
            },
            264: {
                'name': 'CellWidth',
                'type': 'Short'
            },
            265: {
                'name': 'CellLength',
                'type': 'Short'
            },
            266: {
                'name': 'FillOrder',
                'type': 'Short'
            },
            269: {
                'name': 'DocumentName',
                'type': 'Ascii'
            },
            270: {
                'name': 'ImageDescription',
                'type': 'Ascii'
            },
            271: {
                'name': 'Make',
                'type': 'Ascii'
            },
            272: {
                'name': 'Model',
                'type': 'Ascii'
            },
            273: {
                'name': 'StripOffsets',
                'type': 'Long'
            },
            274: {
                'name': 'Orientation',
                'type': 'Short'
            },
            277: {
                'name': 'SamplesPerPixel',
                'type': 'Short'
            },
            278: {
                'name': 'RowsPerStrip',
                'type': 'Long'
            },
            279: {
                'name': 'StripByteCounts',
                'type': 'Long'
            },
            282: {
                'name': 'XResolution',
                'type': 'Rational'
            },
            283: {
                'name': 'YResolution',
                'type': 'Rational'
            },
            284: {
                'name': 'PlanarConfiguration',
                'type': 'Short'
            },
            290: {
                'name': 'GrayResponseUnit',
                'type': 'Short'
            },
            291: {
                'name': 'GrayResponseCurve',
                'type': 'Short'
            },
            292: {
                'name': 'T4Options',
                'type': 'Long'
            },
            293: {
                'name': 'T6Options',
                'type': 'Long'
            },
            296: {
                'name': 'ResolutionUnit',
                'type': 'Short'
            },
            301: {
                'name': 'TransferFunction',
                'type': 'Short'
            },
            305: {
                'name': 'Software',
                'type': 'Ascii'
            },
            306: {
                'name': 'DateTime',
                'type': 'Ascii'
            },
            315: {
                'name': 'Artist',
                'type': 'Ascii'
            },
            316: {
                'name': 'HostComputer',
                'type': 'Ascii'
            },
            317: {
                'name': 'Predictor',
                'type': 'Short'
            },
            318: {
                'name': 'WhitePoint',
                'type': 'Rational'
            },
            319: {
                'name': 'PrimaryChromaticities',
                'type': 'Rational'
            },
            320: {
                'name': 'ColorMap',
                'type': 'Short'
            },
            321: {
                'name': 'HalftoneHints',
                'type': 'Short'
            },
            322: {
                'name': 'TileWidth',
                'type': 'Short'
            },
            323: {
                'name': 'TileLength',
                'type': 'Short'
            },
            324: {
                'name': 'TileOffsets',
                'type': 'Short'
            },
            325: {
                'name': 'TileByteCounts',
                'type': 'Short'
            },
            330: {
                'name': 'SubIFDs',
                'type': 'Long'
            },
            332: {
                'name': 'InkSet',
                'type': 'Short'
            },
            333: {
                'name': 'InkNames',
                'type': 'Ascii'
            },
            334: {
                'name': 'NumberOfInks',
                'type': 'Short'
            },
            336: {
                'name': 'DotRange',
                'type': 'Byte'
            },
            337: {
                'name': 'TargetPrinter',
                'type': 'Ascii'
            },
            338: {
                'name': 'ExtraSamples',
                'type': 'Short'
            },
            339: {
                'name': 'SampleFormat',
                'type': 'Short'
            },
            340: {
                'name': 'SMinSampleValue',
                'type': 'Short'
            },
            341: {
                'name': 'SMaxSampleValue',
                'type': 'Short'
            },
            342: {
                'name': 'TransferRange',
                'type': 'Short'
            },
            343: {
                'name': 'ClipPath',
                'type': 'Byte'
            },
            344: {
                'name': 'XClipPathUnits',
                'type': 'Long'
            },
            345: {
                'name': 'YClipPathUnits',
                'type': 'Long'
            },
            346: {
                'name': 'Indexed',
                'type': 'Short'
            },
            347: {
                'name': 'JPEGTables',
                'type': 'Undefined'
            },
            351: {
                'name': 'OPIProxy',
                'type': 'Short'
            },
            512: {
                'name': 'JPEGProc',
                'type': 'Long'
            },
            513: {
                'name': 'JPEGInterchangeFormat',
                'type': 'Long'
            },
            514: {
                'name': 'JPEGInterchangeFormatLength',
                'type': 'Long'
            },
            515: {
                'name': 'JPEGRestartInterval',
                'type': 'Short'
            },
            517: {
                'name': 'JPEGLosslessPredictors',
                'type': 'Short'
            },
            518: {
                'name': 'JPEGPointTransforms',
                'type': 'Short'
            },
            519: {
                'name': 'JPEGQTables',
                'type': 'Long'
            },
            520: {
                'name': 'JPEGDCTables',
                'type': 'Long'
            },
            521: {
                'name': 'JPEGACTables',
                'type': 'Long'
            },
            529: {
                'name': 'YCbCrCoefficients',
                'type': 'Rational'
            },
            530: {
                'name': 'YCbCrSubSampling',
                'type': 'Short'
            },
            531: {
                'name': 'YCbCrPositioning',
                'type': 'Short'
            },
            532: {
                'name': 'ReferenceBlackWhite',
                'type': 'Rational'
            },
            700: {
                'name': 'XMLPacket',
                'type': 'Byte'
            },
            18246: {
                'name': 'Rating',
                'type': 'Short'
            },
            18249: {
                'name': 'RatingPercent',
                'type': 'Short'
            },
            32781: {
                'name': 'ImageID',
                'type': 'Ascii'
            },
            33421: {
                'name': 'CFARepeatPatternDim',
                'type': 'Short'
            },
            33422: {
                'name': 'CFAPattern',
                'type': 'Byte'
            },
            33423: {
                'name': 'BatteryLevel',
                'type': 'Rational'
            },
            33432: {
                'name': 'Copyright',
                'type': 'Ascii'
            },
            33434: {
                'name': 'ExposureTime',
                'type': 'Rational'
            },
            34377: {
                'name': 'ImageResources',
                'type': 'Byte'
            },
            34665: {
                'name': 'ExifTag',
                'type': 'Long'
            },
            34675: {
                'name': 'InterColorProfile',
                'type': 'Undefined'
            },
            34853: {
                'name': 'GPSTag',
                'type': 'Long'
            },
            34857: {
                'name': 'Interlace',
                'type': 'Short'
            },
            34858: {
                'name': 'TimeZoneOffset',
                'type': 'Long'
            },
            34859: {
                'name': 'SelfTimerMode',
                'type': 'Short'
            },
            37387: {
                'name': 'FlashEnergy',
                'type': 'Rational'
            },
            37388: {
                'name': 'SpatialFrequencyResponse',
                'type': 'Undefined'
            },
            37389: {
                'name': 'Noise',
                'type': 'Undefined'
            },
            37390: {
                'name': 'FocalPlaneXResolution',
                'type': 'Rational'
            },
            37391: {
                'name': 'FocalPlaneYResolution',
                'type': 'Rational'
            },
            37392: {
                'name': 'FocalPlaneResolutionUnit',
                'type': 'Short'
            },
            37393: {
                'name': 'ImageNumber',
                'type': 'Long'
            },
            37394: {
                'name': 'SecurityClassification',
                'type': 'Ascii'
            },
            37395: {
                'name': 'ImageHistory',
                'type': 'Ascii'
            },
            37397: {
                'name': 'ExposureIndex',
                'type': 'Rational'
            },
            37398: {
                'name': 'TIFFEPStandardID',
                'type': 'Byte'
            },
            37399: {
                'name': 'SensingMethod',
                'type': 'Short'
            },
            40091: {
                'name': 'XPTitle',
                'type': 'Byte'
            },
            40092: {
                'name': 'XPComment',
                'type': 'Byte'
            },
            40093: {
                'name': 'XPAuthor',
                'type': 'Byte'
            },
            40094: {
                'name': 'XPKeywords',
                'type': 'Byte'
            },
            40095: {
                'name': 'XPSubject',
                'type': 'Byte'
            },
            50341: {
                'name': 'PrintImageMatching',
                'type': 'Undefined'
            },
            50706: {
                'name': 'DNGVersion',
                'type': 'Byte'
            },
            50707: {
                'name': 'DNGBackwardVersion',
                'type': 'Byte'
            },
            50708: {
                'name': 'UniqueCameraModel',
                'type': 'Ascii'
            },
            50709: {
                'name': 'LocalizedCameraModel',
                'type': 'Byte'
            },
            50710: {
                'name': 'CFAPlaneColor',
                'type': 'Byte'
            },
            50711: {
                'name': 'CFALayout',
                'type': 'Short'
            },
            50712: {
                'name': 'LinearizationTable',
                'type': 'Short'
            },
            50713: {
                'name': 'BlackLevelRepeatDim',
                'type': 'Short'
            },
            50714: {
                'name': 'BlackLevel',
                'type': 'Rational'
            },
            50715: {
                'name': 'BlackLevelDeltaH',
                'type': 'SRational'
            },
            50716: {
                'name': 'BlackLevelDeltaV',
                'type': 'SRational'
            },
            50717: {
                'name': 'WhiteLevel',
                'type': 'Short'
            },
            50718: {
                'name': 'DefaultScale',
                'type': 'Rational'
            },
            50719: {
                'name': 'DefaultCropOrigin',
                'type': 'Short'
            },
            50720: {
                'name': 'DefaultCropSize',
                'type': 'Short'
            },
            50721: {
                'name': 'ColorMatrix1',
                'type': 'SRational'
            },
            50722: {
                'name': 'ColorMatrix2',
                'type': 'SRational'
            },
            50723: {
                'name': 'CameraCalibration1',
                'type': 'SRational'
            },
            50724: {
                'name': 'CameraCalibration2',
                'type': 'SRational'
            },
            50725: {
                'name': 'ReductionMatrix1',
                'type': 'SRational'
            },
            50726: {
                'name': 'ReductionMatrix2',
                'type': 'SRational'
            },
            50727: {
                'name': 'AnalogBalance',
                'type': 'Rational'
            },
            50728: {
                'name': 'AsShotNeutral',
                'type': 'Short'
            },
            50729: {
                'name': 'AsShotWhiteXY',
                'type': 'Rational'
            },
            50730: {
                'name': 'BaselineExposure',
                'type': 'SRational'
            },
            50731: {
                'name': 'BaselineNoise',
                'type': 'Rational'
            },
            50732: {
                'name': 'BaselineSharpness',
                'type': 'Rational'
            },
            50733: {
                'name': 'BayerGreenSplit',
                'type': 'Long'
            },
            50734: {
                'name': 'LinearResponseLimit',
                'type': 'Rational'
            },
            50735: {
                'name': 'CameraSerialNumber',
                'type': 'Ascii'
            },
            50736: {
                'name': 'LensInfo',
                'type': 'Rational'
            },
            50737: {
                'name': 'ChromaBlurRadius',
                'type': 'Rational'
            },
            50738: {
                'name': 'AntiAliasStrength',
                'type': 'Rational'
            },
            50739: {
                'name': 'ShadowScale',
                'type': 'SRational'
            },
            50740: {
                'name': 'DNGPrivateData',
                'type': 'Byte'
            },
            50741: {
                'name': 'MakerNoteSafety',
                'type': 'Short'
            },
            50778: {
                'name': 'CalibrationIlluminant1',
                'type': 'Short'
            },
            50779: {
                'name': 'CalibrationIlluminant2',
                'type': 'Short'
            },
            50780: {
                'name': 'BestQualityScale',
                'type': 'Rational'
            },
            50781: {
                'name': 'RawDataUniqueID',
                'type': 'Byte'
            },
            50827: {
                'name': 'OriginalRawFileName',
                'type': 'Byte'
            },
            50828: {
                'name': 'OriginalRawFileData',
                'type': 'Undefined'
            },
            50829: {
                'name': 'ActiveArea',
                'type': 'Short'
            },
            50830: {
                'name': 'MaskedAreas',
                'type': 'Short'
            },
            50831: {
                'name': 'AsShotICCProfile',
                'type': 'Undefined'
            },
            50832: {
                'name': 'AsShotPreProfileMatrix',
                'type': 'SRational'
            },
            50833: {
                'name': 'CurrentICCProfile',
                'type': 'Undefined'
            },
            50834: {
                'name': 'CurrentPreProfileMatrix',
                'type': 'SRational'
            },
            50879: {
                'name': 'ColorimetricReference',
                'type': 'Short'
            },
            50931: {
                'name': 'CameraCalibrationSignature',
                'type': 'Byte'
            },
            50932: {
                'name': 'ProfileCalibrationSignature',
                'type': 'Byte'
            },
            50934: {
                'name': 'AsShotProfileName',
                'type': 'Byte'
            },
            50935: {
                'name': 'NoiseReductionApplied',
                'type': 'Rational'
            },
            50936: {
                'name': 'ProfileName',
                'type': 'Byte'
            },
            50937: {
                'name': 'ProfileHueSatMapDims',
                'type': 'Long'
            },
            50938: {
                'name': 'ProfileHueSatMapData1',
                'type': 'Float'
            },
            50939: {
                'name': 'ProfileHueSatMapData2',
                'type': 'Float'
            },
            50940: {
                'name': 'ProfileToneCurve',
                'type': 'Float'
            },
            50941: {
                'name': 'ProfileEmbedPolicy',
                'type': 'Long'
            },
            50942: {
                'name': 'ProfileCopyright',
                'type': 'Byte'
            },
            50964: {
                'name': 'ForwardMatrix1',
                'type': 'SRational'
            },
            50965: {
                'name': 'ForwardMatrix2',
                'type': 'SRational'
            },
            50966: {
                'name': 'PreviewApplicationName',
                'type': 'Byte'
            },
            50967: {
                'name': 'PreviewApplicationVersion',
                'type': 'Byte'
            },
            50968: {
                'name': 'PreviewSettingsName',
                'type': 'Byte'
            },
            50969: {
                'name': 'PreviewSettingsDigest',
                'type': 'Byte'
            },
            50970: {
                'name': 'PreviewColorSpace',
                'type': 'Long'
            },
            50971: {
                'name': 'PreviewDateTime',
                'type': 'Ascii'
            },
            50972: {
                'name': 'RawImageDigest',
                'type': 'Undefined'
            },
            50973: {
                'name': 'OriginalRawFileDigest',
                'type': 'Undefined'
            },
            50974: {
                'name': 'SubTileBlockSize',
                'type': 'Long'
            },
            50975: {
                'name': 'RowInterleaveFactor',
                'type': 'Long'
            },
            50981: {
                'name': 'ProfileLookTableDims',
                'type': 'Long'
            },
            50982: {
                'name': 'ProfileLookTableData',
                'type': 'Float'
            },
            51008: {
                'name': 'OpcodeList1',
                'type': 'Undefined'
            },
            51009: {
                'name': 'OpcodeList2',
                'type': 'Undefined'
            },
            51022: {
                'name': 'OpcodeList3',
                'type': 'Undefined'
            }
        },
        'Exif': {
            33434: {
                'name': 'ExposureTime',
                'type': 'Rational'
            },
            33437: {
                'name': 'FNumber',
                'type': 'Rational'
            },
            34850: {
                'name': 'ExposureProgram',
                'type': 'Short'
            },
            34852: {
                'name': 'SpectralSensitivity',
                'type': 'Ascii'
            },
            34855: {
                'name': 'ISOSpeedRatings',
                'type': 'Short'
            },
            34856: {
                'name': 'OECF',
                'type': 'Undefined'
            },
            34864: {
                'name': 'SensitivityType',
                'type': 'Short'
            },
            34865: {
                'name': 'StandardOutputSensitivity',
                'type': 'Long'
            },
            34866: {
                'name': 'RecommendedExposureIndex',
                'type': 'Long'
            },
            34867: {
                'name': 'ISOSpeed',
                'type': 'Long'
            },
            34868: {
                'name': 'ISOSpeedLatitudeyyy',
                'type': 'Long'
            },
            34869: {
                'name': 'ISOSpeedLatitudezzz',
                'type': 'Long'
            },
            36864: {
                'name': 'ExifVersion',
                'type': 'Undefined'
            },
            36867: {
                'name': 'DateTimeOriginal',
                'type': 'Ascii'
            },
            36868: {
                'name': 'DateTimeDigitized',
                'type': 'Ascii'
            },
            37121: {
                'name': 'ComponentsConfiguration',
                'type': 'Undefined'
            },
            37122: {
                'name': 'CompressedBitsPerPixel',
                'type': 'Rational'
            },
            37377: {
                'name': 'ShutterSpeedValue',
                'type': 'SRational'
            },
            37378: {
                'name': 'ApertureValue',
                'type': 'Rational'
            },
            37379: {
                'name': 'BrightnessValue',
                'type': 'SRational'
            },
            37380: {
                'name': 'ExposureBiasValue',
                'type': 'SRational'
            },
            37381: {
                'name': 'MaxApertureValue',
                'type': 'Rational'
            },
            37382: {
                'name': 'SubjectDistance',
                'type': 'Rational'
            },
            37383: {
                'name': 'MeteringMode',
                'type': 'Short'
            },
            37384: {
                'name': 'LightSource',
                'type': 'Short'
            },
            37385: {
                'name': 'Flash',
                'type': 'Short'
            },
            37386: {
                'name': 'FocalLength',
                'type': 'Rational'
            },
            37396: {
                'name': 'SubjectArea',
                'type': 'Short'
            },
            37500: {
                'name': 'MakerNote',
                'type': 'Undefined'
            },
            37510: {
                'name': 'UserComment',
                'type': 'Undefined' // Changed from Ascii to Undefined
            },
            37520: {
                'name': 'SubSecTime',
                'type': 'Ascii'
            },
            37521: {
                'name': 'SubSecTimeOriginal',
                'type': 'Ascii'
            },
            37522: {
                'name': 'SubSecTimeDigitized',
                'type': 'Ascii'
            },
            40960: {
                'name': 'FlashpixVersion',
                'type': 'Undefined'
            },
            40961: {
                'name': 'ColorSpace',
                'type': 'Short'
            },
            40962: {
                'name': 'PixelXDimension',
                'type': 'Long'
            },
            40963: {
                'name': 'PixelYDimension',
                'type': 'Long'
            },
            40964: {
                'name': 'RelatedSoundFile',
                'type': 'Ascii'
            },
            40965: {
                'name': 'InteroperabilityTag',
                'type': 'Long'
            },
            41483: {
                'name': 'FlashEnergy',
                'type': 'Rational'
            },
            41484: {
                'name': 'SpatialFrequencyResponse',
                'type': 'Undefined'
            },
            41486: {
                'name': 'FocalPlaneXResolution',
                'type': 'Rational'
            },
            41487: {
                'name': 'FocalPlaneYResolution',
                'type': 'Rational'
            },
            41488: {
                'name': 'FocalPlaneResolutionUnit',
                'type': 'Short'
            },
            41492: {
                'name': 'SubjectLocation',
                'type': 'Short'
            },
            41493: {
                'name': 'ExposureIndex',
                'type': 'Rational'
            },
            41495: {
                'name': 'SensingMethod',
                'type': 'Short'
            },
            41728: {
                'name': 'FileSource',
                'type': 'Undefined'
            },
            41729: {
                'name': 'SceneType',
                'type': 'Undefined'
            },
            41730: {
                'name': 'CFAPattern',
                'type': 'Undefined'
            },
            41985: {
                'name': 'CustomRendered',
                'type': 'Short'
            },
            41986: {
                'name': 'ExposureMode',
                'type': 'Short'
            },
            41987: {
                'name': 'WhiteBalance',
                'type': 'Short'
            },
            41988: {
                'name': 'DigitalZoomRatio',
                'type': 'Rational'
            },
            41989: {
                'name': 'FocalLengthIn35mmFilm',
                'type': 'Short'
            },
            41990: {
                'name': 'SceneCaptureType',
                'type': 'Short'
            },
            41991: {
                'name': 'GainControl',
                'type': 'Short'
            },
            41992: {
                'name': 'Contrast',
                'type': 'Short'
            },
            41993: {
                'name': 'Saturation',
                'type': 'Short'
            },
            41994: {
                'name': 'Sharpness',
                'type': 'Short'
            },
            41995: {
                'name': 'DeviceSettingDescription',
                'type': 'Undefined'
            },
            41996: {
                'name': 'SubjectDistanceRange',
                'type': 'Short'
            },
            42016: {
                'name': 'ImageUniqueID',
                'type': 'Ascii'
            },
            42032: {
                'name': 'CameraOwnerName',
                'type': 'Ascii'
            },
            42033: {
                'name': 'BodySerialNumber',
                'type': 'Ascii'
            },
            42034: {
                'name': 'LensSpecification',
                'type': 'Rational'
            },
            42035: {
                'name': 'LensMake',
                'type': 'Ascii'
            },
            42036: {
                'name': 'LensModel',
                'type': 'Ascii'
            },
            42037: {
                'name': 'LensSerialNumber',
                'type': 'Ascii'
            },
            42240: {
                'name': 'Gamma',
                'type': 'Rational'
            }
        },
        'GPS': {
            0: {
                'name': 'GPSVersionID',
                'type': 'Byte'
            },
            1: {
                'name': 'GPSLatitudeRef',
                'type': 'Ascii'
            },
            2: {
                'name': 'GPSLatitude',
                'type': 'Rational'
            },
            3: {
                'name': 'GPSLongitudeRef',
                'type': 'Ascii'
            },
            4: {
                'name': 'GPSLongitude',
                'type': 'Rational'
            },
            5: {
                'name': 'GPSAltitudeRef',
                'type': 'Byte'
            },
            6: {
                'name': 'GPSAltitude',
                'type': 'Rational'
            },
            7: {
                'name': 'GPSTimeStamp',
                'type': 'Rational'
            },
            8: {
                'name': 'GPSSatellites',
                'type': 'Ascii'
            },
            9: {
                'name': 'GPSStatus',
                'type': 'Ascii'
            },
            10: {
                'name': 'GPSMeasureMode',
                'type': 'Ascii'
            },
            11: {
                'name': 'GPSDOP',
                'type': 'Rational'
            },
            12: {
                'name': 'GPSSpeedRef',
                'type': 'Ascii'
            },
            13: {
                'name': 'GPSSpeed',
                'type': 'Rational'
            },
            14: {
                'name': 'GPSTrackRef',
                'type': 'Ascii'
            },
            15: {
                'name': 'GPSTrack',
                'type': 'Rational'
            },
            16: {
                'name': 'GPSImgDirectionRef',
                'type': 'Ascii'
            },
            17: {
                'name': 'GPSImgDirection',
                'type': 'Rational'
            },
            18: {
                'name': 'GPSMapDatum',
                'type': 'Ascii'
            },
            19: {
                'name': 'GPSDestLatitudeRef',
                'type': 'Ascii'
            },
            20: {
                'name': 'GPSDestLatitude',
                'type': 'Rational'
            },
            21: {
                'name': 'GPSDestLongitudeRef',
                'type': 'Ascii'
            },
            22: {
                'name': 'GPSDestLongitude',
                'type': 'Rational'
            },
            23: {
                'name': 'GPSDestBearingRef',
                'type': 'Ascii'
            },
            24: {
                'name': 'GPSDestBearing',
                'type': 'Rational'
            },
            25: {
                'name': 'GPSDestDistanceRef',
                'type': 'Ascii'
            },
            26: {
                'name': 'GPSDestDistance',
                'type': 'Rational'
            },
            27: {
                'name': 'GPSProcessingMethod',
                'type': 'Undefined'
            },
            28: {
                'name': 'GPSAreaInformation',
                'type': 'Undefined'
            },
            29: {
                'name': 'GPSDateStamp',
                'type': 'Ascii'
            },
            30: {
                'name': 'GPSDifferential',
                'type': 'Short'
            },
            31: {
                'name': 'GPSHPositioningError',
                'type': 'Rational'
            }
        },
        'Interop': {
            1: {
                'name': 'InteroperabilityIndex',
                'type': 'Ascii'
            }
        },
    };
    TAGS["0th"] = TAGS["Image"];
    TAGS["1st"] = TAGS["Image"];
    that.TAGS = TAGS;


    that.ImageIFD = {
        ProcessingSoftware: 11,
        NewSubfileType: 254,
        SubfileType: 255,
        ImageWidth: 256,
        ImageLength: 257,
        BitsPerSample: 258,
        Compression: 259,
        PhotometricInterpretation: 262,
        Threshholding: 263,
        CellWidth: 264,
        CellLength: 265,
        FillOrder: 266,
        DocumentName: 269,
        ImageDescription: 270,
        Make: 271,
        Model: 272,
        StripOffsets: 273,
        Orientation: 274,
        SamplesPerPixel: 277,
        RowsPerStrip: 278,
        StripByteCounts: 279,
        XResolution: 282,
        YResolution: 283,
        PlanarConfiguration: 284,
        GrayResponseUnit: 290,
        GrayResponseCurve: 291,
        T4Options: 292,
        T6Options: 293,
        ResolutionUnit: 296,
        TransferFunction: 301,
        Software: 305,
        DateTime: 306,
        Artist: 315,
        HostComputer: 316,
        Predictor: 317,
        WhitePoint: 318,
        PrimaryChromaticities: 319,
        ColorMap: 320,
        HalftoneHints: 321,
        TileWidth: 322,
        TileLength: 323,
        TileOffsets: 324,
        TileByteCounts: 325,
        SubIFDs: 330,
        InkSet: 332,
        InkNames: 333,
        NumberOfInks: 334,
        DotRange: 336,
        TargetPrinter: 337,
        ExtraSamples: 338,
        SampleFormat: 339,
        SMinSampleValue: 340,
        SMaxSampleValue: 341,
        TransferRange: 342,
        ClipPath: 343,
        XClipPathUnits: 344,
        YClipPathUnits: 345,
        Indexed: 346,
        JPEGTables: 347,
        OPIProxy: 351,
        JPEGProc: 512,
        JPEGInterchangeFormat: 513,
        JPEGInterchangeFormatLength: 514,
        JPEGRestartInterval: 515,
        JPEGLosslessPredictors: 517,
        JPEGPointTransforms: 518,
        JPEGQTables: 519,
        JPEGDCTables: 520,
        JPEGACTables: 521,
        YCbCrCoefficients: 529,
        YCbCrSubSampling: 530,
        YCbCrPositioning: 531,
        ReferenceBlackWhite: 532,
        XMLPacket: 700,
        Rating: 18246,
        RatingPercent: 18249,
        ImageID: 32781,
        CFARepeatPatternDim: 33421,
        CFAPattern: 33422,
        BatteryLevel: 33423,
        Copyright: 33432,
        ExposureTime: 33434,
        ImageResources: 34377,
        ExifTag: 34665,
        InterColorProfile: 34675,
        GPSTag: 34853,
        Interlace: 34857,
        TimeZoneOffset: 34858,
        SelfTimerMode: 34859,
        FlashEnergy: 37387,
        SpatialFrequencyResponse: 37388,
        Noise: 37389,
        FocalPlaneXResolution: 37390,
        FocalPlaneYResolution: 37391,
        FocalPlaneResolutionUnit: 37392,
        ImageNumber: 37393,
        SecurityClassification: 37394,
        ImageHistory: 37395,
        ExposureIndex: 37397,
        TIFFEPStandardID: 37398,
        SensingMethod: 37399,
        XPTitle: 40091,
        XPComment: 40092,
        XPAuthor: 40093,
        XPKeywords: 40094,
        XPSubject: 40095,
        PrintImageMatching: 50341,
        DNGVersion: 50706,
        DNGBackwardVersion: 50707,
        UniqueCameraModel: 50708,
        LocalizedCameraModel: 50709,
        CFAPlaneColor: 50710,
        CFALayout: 50711,
        LinearizationTable: 50712,
        BlackLevelRepeatDim: 50713,
        BlackLevel: 50714,
        BlackLevelDeltaH: 50715,
        BlackLevelDeltaV: 50716,
        WhiteLevel: 50717,
        DefaultScale: 50718,
        DefaultCropOrigin: 50719,
        DefaultCropSize: 50720,
        ColorMatrix1: 50721,
        ColorMatrix2: 50722,
        CameraCalibration1: 50723,
        CameraCalibration2: 50724,
        ReductionMatrix1: 50725,
        ReductionMatrix2: 50726,
        AnalogBalance: 50727,
        AsShotNeutral: 50728,
        AsShotWhiteXY: 50729,
        BaselineExposure: 50730,
        BaselineNoise: 50731,
        BaselineSharpness: 50732,
        BayerGreenSplit: 50733,
        LinearResponseLimit: 50734,
        CameraSerialNumber: 50735,
        LensInfo: 50736,
        ChromaBlurRadius: 50737,
        AntiAliasStrength: 50738,
        ShadowScale: 50739,
        DNGPrivateData: 50740,
        MakerNoteSafety: 50741,
        CalibrationIlluminant1: 50778,
        CalibrationIlluminant2: 50779,
        BestQualityScale: 50780,
        RawDataUniqueID: 50781,
        OriginalRawFileName: 50827,
        OriginalRawFileData: 50828,
        ActiveArea: 50829,
        MaskedAreas: 50830,
        AsShotICCProfile: 50831,
        AsShotPreProfileMatrix: 50832,
        CurrentICCProfile: 50833,
        CurrentPreProfileMatrix: 50834,
        ColorimetricReference: 50879,
        CameraCalibrationSignature: 50931,
        ProfileCalibrationSignature: 50932,
        AsShotProfileName: 50934,
        NoiseReductionApplied: 50935,
        ProfileName: 50936,
        ProfileHueSatMapDims: 50937,
        ProfileHueSatMapData1: 50938,
        ProfileHueSatMapData2: 50939,
        ProfileToneCurve: 50940,
        ProfileEmbedPolicy: 50941,
        ProfileCopyright: 50942,
        ForwardMatrix1: 50964,
        ForwardMatrix2: 50965,
        PreviewApplicationName: 50966,
        PreviewApplicationVersion: 50967,
        PreviewSettingsName: 50968,
        PreviewSettingsDigest: 50969,
        PreviewColorSpace: 50970,
        PreviewDateTime: 50971,
        RawImageDigest: 50972,
        OriginalRawFileDigest: 50973,
        SubTileBlockSize: 50974,
        RowInterleaveFactor: 50975,
        ProfileLookTableDims: 50981,
        ProfileLookTableData: 50982,
        OpcodeList1: 51008,
        OpcodeList2: 51009,
        OpcodeList3: 51022,
        NoiseProfile: 51041,
    };


    that.ExifIFD = {
        ExposureTime: 33434,
        FNumber: 33437,
        ExposureProgram: 34850,
        SpectralSensitivity: 34852,
        ISOSpeedRatings: 34855,
        OECF: 34856,
        SensitivityType: 34864,
        StandardOutputSensitivity: 34865,
        RecommendedExposureIndex: 34866,
        ISOSpeed: 34867,
        ISOSpeedLatitudeyyy: 34868,
        ISOSpeedLatitudezzz: 34869,
        ExifVersion: 36864,
        DateTimeOriginal: 36867,
        DateTimeDigitized: 36868,
        ComponentsConfiguration: 37121,
        CompressedBitsPerPixel: 37122,
        ShutterSpeedValue: 37377,
        ApertureValue: 37378,
        BrightnessValue: 37379,
        ExposureBiasValue: 37380,
        MaxApertureValue: 37381,
        SubjectDistance: 37382,
        MeteringMode: 37383,
        LightSource: 37384,
        Flash: 37385,
        FocalLength: 37386,
        SubjectArea: 37396,
        MakerNote: 37500,
        UserComment: 37510,
        SubSecTime: 37520,
        SubSecTimeOriginal: 37521,
        SubSecTimeDigitized: 37522,
        FlashpixVersion: 40960,
        ColorSpace: 40961,
        PixelXDimension: 40962,
        PixelYDimension: 40963,
        RelatedSoundFile: 40964,
        InteroperabilityTag: 40965,
        FlashEnergy: 41483,
        SpatialFrequencyResponse: 41484,
        FocalPlaneXResolution: 41486,
        FocalPlaneYResolution: 41487,
        FocalPlaneResolutionUnit: 41488,
        SubjectLocation: 41492,
        ExposureIndex: 41493,
        SensingMethod: 41495,
        FileSource: 41728,
        SceneType: 41729,
        CFAPattern: 41730,
        CustomRendered: 41985,
        ExposureMode: 41986,
        WhiteBalance: 41987,
        DigitalZoomRatio: 41988,
        FocalLengthIn35mmFilm: 41989,
        SceneCaptureType: 41990,
        GainControl: 41991,
        Contrast: 41992,
        Saturation: 41993,
        Sharpness: 41994,
        DeviceSettingDescription: 41995,
        SubjectDistanceRange: 41996,
        ImageUniqueID: 42016,
        CameraOwnerName: 42032,
        BodySerialNumber: 42033,
        LensSpecification: 42034,
        LensMake: 42035,
        LensModel: 42036,
        LensSerialNumber: 42037,
        Gamma: 42240,
    };


    that.GPSIFD = {
        GPSVersionID: 0,
        GPSLatitudeRef: 1,
        GPSLatitude: 2,
        GPSLongitudeRef: 3,
        GPSLongitude: 4,
        GPSAltitudeRef: 5,
        GPSAltitude: 6,
        GPSTimeStamp: 7,
        GPSSatellites: 8,
        GPSStatus: 9,
        GPSMeasureMode: 10,
        GPSDOP: 11,
        GPSSpeedRef: 12,
        GPSSpeed: 13,
        GPSTrackRef: 14,
        GPSTrack: 15,
        GPSImgDirectionRef: 16,
        GPSImgDirection: 17,
        GPSMapDatum: 18,
        GPSDestLatitudeRef: 19,
        GPSDestLatitude: 20,
        GPSDestLongitudeRef: 21,
        GPSDestLongitude: 22,
        GPSDestBearingRef: 23,
        GPSDestBearing: 24,
        GPSDestDistanceRef: 25,
        GPSDestDistance: 26,
        GPSProcessingMethod: 27,
        GPSAreaInformation: 28,
        GPSDateStamp: 29,
        GPSDifferential: 30,
        GPSHPositioningError: 31,
    };


    that.InteropIFD = {
        InteroperabilityIndex: 1,
    };

    that.GPSHelper = {
        degToDmsRational: function (degFloat) {
            var degAbs = Math.abs(degFloat);
            var minFloat = degAbs % 1 * 60;
            var secFloat = minFloat % 1 * 60;
            var deg = Math.floor(degAbs);
            var min = Math.floor(minFloat);
            var sec = Math.round(secFloat * 100);

            return [[deg, 1], [min, 1], [sec, 100]];
        },

        dmsRationalToDeg: function (dmsArray, ref) {
            var sign = (ref === 'S' || ref === 'W') ? -1.0 : 1.0;
            var deg = dmsArray[0][0] / dmsArray[0][1] +
                dmsArray[1][0] / dmsArray[1][1] / 60.0 +
                dmsArray[2][0] / dmsArray[2][1] / 3600.0;

            return deg * sign;
        }
    };


    if (typeof exports !== 'undefined') {
        if (typeof module !== 'undefined' && module.exports) {
            exports = module.exports = that;
        }
        exports.piexif = that;
    } else {
        window.piexif = that;
    }
})();
// End of piexifjs

// Start of QuickEXIF script
(function () {
    'use strict';

    // Constants
    const JPEG_EXTENSIONS = ['jpg', 'jpeg'];
    const MAX_COORDINATE_VALUE = 180;
    const MAX_EDIT_SUMMARY_LENGTH = 500;
    const SUCCESS_RELOAD_DELAY = 2000;
    const EXIF_DATE_FORMAT = /^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$/;
    const USERCOMMENT_CHARSET_PREFIX = 'ASCII\x00\x00\x00';
    const GPS_VERSION = [2, 2, 0, 0];
    const GPS_MAP_DATUM = 'WGS-84';

    // Regex patterns and maps
    const RE_GPS_DECIMAL = /^([+-]?\d+\.?\d*)°?$/;
    const RE_GPS_DMS = /(\d+(?:\.\d+)?)°\s*(\d+(?:\.\d+)?)['''′]\s*([\d.]+)["″""]?\s*([NSEW])?/i;
    const RE_GPS_DECIMAL_DIR = /([\d.]+)\s*([NSEW])/i;
    const RE_WM_DATE = /(\d{1,2}):(\d{2}),\s+(\d{1,2})\s+(\w+)\s+(\d{4})/;
    const RE_ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/;

    const MONTHS_MAP = {
        'January': '01', 'February': '02', 'March': '03', 'April': '04',
        'May': '05', 'June': '06', 'July': '07', 'August': '08',
        'September': '09', 'October': '10', 'November': '11', 'December': '12'
    };

    const FIELD_NAME_MAP = {
        'datetimeoriginal': 'DateTimeOriginal',
        'imagedescription': 'ImageDescription',
        'artist': 'Artist',
        'copyright': 'Copyright',
        'usercomment': 'UserComment',
        'gpslatitude': 'GPSLatitude',
        'gpslongitude': 'GPSLongitude'
    };

    let editMode = false;
    let currentFileName = null;
    let api = null;
    let metadataTable = null;

    /**
     * Initialize the EXIF editor.
     */
    function init() {
        try {
            if (typeof mw === 'undefined' || !mw.Api) {
                return;
            }

            api = new mw.Api();

            metadataTable = document.getElementById('mw_metadata');
            if (!metadataTable) {
                return;
            }

            currentFileName = getFileName();
            if (!currentFileName) {
                return;
            }

            // Only run on JPEG files
            if (!isJPEGFile(currentFileName)) {
                return;
            }

            // Only show "Edit" button if user can upload
            canUserReupload().then(canReupload => {
                if (canReupload) {
                    injectEditButton(metadataTable);
                } else {
                    showUnavailableMessage(metadataTable);
                }
            }).catch(error => {
                console.error('[QuickEXIF] Error checking upload permissions:', error);
                // Show button anyway as fallback
                injectEditButton(metadataTable);
            });
        } catch (error) {
            console.error('[QuickEXIF] Error initializing:', error);
        }
    }

    /**
     * Show a message indicating QuickEXIF is unavailable due to permissions.
     * @param {HTMLElement} metadataTable - The metadata table element.
     */
    function showUnavailableMessage(metadataTable) {
        const messageContainer = document.createElement('div');
        messageContainer.style.cssText = 'width: fit-content; margin: 0 0 10px 0; padding: 10px; background: #f0f0f0; color: #666; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;';
        messageContainer.textContent = 'QuickEXIF unavailable, can\'t overwrite file';

        metadataTable.parentNode.insertBefore(messageContainer, metadataTable);
    }

    /**
     * Check if user has permission to reupload the file.
     * @returns {Promise<boolean>} Promise that resolves to true if the user can reupload, false otherwise.
     */
    async function canUserReupload() {
        // Get current user
        const currentUser = mw.config.get('wgUserName');

        // Anonymous users cannot upload
        if (!currentUser) {
            return false;
        }

        // Get user groups
        const userGroups = mw.config.get('wgUserGroups') || [];

        // Sysops, autoconfirmed, and confirmed users can upload other users' files
        if (userGroups.includes('sysop') ||
            userGroups.includes('autoconfirmed') ||
            userGroups.includes('confirmed')) {
            return checkFileProtection();
        }

        // Other regular users can only reupload their own files
        if (userGroups.includes('user')) {
            try {
                const isOwner = await isOriginalUploader(currentUser);
                if (isOwner) {
                    return checkFileProtection();
                }
            } catch (error) {
                console.error('[QuickEXIF] Error checking file ownership:', error);
                return false;
            }
        }

        return false;
    }

    /**
     * Check if the current user is the original uploader of the file.
     * @param {string} currentUser - The current user's username.
     * @returns {Promise<boolean>} Promise that resolves to true if user is original uploader.
     */
    async function isOriginalUploader(currentUser) {
        try {
            const result = await api.get({
                action: 'query',
                prop: 'imageinfo',
                titles: 'File:' + currentFileName,
                iiprop: 'user',
                iilimit: 1
            });

            const pages = result.query.pages;
            const page = pages[Object.keys(pages)[0]];

            if (page.imageinfo && page.imageinfo[0]) {
                return page.imageinfo[0].user === currentUser;
            }
        } catch (error) {
            console.error('[QuickEXIF] Error in isOriginalUploader:', error);
            throw error;
        }

        return false;
    }

    /**
     * Check if the file page has upload protection that the user cannot bypass.
     * @returns {boolean} True if user can upload despite protection.
     */
    function checkFileProtection() {
        // Get upload restrictions from page
        const uploadRestrictions = mw.config.get('wgRestrictionUpload') || [];
        if (uploadRestrictions.length === 0) {
            return true;
        }

        const userGroups = mw.config.get('wgUserGroups') || [];

        // Assume sysops always allowed
        if (userGroups.includes('sysop')) {
            return true;
        }

        // Check if user meets each restriction level
        for (const restrictionLevel of uploadRestrictions) {
            if (restrictionLevel === 'autoconfirmed') {
                if (!userGroups.includes('autoconfirmed') && !userGroups.includes('confirmed')) {
                    return false;
                }
            } else if (restrictionLevel === 'editautopatrolprotected') {
                if (!userGroups.includes('autopatrolled') && !userGroups.includes('patroller')) {
                    return false;
                }
            } else if (!userGroups.includes(restrictionLevel)) {
                // This captures sysop protection
                return false;
            }
        }

        return true;
    }

    /**
     * Check if a file is a JPEG based on its extension.
     * @param {string} filename - The filename to check.
     * @returns {boolean} True if the file is a JPEG, false otherwise.
     */
    function isJPEGFile(filename) {
        if (!filename || typeof filename !== 'string') {
            return false;
        }
        const extension = filename.split('.').pop().toLowerCase();
        return JPEG_EXTENSIONS.includes(extension);
    }

    /**
     * Extract the file name from the current page.
     * @returns {string|null} The file name without 'File:' prefix, or null if not found.
     */
    function getFileName() {
        const pageTitle = mw.config.get('wgPageName');
        if (pageTitle && pageTitle.startsWith('File:')) {
            // Remove 'File:' prefix and return just the filename
            return pageTitle.substring(5);
        }
        return null;
    }

    /**
     * Inject missing editable EXIF fields into the metadata table.
     * Fields that don't exist in the original EXIF data will be added as editable placeholders.
     * @param {HTMLElement} metadataTable - The metadata table element.
     */
    function injectMissingFields(metadataTable) {
        const tbody = metadataTable.querySelector('tbody');
        if (!tbody) return;

        const missingFields = [];
        EDITABLE_FIELDS.forEach(field => {
            const existingRow = metadataTable.querySelector('.' + field.className);
            if (!existingRow) {
                missingFields.push(field);
            }
        });

        if (missingFields.length === 0) return;

        // Create "Existing EXIF Fields" header
        let existingHeader = metadataTable.querySelector('.exif-existing-fields-header');
        if (!existingHeader) {
            let insertionPoint = tbody.firstChild;
            while (insertionPoint && (insertionPoint.classList.contains('exif-new-fields-header') || (insertionPoint.dataset && insertionPoint.dataset.injected === 'true'))) {
                insertionPoint = insertionPoint.nextSibling;
            }

            if (insertionPoint) {
                existingHeader = document.createElement('tr');
                existingHeader.className = 'exif-existing-fields-header';
                const th = document.createElement('th');
                th.textContent = 'Existing EXIF Fields';
                th.colSpan = 2;
                th.style.cssText = 'text-align: center; background-color: #f8f9fa; color: #202122; padding: 10px; border-bottom: 2px solid #a2a9b1; border-top: 2px solid #a2a9b1;';
                existingHeader.appendChild(th);
                tbody.insertBefore(existingHeader, insertionPoint);
            }
        }

        // Create "New EXIF Fields" header
        let headerRow = metadataTable.querySelector('.exif-new-fields-header');
        if (!headerRow) {
            headerRow = document.createElement('tr');
            headerRow.className = 'exif-new-fields-header';
            const th = document.createElement('th');
            th.textContent = 'New EXIF Fields';
            th.colSpan = 2;
            th.style.cssText = 'text-align: center; background-color: #f8f9fa; color: #202122; padding: 10px; border-bottom: 2px solid #a2a9b1;';
            headerRow.appendChild(th);
            tbody.insertBefore(headerRow, tbody.firstChild);
        }

        // Insert new fields after the "new fields" header
        let lastNode = headerRow;
        let tempNode = headerRow;
        while (tempNode.nextSibling && tempNode.nextSibling.dataset && tempNode.nextSibling.dataset.injected === 'true') {
            tempNode = tempNode.nextSibling;
        }
        lastNode = tempNode;

        missingFields.forEach(field => {
            if (metadataTable.querySelector('.' + field.className)) return;

            // Create new row for the missing field
            const tr = document.createElement('tr');
            tr.className = field.className;
            tr.dataset.injected = 'true';

            const th = document.createElement('th');
            th.textContent = field.label;
            th.title = 'This field was not in the original EXIF data';

            const td = document.createElement('td');
            td.textContent = field.defaultValue;
            td.style.fontStyle = 'italic';
            td.style.color = '#999';

            tr.appendChild(th);
            tr.appendChild(td);

            // Insert after lastNode
            if (lastNode.nextSibling) {
                tbody.insertBefore(tr, lastNode.nextSibling);
            } else {
                tbody.appendChild(tr);
            }
            lastNode = tr;
        });
    }

    /**
     * Inject the Edit EXIF button into the page.
     * @param {HTMLElement} metadataTable - The metadata table element.
     */
    function injectEditButton(metadataTable) {
        const buttonContainer = document.createElement('div');
        buttonContainer.id = 'exif-editor-controls';
        buttonContainer.style.cssText = 'margin: 0 0 10px 0;';

        const editButton = document.createElement('button');
        editButton.textContent = 'Edit EXIF';
        editButton.style.cssText = 'padding: 8px 16px; background: #36c; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; margin-right: 10px;';
        editButton.addEventListener('click', toggleEditMode);

        buttonContainer.appendChild(editButton);
        metadataTable.parentNode.insertBefore(buttonContainer, metadataTable);
    }

    /**
     * Toggle between view and edit modes.
     */
    function toggleEditMode() {
        editMode = !editMode;

        if (editMode) {
            enterEditMode();
        } else {
            exitEditMode();
        }
    }

    /**
     * Configuration for editable EXIF fields.
     */
    const EDITABLE_FIELDS = [
        {
            className: 'exif-datetimeoriginal',
            label: 'Date and time of data generation',
            fieldName: 'datetimeoriginal',
            inputType: 'datetime-local',
            defaultValue: '',
            createInput: (currentValue) => {
                const input = document.createElement('input');
                input.type = 'datetime-local';
                input.step = '1';
                input.style.cssText = 'width: 95%; padding: 4px; font-size: 14px;';
                input.dataset.fieldName = 'datetimeoriginal';
                input.required = false;
                const parsedDate = parseWikimediaDate(currentValue);
                if (parsedDate) {
                    input.value = parsedDate;
                }
                return input;
            }
        },
        {
            className: 'exif-imagedescription',
            label: 'Image description',
            fieldName: 'imagedescription',
            inputType: 'text',
            multiline: true,
            defaultValue: '',
            placeholder: 'Brief description of the image'
        },
        {
            className: 'exif-artist',
            label: 'Photographer',
            fieldName: 'artist',
            inputType: 'text',
            multiline: true,
            defaultValue: '',
            placeholder: 'Author/Artist name'
        },
        {
            className: 'exif-copyright',
            label: 'Copyright holder',
            fieldName: 'copyright',
            inputType: 'text',
            multiline: true,
            defaultValue: '',
            placeholder: 'Copyright holder'
        },
        {
            className: 'exif-usercomment',
            label: 'User comment',
            fieldName: 'usercomment',
            inputType: 'text',
            multiline: true,
            defaultValue: '',
            placeholder: 'Additional notes or comments'
        },
        {
            className: 'exif-gpslatitude',
            label: 'GPS Latitude',
            fieldName: 'gpslatitude',
            inputType: 'number',
            defaultValue: '(not set)',
            createInput: (currentValue) => {
                const parsedCoord = parseGPSCoordinate(currentValue);
                const input = document.createElement('input');
                input.type = 'number';
                input.step = 'any';
                input.min = '-90';
                input.max = '90';
                input.style.cssText = 'width: 95%; padding: 4px; font-size: 14px;';
                input.value = parsedCoord !== null ? parsedCoord : '';
                input.dataset.fieldName = 'gpslatitude';
                input.placeholder = 'e.g., 37.7749 (positive for N, negative for S)';
                return input;
            }
        },
        {
            className: 'exif-gpslongitude',
            label: 'GPS Longitude',
            fieldName: 'gpslongitude',
            inputType: 'number',
            defaultValue: '(not set)',
            createInput: (currentValue) => {
                const parsedCoord = parseGPSCoordinate(currentValue);
                const input = document.createElement('input');
                input.type = 'number';
                input.step = 'any';
                input.min = String(-MAX_COORDINATE_VALUE);
                input.max = String(MAX_COORDINATE_VALUE);
                input.style.cssText = 'width: 95%; padding: 4px; font-size: 14px;';
                input.value = parsedCoord !== null ? parsedCoord : '';
                input.dataset.fieldName = 'gpslongitude';
                input.placeholder = 'e.g., -122.4194 (positive for E, negative for W)';
                return input;
            }
        }
    ];

    /**
     * Create an input element for a field.
     * @param {Object} fieldConfig - Field configuration object.
     * @param {string} currentValue - Current field value.
     * @returns {HTMLInputElement} The created input element.
     */
    function createFieldInput(fieldConfig, currentValue) {
        let input;
        if (fieldConfig.createInput) {
            input = fieldConfig.createInput(currentValue);
        } else {
            if (fieldConfig.multiline) {
                input = document.createElement('textarea');
                input.rows = 2;
                input.style.resize = 'vertical';
            } else {
                input = document.createElement('input');
                input.type = fieldConfig.inputType || 'text';
            }

            input.style.cssText += 'width: 95%; padding: 4px; font-size: 14px; font-family: sans-serif;';
            if (fieldConfig.multiline) {
                input.style.minHeight = '60px';
            }

            input.value = currentValue;
            input.dataset.fieldName = fieldConfig.fieldName;
            if (fieldConfig.placeholder) {
                input.placeholder = fieldConfig.placeholder;
            }
        }

        // Store for restoration (the original display text)
        input.dataset.originalText = currentValue;

        // Store for change detection (the initial input state)
        input.dataset.initialValue = input.value || '';

        return input;
    }

    /**
     * Restore a field to its original text value.
     * @param {HTMLElement} row - The table row element.
     */
    function restoreField(row) {
        if (!row) return;
        const tdElement = row.querySelector('td');
        if (!tdElement) return;
        const input = tdElement.querySelector('input, textarea');
        if (input && input.dataset.originalText !== undefined) {
            tdElement.textContent = input.dataset.originalText;
        }
    }

    /**
     * Enter edit mode and convert metadata fields to editable inputs.
     */
    function enterEditMode() {
        if (!metadataTable) return;

        injectMissingFields(metadataTable);

        // Convert each field to editable input
        EDITABLE_FIELDS.forEach(fieldConfig => {
            const row = metadataTable.querySelector('.' + fieldConfig.className);
            if (row) {
                const tdElement = row.querySelector('td');
                if (tdElement) {
                    const currentValue = tdElement.textContent.trim();
                    const input = createFieldInput(fieldConfig, currentValue);
                    tdElement.innerHTML = '';
                    tdElement.appendChild(input);
                }
            }
        });

        // Update button text and add save button
        updateButtonsForEditMode();
    }

    /**
     * Exit edit mode and restore the original field values.
     */
    function exitEditMode() {
        if (!metadataTable) return;

        // Restore all fields to their original values
        EDITABLE_FIELDS.forEach(fieldConfig => {
            const row = metadataTable.querySelector('.' + fieldConfig.className);
            restoreField(row);
        });

        updateButtonsForViewMode();
    }

    /**
     * Update the UI to show edit mode controls (cancel, save, edit summary).
     */
    function updateButtonsForEditMode() {
        const buttonContainer = document.getElementById('exif-editor-controls');
        buttonContainer.innerHTML = '';

        // Create button container
        const buttonsDiv = document.createElement('div');
        buttonsDiv.style.cssText = 'margin-bottom: 10px;';

        const cancelButton = document.createElement('button');
        cancelButton.textContent = 'Cancel';
        cancelButton.style.cssText = 'padding: 8px 16px; background: #72777d; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; margin-right: 10px;';
        cancelButton.addEventListener('click', () => {
            editMode = false;
            exitEditMode();
        });

        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save and Re-upload';
        saveButton.style.cssText = 'padding: 8px 16px; background: #00af89; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 14px;';
        saveButton.addEventListener('click', saveAndReupload);

        buttonsDiv.appendChild(cancelButton);
        buttonsDiv.appendChild(saveButton);

        // Create a container for the edit summary input
        const summaryContainer = document.createElement('div');

        const summaryLabel = document.createElement('label');
        summaryLabel.textContent = 'Edit summary (optional): ';
        summaryLabel.style.cssText = 'display: inline-block; margin-right: 8px; font-weight: bold;';

        const summaryInput = document.createElement('input');
        summaryInput.type = 'text';
        summaryInput.maxLength = MAX_EDIT_SUMMARY_LENGTH;
        summaryInput.id = 'exif-edit-summary';
        summaryInput.placeholder = 'Additional details about your changes';
        summaryInput.style.cssText = 'width: 400px; padding: 6px; font-size: 14px; border: 1px solid #a2a9b1; border-radius: 2px;';

        summaryContainer.appendChild(summaryLabel);
        summaryContainer.appendChild(summaryInput);

        buttonContainer.appendChild(buttonsDiv);
        buttonContainer.appendChild(summaryContainer);
    }

    /**
     * Update the UI to show view mode controls (edit button).
     */
    function updateButtonsForViewMode() {
        const buttonContainer = document.getElementById('exif-editor-controls');
        buttonContainer.innerHTML = '';

        const editButton = document.createElement('button');
        editButton.textContent = 'Edit EXIF';
        editButton.style.cssText = 'padding: 8px 16px; background: #36c; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; margin-right: 10px;';
        editButton.addEventListener('click', toggleEditMode);

        buttonContainer.appendChild(editButton);
    }

    /**
     * Check if a field value should be considered empty.
     * @param {*} value - The value to check.
     * @returns {boolean} True if the value is empty, false otherwise.
     */
    function isEmptyValue(value) {
        return !value || value === '' || value === '(not set)';
    }

    /**
     * Validate GPS coordinate value.
     * @param {number} coord - The coordinate value to validate.
     * @param {string} type - Type of coordinate ('latitude' or 'longitude').
     * @returns {boolean} True if valid, false otherwise.
     */
    function isValidGPSCoordinate(coord, type) {
        if (typeof coord !== 'number' || isNaN(coord) || !isFinite(coord)) {
            return false;
        }
        if (type === 'latitude') {
            return coord >= -90 && coord <= 90;
        } else if (type === 'longitude') {
            return coord >= -MAX_COORDINATE_VALUE && coord <= MAX_COORDINATE_VALUE;
        }
        return false;
    }

    /**
     * Get GPS reference direction for a coordinate.
     * @param {number} value - The coordinate value.
     * @param {string} type - Type of coordinate ('latitude' or 'longitude').
     * @returns {string} The reference direction (N/S for latitude, E/W for longitude).
     */
    function getGPSReference(value, type) {
        if (type === 'latitude') {
            return value >= 0 ? 'N' : 'S';
        } else {
            return value >= 0 ? 'E' : 'W';
        }
    }

    /**
     * Parse GPS coordinates from various formats to decimal degrees.
     * Supports decimal, DMS (degrees minutes seconds), and decimal with direction formats.
     * @param {string} coordStr - The coordinate string to parse.
     * @returns {number|null} The coordinate in decimal degrees, or null if parsing fails.
     */
    function parseGPSCoordinate(coordStr) {
        if (isEmptyValue(coordStr)) return null;

        // Clean up string
        coordStr = coordStr.trim();

        // Try parsing decimal format (e.g., "37.7749" or "37.7749°")
        const decimalMatch = coordStr.match(RE_GPS_DECIMAL);
        if (decimalMatch) {
            return parseFloat(decimalMatch[1]);
        }

        // Try parsing DMS format with various quote styles
        // Handles: 37° 46' 29.64" N, 37° 46′ 29.64″ N, etc.
        const dmsMatch = coordStr.match(RE_GPS_DMS);
        if (dmsMatch) {
            const degrees = parseFloat(dmsMatch[1]);
            const minutes = parseFloat(dmsMatch[2]);
            const seconds = parseFloat(dmsMatch[3]);
            const direction = dmsMatch[4];

            let decimal = degrees + minutes / 60 + seconds / 3600;

            if (direction && (direction.toUpperCase() === 'S' || direction.toUpperCase() === 'W')) {
                decimal = -decimal;
            }

            return decimal;
        }

        // Try parsing format "37.7749 N" or "122.4194 W"
        const decimalDirMatch = coordStr.match(RE_GPS_DECIMAL_DIR);
        if (decimalDirMatch) {
            let decimal = parseFloat(decimalDirMatch[1]);
            const direction = decimalDirMatch[2].toUpperCase();

            if (direction === 'S' || direction === 'W') {
                decimal = -decimal;
            }

            return decimal;
        }

        // Try parsing just a decimal number
        const plainDecimal = parseFloat(coordStr);
        return isNaN(plainDecimal) ? null : plainDecimal;
    }

    /**
     * Parse Wikimedia date format to datetime-local format for HTML input.
     * @param {string} dateStr - Date string in format "HH:MM, DD Month YYYY".
     * @returns {string|null} ISO 8601 format datetime string, or null if parsing fails.
     */
    function parseWikimediaDate(dateStr) {
        const match = dateStr.match(RE_WM_DATE);
        if (match) {
            const [_, hours, minutes, day, month, year] = match;
            const monthNum = MONTHS_MAP[month];
            if (!monthNum) return null;

            const dayPadded = day.padStart(2, '0');

            return `${year}-${monthNum}-${dayPadded}T${hours.padStart(2, '0')}:${minutes}`;
        }

        return null;
    }

    /**
     * Convert datetime-local format to EXIF format.
     * @param {string} dateStr - ISO 8601 format datetime string (e.g., "2025-09-06T07:56").
     * @returns {string} EXIF format datetime string (e.g., "2025:09:06 07:56:00").
     */
    function formatDateForExif(dateStr) {
        const date = new Date(dateStr);
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const hours = String(date.getHours()).padStart(2, '0');
        const minutes = String(date.getMinutes()).padStart(2, '0');
        const seconds = String(date.getSeconds()).padStart(2, '0');

        return `${year}:${month}:${day} ${hours}:${minutes}:${seconds}`;
    }

    /**
     * Collect edited data, download the original image, modify EXIF, and re-upload.
     */
    async function saveAndReupload() {
        try {
            return await performSaveAndReupload();
        } catch (error) {
            console.error('[QuickEXIF] Error in saveAndReupload:', error);
            hideLoadingState();
            updateButtonsForEditMode();
            alert('An error occurred while saving: ' + (error.message || 'Unknown error'));
        }
    }

    async function performSaveAndReupload() {
        if (!metadataTable) {
            throw new Error('Metadata table not found');
        }

        const editedData = {};
        const fieldActions = {};
        const allInputs = metadataTable.querySelectorAll('input[data-field-name], textarea[data-field-name]');
        const inputParsingErrors = [];

        allInputs.forEach(input => {
            // Check for browser-detected invalid input (e.g. partial dates in datetime-local)
            if (input.validity && input.validity.badInput) {
                const label = input.closest('tr')?.querySelector('th')?.textContent?.trim() || input.dataset.fieldName;
                inputParsingErrors.push(`Invalid value for field "${label}".`);
                return;
            }

            const fieldName = input.dataset.fieldName;
            // Use dataset.initialValue (set in createFieldInput) for comparison
            const originalValue = input.dataset.initialValue || '';
            const currentValue = input.value ? input.value.trim() : '';

            // Include field if changed
            if (currentValue !== originalValue) {
                editedData[fieldName] = currentValue;

                // Determine action type
                // Actions:
                // 'removed' (clearing a value),
                // 'added' (setting a new value),
                // 'changed' (modifying existing)
                const origEmpty = isEmptyValue(originalValue);
                const currEmpty = isEmptyValue(currentValue) || currentValue === '';

                if (currEmpty) {
                    fieldActions[fieldName] = 'removed';
                } else if (origEmpty) {
                    fieldActions[fieldName] = 'added';
                } else {
                    fieldActions[fieldName] = 'changed';
                }
            }
        });

        if (inputParsingErrors.length > 0) {
            alert('Validation failed:\n' + inputParsingErrors.join('\n'));
            return;
        }

        if (Object.keys(editedData).length === 0) {
            alert('No changes detected. Please modify at least one field.');
            return;
        }

        // Validate edited data
        const validation = validateEditedData(editedData);
        if (!validation.isValid) {
            alert('Validation failed:\n' + validation.errors.join('\n'));
            return;
        }

        if (mw.user.isAnon()) {
            alert('You must be logged in to upload files. Please log in and try again.');
            return;
        }

        const customSummaryInput = document.getElementById('exif-edit-summary');
        const customSummary = customSummaryInput ? customSummaryInput.value.trim() : '';

        showLoadingState('Downloading image...');

        try {
            const imageUrl = getOriginalImageUrl();
            if (!imageUrl) {
                throw new Error('Could not find original image URL');
            }

            const imageBlob = await downloadImage(imageUrl);

            showLoadingState('Modifying EXIF data...');
            const modifiedBlob = await modifyExifData(imageBlob, editedData, fieldActions);

            showLoadingState('Uploading to Commons...');
            await uploadToCommons(modifiedBlob, customSummary, editedData, fieldActions);

            showSuccessMessage();
        } catch (error) {
            throw error;
        }
    }

    /**
     * Get the URL of the original full-resolution image.
     * @returns {string|null} The image URL, or null if not found.
     */
    function getOriginalImageUrl() {
        // Primary method: look for full resolution link
        const fullResLink = document.querySelector('.fullMedia a');
        if (fullResLink && fullResLink.href) {
            return fullResLink.href;
        }

        // Fallback: construct URL from filename
        if (currentFileName) {
            const imageUrl = mw.config.get('wgServer') + mw.config.get('wgScriptPath') + '/index.php?title=Special:Redirect/file/' + encodeURIComponent(currentFileName);
            return imageUrl;
        }

        return null;
    }

    /**
     * Download an image from a URL.
     * @param {string} url - The URL of the image to download.
     * @returns {Promise<Blob>} Promise that resolves to the image blob.
     */
    async function downloadImage(url) {
        if (!url) {
            throw new Error('Invalid image URL');
        }

        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
            }
            const blob = await response.blob();
            if (!blob || blob.size === 0) {
                throw new Error('Downloaded image is empty');
            }
            return blob;
        } catch (error) {
            throw new Error('Network error while downloading image: ' + error.message);
        }
    }

    /**
     * Modify EXIF data in the image.
     * @param {Blob} blob - The image blob to modify.
     * @param {Object} editedData - Object containing the edited field values.
     * @returns {Promise<Blob>} Promise that resolves to a new blob with modified EXIF data.
     */
    /**
     * Validate edited data before applying to EXIF.
     * @param {Object} editedData - The edited data to validate.
     * @returns {Object} Validation result with isValid and errors properties.
     */
    function validateEditedData(editedData) {
        const errors = [];

        // Validate GPS coordinates
        const gpsFields = [
            { key: 'gpslatitude', type: 'latitude', range: '90' },
            { key: 'gpslongitude', type: 'longitude', range: String(MAX_COORDINATE_VALUE) }
        ];

        gpsFields.forEach(field => {
            const value = editedData[field.key];
            if (value !== undefined && value !== null && value !== '') {
                const coord = parseFloat(value);
                if (!isValidGPSCoordinate(coord, field.type)) {
                    errors.push(`Invalid ${field.type} value. Must be between -${field.range} and ${field.range}.`);
                }
            }
        });

        // Validate datetime
        if (editedData.datetimeoriginal !== undefined && editedData.datetimeoriginal !== '') {
            // Validate datetime-local format: YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS
            if (!RE_ISO_DATE.test(editedData.datetimeoriginal)) {
                errors.push('Invalid date/time format. Please use the date and time picker to select a complete date and time.');
            } else {
                // Check if the input date is valid
                const testDate = new Date(editedData.datetimeoriginal);
                if (isNaN(testDate.getTime())) {
                    errors.push('Invalid date/time value.');
                } else {
                    const exifDateTime = formatDateForExif(editedData.datetimeoriginal);
                    if (!EXIF_DATE_FORMAT.test(exifDateTime)) {
                        errors.push('Invalid date/time format. Expected format: YYYY:MM:DD HH:MM:SS');
                    }
                }
            }
        }

        return {
            isValid: errors.length === 0,
            errors: errors
        };
    }

    function modifyExifData(blob, editedData, fieldActions) {
        if (!blob || blob.size === 0) {
            throw new Error('Invalid image blob: blob is empty');
        }

        if (blob.type && !blob.type.startsWith('image/')) {
            throw new Error('Invalid blob type: expected image, got ' + blob.type);
        }

        fieldActions = fieldActions || {};

        return new Promise((resolve, reject) => {
            const reader = new FileReader();

            reader.onload = function (e) {
                try {
                    const dataUrl = e.target.result;

                    if (!dataUrl || typeof dataUrl !== 'string') {
                        throw new Error('Failed to read image data');
                    }

                    let exifObj;
                    try {
                        exifObj = piexif.load(dataUrl);
                    } catch (err) {
                        exifObj = {
                            "0th": {},
                            "Exif": {},
                            "GPS": {},
                            "Interop": {},
                            "1st": {},
                            "thumbnail": null
                        };
                    }

                    // Ensure required dicts exist
                    if (!exifObj['Exif']) {
                        exifObj['Exif'] = {};
                    }
                    if (!exifObj['0th']) {
                        exifObj['0th'] = {};
                    }
                    if (!exifObj['GPS']) {
                        exifObj['GPS'] = {};
                    }

                    // Modify or clear DateTimeOriginal
                    if (editedData.datetimeoriginal !== undefined) {
                        if (editedData.datetimeoriginal) {
                            const exifDateTime = formatDateForExif(editedData.datetimeoriginal);
                            exifObj['Exif'][piexif.ExifIFD.DateTimeOriginal] = exifDateTime;
                            exifObj['Exif'][piexif.ExifIFD.DateTimeDigitized] = exifDateTime;
                        } else {
                            delete exifObj['Exif'][piexif.ExifIFD.DateTimeOriginal];
                            delete exifObj['Exif'][piexif.ExifIFD.DateTimeDigitized];
                        }
                    }

                    // Modify or clear Image Description
                    if (editedData.imagedescription !== undefined) {
                        if (editedData.imagedescription) {
                            exifObj['0th'][piexif.ImageIFD.ImageDescription] = editedData.imagedescription;
                        } else {
                            delete exifObj['0th'][piexif.ImageIFD.ImageDescription];
                        }
                    }

                    // Modify or clear Artist
                    if (editedData.artist !== undefined) {
                        if (editedData.artist) {
                            exifObj['0th'][piexif.ImageIFD.Artist] = editedData.artist;
                        } else {
                            delete exifObj['0th'][piexif.ImageIFD.Artist];
                        }
                    }

                    // Modify or clear Copyright
                    if (editedData.copyright !== undefined) {
                        if (editedData.copyright) {
                            exifObj['0th'][piexif.ImageIFD.Copyright] = editedData.copyright;
                        } else {
                            delete exifObj['0th'][piexif.ImageIFD.Copyright];
                        }
                    }

                    // Modify or clear User Comment
                    if (editedData.usercomment !== undefined) {
                        if (editedData.usercomment) {
                            // UserComment requires 8-byte character code prefix
                            exifObj['Exif'][piexif.ExifIFD.UserComment] = USERCOMMENT_CHARSET_PREFIX + editedData.usercomment;
                        } else {
                            delete exifObj['Exif'][piexif.ExifIFD.UserComment];
                        }
                    }

                    // Helper to set or clear GPS coordinate
                    const setGPSCoordinate = (value, coordField, refField, type) => {
                        if (value !== '') {
                            const coord = parseFloat(value);
                            if (!isNaN(coord)) {
                                const ref = getGPSReference(coord, type);
                                const dms = piexif.GPSHelper.degToDmsRational(Math.abs(coord));
                                exifObj['GPS'][coordField] = dms;
                                exifObj['GPS'][refField] = ref;
                            }
                        } else {
                            delete exifObj['GPS'][coordField];
                            delete exifObj['GPS'][refField];
                        }
                    };

                    // Modify or clear GPS Latitude
                    if (editedData.gpslatitude !== undefined) {
                        setGPSCoordinate(
                            editedData.gpslatitude,
                            piexif.GPSIFD.GPSLatitude,
                            piexif.GPSIFD.GPSLatitudeRef,
                            'latitude'
                        );
                    }

                    // Modify or clear GPS Longitude
                    if (editedData.gpslongitude !== undefined) {
                        setGPSCoordinate(
                            editedData.gpslongitude,
                            piexif.GPSIFD.GPSLongitude,
                            piexif.GPSIFD.GPSLongitudeRef,
                            'longitude'
                        );
                    }

                    // If GPS data was set, ensure basic GPS fields are present
                    if (Object.keys(exifObj['GPS']).length > 0) {
                        // Set GPS version to 2.2.0.0
                        if (!exifObj['GPS'][piexif.GPSIFD.GPSVersionID]) {
                            exifObj['GPS'][piexif.GPSIFD.GPSVersionID] = GPS_VERSION;
                        }

                        // Set map datum to WGS-84
                        if (!exifObj['GPS'][piexif.GPSIFD.GPSMapDatum]) {
                            exifObj['GPS'][piexif.GPSIFD.GPSMapDatum] = GPS_MAP_DATUM;
                        }

                        // Set GPS date stamp from DateTimeOriginal if available
                        if (!exifObj['GPS'][piexif.GPSIFD.GPSDateStamp] && editedData.datetimeoriginal) {
                            const datePart = formatDateForExif(editedData.datetimeoriginal).split(' ')[0];
                            exifObj['GPS'][piexif.GPSIFD.GPSDateStamp] = datePart;
                        }

                        // Set GPS tag pointer
                        if (!exifObj['0th'][piexif.ImageIFD.GPSTag]) {
                            exifObj['0th'][piexif.ImageIFD.GPSTag] = 0;
                        }
                    }

                    // Serialize EXIF data
                    const exifBytes = piexif.dump(exifObj);

                    // Log summary of changes
                    const modifiedFields = [];
                    Object.keys(editedData).forEach(key => {
                        const displayName = FIELD_NAME_MAP[key] || key;
                        const action = fieldActions[key] || 'changed';
                        modifiedFields.push(`${displayName} (${action})`);
                    });

                    console.log('[QuickEXIF] Modified EXIF fields:', modifiedFields.join(', '));

                    // Insert EXIF into image
                    const newDataUrl = piexif.insert(exifBytes, dataUrl);

                    // Convert data URL to blob
                    fetch(newDataUrl)
                        .then(res => res.blob())
                        .then(newBlob => resolve(newBlob))
                        .catch(reject);

                } catch (error) {
                    reject(new Error('Failed to modify EXIF data: ' + error.message));
                }
            };

            reader.onerror = function () {
                reject(new Error('Failed to read image file'));
            };

            reader.readAsDataURL(blob);
        });
    }

    /**
     * Upload modified image to Wikimedia Commons.
     * @param {Blob} blob - The image blob to upload.
     * @param {string} customSummary - Optional custom edit summary to append.
     * @param {Object} editedData - The edited field data.
     * @param {Object} fieldActions - Actions taken on each field (added/changed/removed).
     * @returns {Promise<Object>} Promise that resolves to the upload result.
     */
    async function uploadToCommons(blob, customSummary, editedData, fieldActions) {
        if (!blob || blob.size === 0) {
            throw new Error('Invalid image blob for upload');
        }

        if (!currentFileName) {
            throw new Error('File name is not set');
        }

        if (!api) {
            throw new Error('MediaWiki API not initialized');
        }

        try {
            const file = new File([blob], currentFileName, { type: blob.type || 'image/jpeg' });

            // Build field changes summary
            const fieldsByAction = {
                added: new Set(),
                changed: new Set(),
                removed: new Set()
            };

            Object.keys(editedData || {}).forEach(key => {
                const displayName = FIELD_NAME_MAP[key] || key;
                const action = (fieldActions && fieldActions[key]) || 'changed';
                if (fieldsByAction[action]) {
                    fieldsByAction[action].add(displayName);
                }
            });

            const summaryParts = [];
            if (fieldsByAction.added.size > 0) {
                summaryParts.push('Added ' + Array.from(fieldsByAction.added).join(', '));
            }
            if (fieldsByAction.changed.size > 0) {
                summaryParts.push('Updated ' + Array.from(fieldsByAction.changed).join(', '));
            }
            if (fieldsByAction.removed.size > 0) {
                summaryParts.push('Removed ' + Array.from(fieldsByAction.removed).join(', '));
            }

            let editSummary = 'Change EXIF via QuickEXIF';
            if (summaryParts.length > 0) {
                editSummary += ': ' + summaryParts.join('; ');
            }

            if (customSummary && customSummary.trim()) {
                const sanitized = customSummary.trim().substring(0, MAX_EDIT_SUMMARY_LENGTH);
                editSummary += ' – ' + sanitized;
            }

            // Truncate if too long
            if (editSummary.length > MAX_EDIT_SUMMARY_LENGTH) {
                editSummary = editSummary.substring(0, MAX_EDIT_SUMMARY_LENGTH - 3) + '...';
            }

            // Upload using MediaWiki API
            const result = await api.upload(file, {
                filename: currentFileName,
                comment: editSummary,
                tags: 'QuickEXIF',
                ignorewarnings: 1 // Allow overwriting existing file
            });

            if (result && result.upload && result.upload.result === 'Success') {
                return result;
            }

            // Construct detailed error message
            let errorMsg = 'Unknown error';
            if (result && result.upload && result.upload.result) {
                errorMsg = result.upload.result;
                if (result.upload.warnings) {
                    errorMsg += ' (Warnings: ' + JSON.stringify(result.upload.warnings) + ')';
                }
            } else if (result && result.error) {
                errorMsg = result.error.info || result.error.code || 'API error';
            }
            throw new Error('Upload failed: ' + errorMsg);
        } catch (error) {
            if (typeof error === 'string' && error === currentFileName) {
                return { upload: { result: 'Success' } };
            }

            throw new Error('Failed to upload to Commons: ' + (error && error.message ? error.message : error));
        }
    }

    /**
     * Display a loading message to the user.
     * @param {string} message - The message to display.
     */
    function showLoadingState(message) {
        const buttonContainer = document.getElementById('exif-editor-controls');
        if (!buttonContainer) {
            console.warn('[QuickEXIF] Button container not found for loading state');
            return;
        }

        let loadingDiv = document.getElementById('exif-loading');

        // Remove all children except loadingDiv
        Array.from(buttonContainer.children).forEach(child => {
            if (child.id !== 'exif-loading') {
                buttonContainer.removeChild(child);
            }
        });

        if (!loadingDiv) {
            loadingDiv = document.createElement('div');
            loadingDiv.id = 'exif-loading';
            loadingDiv.style.cssText = 'margin-top: 10px; padding: 10px; background: #fef6e7; border: 1px solid #f4d03f; border-radius: 3px; font-style: italic; min-width: 200px; color: #856404;';
            buttonContainer.appendChild(loadingDiv);
        }
        loadingDiv.textContent = message || 'Processing...';
    }

    /**
     * Hide the loading message.
     */
    function hideLoadingState() {
        const loadingDiv = document.getElementById('exif-loading');
        if (loadingDiv) {
            loadingDiv.remove();
        }
    }

    /**
     * Display a success message and reload the page after a delay.
     */
    function showSuccessMessage() {
        hideLoadingState();
        editMode = false;
        const buttonContainer = document.getElementById('exif-editor-controls');

        if (!buttonContainer) {
            console.warn('[QuickEXIF] Button container not found, reloading immediately');
            location.reload();
            return;
        }

        // Remove cancel/save buttons
        const buttons = buttonContainer.querySelectorAll('button');
        buttons.forEach(btn => btn.remove());

        // Show success message
        const messageDiv = document.createElement('div');
        messageDiv.style.cssText = 'margin-top: 10px; padding: 10px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 3px; color: #155724;';
        const strongText = document.createElement('strong');
        strongText.textContent = '✓ Successfully uploaded!';
        messageDiv.appendChild(strongText);
        messageDiv.appendChild(document.createElement('br'));
        messageDiv.appendChild(document.createTextNode('The file has been updated with the new EXIF data. The page will reload in a moment...'));
        
        buttonContainer.appendChild(messageDiv);

        // Reload after a delay to show the updated file
        setTimeout(() => {
            location.reload();
        }, SUCCESS_RELOAD_DELAY);
    }

    // Initialize when the page is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();