- 地图脚本:localStorage 读写走 lsGet/lsSet 兜底(隐私模式下裸访问会打断整段脚本); preview() 在 /api/map-config 还没回来时不丢点(previewQueue 在 if (live) 之前排队); 异步回包一律发序号作废,取消后回来的包不再画 - photo-exif.js:EXIF 时间全 0 不再变成 1899-11-30 写进到访时间;读 count 前先按版本卡宽度 - 数据编辑页:三个筛选表单都把其余条件用隐藏域原样带上,提交不再把别的列表的筛选清掉 - 补全页:sessionStorage 的读写一律包 try(脏数据/隐私模式都会抛) - 窄屏:删掉被后面同特异性规则盖掉的死内边距;宽表堆叠类名成对、判据扩到所有 tb 表 - 无障碍:图片删除按钮补 aria-label,访客画廊图片补 alt - report.html 补上漏掉的 </tr>,fog-mask 的宽表带 tb-stack 本批不碰数据库与导出路径:往返包 823/323/8/30/791/557/2246 全等、 坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。
288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
// 浏览器端读取照片的 EXIF(拍摄时间 + GPS),只读文件头附近的一小段,
|
||
// 不把整张照片传到服务器——几十张手机照片动辄几百 MB,上传既慢又占磁盘。
|
||
//
|
||
// 支持的容器:JPEG(APP1 段)与 HEIC/HEIF(ISO BMFF 的 meta box)。
|
||
// 算法与 internal/exif 的 Go 实现一致,但这里必须分片读取:
|
||
// JPEG 的 APP1 在最前面,通常 < 64KB;
|
||
// HEIC 的 Exif item 位置由 iloc 指定(绝对偏移),可能落在文件靠后,
|
||
// 所以先读 64KB 解析出偏移,必要时再按需读那一小段。
|
||
(function () {
|
||
'use strict';
|
||
|
||
var HEAD_BYTES = 64 * 1024;
|
||
|
||
function readRange(file, start, end) {
|
||
var blob = file.slice(start, Math.min(end, file.size));
|
||
return blob.arrayBuffer().then(function (buf) {
|
||
return new Uint8Array(buf);
|
||
});
|
||
}
|
||
|
||
function str(u8, off, len) {
|
||
var s = '';
|
||
for (var i = 0; i < len && off + i < u8.length; i++) s += String.fromCharCode(u8[off + i]);
|
||
return s;
|
||
}
|
||
|
||
// ---------- TIFF(JPEG 与 HEIC 共用,HEIC 里是 Exif item 的负载) ----------
|
||
|
||
function readIFD(dv, u8, base, off, le) {
|
||
var out = {};
|
||
if (off < 0 || base + off + 2 > u8.length) return out;
|
||
var n = dv.getUint16(base + off, le);
|
||
var p0 = base + off + 2;
|
||
if (p0 + n * 12 > u8.length) n = Math.max(0, Math.floor((u8.length - p0) / 12));
|
||
for (var i = 0; i < n; i++) {
|
||
var p = p0 + i * 12;
|
||
out[dv.getUint16(p, le)] = {
|
||
type: dv.getUint16(p + 2, le),
|
||
count: dv.getUint32(p + 4, le),
|
||
valOff: p + 8
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function typeSize(t) {
|
||
switch (t) {
|
||
case 1: case 2: case 6: case 7: return 1;
|
||
case 3: case 8: return 2;
|
||
case 4: case 9: case 11: return 4;
|
||
case 5: case 10: case 12: return 8;
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
// ascii 读 ASCII 值:总长 ≤ 4 字节时内联在条目里,否则 valOff 是偏移
|
||
function ascii(dv, u8, base, e, le) {
|
||
var total = e.count;
|
||
if (total <= 0) return '';
|
||
var start;
|
||
if (total <= 4) {
|
||
start = e.valOff;
|
||
} else {
|
||
start = base + dv.getUint32(e.valOff, le);
|
||
}
|
||
var out = '';
|
||
for (var i = 0; i < total && start + i < u8.length; i++) {
|
||
var c = u8[start + i];
|
||
if (c === 0) break;
|
||
out += String.fromCharCode(c);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function rationals(dv, u8, base, e, le, n) {
|
||
var off = base + dv.getUint32(e.valOff, le);
|
||
if (off < 0 || off + n * 8 > u8.length) return null;
|
||
var out = [];
|
||
for (var i = 0; i < n; i++) {
|
||
var num = dv.getUint32(off + i * 8, le);
|
||
var den = dv.getUint32(off + i * 8 + 4, le);
|
||
if (den === 0) return null;
|
||
out.push(num / den);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// gpsCoord 把「度分秒 + 半球」拼成十进制度
|
||
function gpsCoord(dv, u8, base, le, gps, refTag, valTag) {
|
||
var refE = gps[refTag], valE = gps[valTag];
|
||
if (!refE || !valE) return null;
|
||
var parts = rationals(dv, u8, base, valE, le, 3);
|
||
if (!parts) return null;
|
||
var v = parts[0] + parts[1] / 60 + parts[2] / 3600;
|
||
var ref = ascii(dv, u8, base, refE, le).toUpperCase();
|
||
if (ref === 'S' || ref === 'W') v = -v;
|
||
return v;
|
||
}
|
||
|
||
function parseTIFF(u8, base) {
|
||
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||
if (base + 8 > u8.length) throw new Error('EXIF 数据不完整');
|
||
var mark = str(u8, base, 2);
|
||
if (mark !== 'II' && mark !== 'MM') throw new Error('EXIF 标识不对');
|
||
var le = mark === 'II';
|
||
if (dv.getUint16(base + 2, le) !== 0x002A) throw new Error('EXIF 标识不对');
|
||
|
||
var res = { hasGPS: false, hasTime: false, lat: 0, lon: 0, shot: null };
|
||
var ifd0 = readIFD(dv, u8, base, dv.getUint32(base + 4, le), le);
|
||
|
||
if (ifd0[0x8769]) {
|
||
var sub = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8769].valOff, le), le);
|
||
if (sub[0x9003]) {
|
||
var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim());
|
||
if (m) {
|
||
// 全 0 的「0000:00:00 00:00:00」会被 Date 归一成 1899-11-30,然后被当成拍摄时间
|
||
// 写进到访时间;字段超范围一律当「没有时间」,别把垃圾时间带进库。
|
||
var Y = +m[1], Mo = +m[2], D = +m[3], H = +m[4], Mi = +m[5], S = +m[6];
|
||
if (Y >= 1900 && Y <= 2200 && Mo >= 1 && Mo <= 12 && D >= 1 && D <= 31 &&
|
||
H <= 23 && Mi <= 59 && S <= 59) {
|
||
res.shot = new Date(Y, Mo - 1, D, H, Mi, S);
|
||
res.hasTime = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (ifd0[0x8825]) {
|
||
var gps = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8825].valOff, le), le);
|
||
var la = gpsCoord(dv, u8, base, le, gps, 1, 2);
|
||
var lo = gpsCoord(dv, u8, base, le, gps, 3, 4);
|
||
if (la !== null && lo !== null && (la !== 0 || lo !== 0)) {
|
||
res.lat = la; res.lon = lo; res.hasGPS = true;
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// ---------- JPEG ----------
|
||
|
||
function parseJPEG(u8) {
|
||
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||
var i = 2;
|
||
while (i + 4 <= u8.length) {
|
||
if (u8[i] !== 0xFF) { i++; continue; }
|
||
var marker = u8[i + 1];
|
||
if (marker === 0xD8 || marker === 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { i += 2; continue; }
|
||
if (marker === 0xDA || marker === 0xD9) break; // 图像数据开始
|
||
var size = (u8[i + 2] << 8) | u8[i + 3];
|
||
if (size < 2 || i + 2 + size > u8.length) break;
|
||
if (marker === 0xE1 && size > 8 && str(u8, i + 4, 6) === 'Exif\x00\x00') {
|
||
return parseTIFF(u8, i + 10);
|
||
}
|
||
i += 2 + size;
|
||
}
|
||
throw new Error('这张 JPEG 里没有 EXIF 信息');
|
||
}
|
||
|
||
// ---------- HEIC / HEIF ----------
|
||
|
||
function boxHeader(dv, u8, off) {
|
||
if (off + 8 > u8.length) return null;
|
||
var size = dv.getUint32(off);
|
||
var hdr = 8;
|
||
if (size === 1) {
|
||
if (off + 16 > u8.length) return null;
|
||
size = Number(dv.getBigUint64(off + 8));
|
||
hdr = 16;
|
||
} else if (size === 0) {
|
||
size = u8.length - off;
|
||
}
|
||
if (size < hdr) return null;
|
||
// next 允许超出已读范围:大文件的 meta 往往只读到头部,后面的数据按需再取
|
||
return { typ: str(u8, off + 4, 4), payload: off + hdr, next: off + size };
|
||
}
|
||
|
||
function findExifItemID(dv, u8, start, end) {
|
||
if (start + 4 > u8.length) return 0;
|
||
var ver = u8[start];
|
||
var p = start + 4;
|
||
// 末尾恰好相等时(start+4 == length)上面那条守卫会放行,紧接着的 getUint16/32
|
||
// 就越界抛 RangeError(用户看到一句英文的 DataView 报错)。读之前把宽度也卡住。
|
||
if (ver === 0 ? p + 2 > u8.length : p + 4 > u8.length) return 0;
|
||
var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p);
|
||
p += ver === 0 ? 2 : 4;
|
||
for (var i = 0; i < count; i++) {
|
||
var b = boxHeader(dv, u8, p);
|
||
if (!b) break;
|
||
if (b.typ === 'infe' && b.payload < u8.length && u8[b.payload] >= 2 && b.payload + 12 <= u8.length) {
|
||
if (str(u8, b.payload + 8, 4) === 'Exif') return dv.getUint16(b.payload + 4);
|
||
}
|
||
p = b.next;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function findItemExtent(dv, u8, start, wantID) {
|
||
if (start + 8 > u8.length) return null;
|
||
var ver = u8[start];
|
||
var p = start + 4;
|
||
var offsetSize = u8[p] >> 4, lengthSize = u8[p] & 0x0F;
|
||
p++;
|
||
var baseSize = u8[p] >> 4, indexSize = ver >= 1 ? (u8[p] & 0x0F) : 0;
|
||
p++;
|
||
if (!offsetSize || !lengthSize) return null;
|
||
var count = dv.getUint16(p);
|
||
p += 2;
|
||
function readN(n) {
|
||
if (n < 0 || p + n > u8.length) return null;
|
||
var v = 0;
|
||
for (var i = 0; i < n; i++) v = v * 256 + u8[p + i];
|
||
p += n;
|
||
return v;
|
||
}
|
||
for (var i = 0; i < count; i++) {
|
||
var id = ver === 0 ? readN(2) : readN(4);
|
||
if (id === null) return null;
|
||
if (ver >= 1 && readN(2) === null) return null;
|
||
if (readN(2) === null) return null; // data_reference_index
|
||
var base = readN(baseSize);
|
||
if (base === null) return null;
|
||
var extents = readN(2);
|
||
if (extents === null) return null;
|
||
for (var e = 0; e < extents; e++) {
|
||
if (ver >= 1 && indexSize > 0 && readN(indexSize) === null) return null;
|
||
var off = readN(offsetSize), len = readN(lengthSize);
|
||
if (off === null || len === null) return null;
|
||
if (id === wantID) return { offset: base + off, length: len };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseHEIF(file, u8) {
|
||
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||
var meta = null;
|
||
for (var off = 0; off + 8 <= u8.length;) {
|
||
var b = boxHeader(dv, u8, off);
|
||
if (!b) break;
|
||
if (b.typ === 'meta') { meta = b; break; }
|
||
off = b.next;
|
||
}
|
||
if (!meta) throw new Error('HEIC 文件里没有找到 meta');
|
||
var start = meta.payload + 4; // meta 是 FullBox
|
||
|
||
var iinf = null, iloc = null;
|
||
for (var o = start; o + 8 <= u8.length && o < meta.next;) {
|
||
var sb = boxHeader(dv, u8, o);
|
||
if (!sb) break;
|
||
if (sb.typ === 'iinf') iinf = sb;
|
||
if (sb.typ === 'iloc') iloc = sb;
|
||
o = sb.next;
|
||
}
|
||
if (!iinf || !iloc) throw new Error('HEIC 里缺少 iinf/iloc');
|
||
|
||
var itemID = findExifItemID(dv, u8, iinf.payload, iinf.next);
|
||
if (!itemID) throw new Error('这张 HEIC 里没有 EXIF');
|
||
var ext = findItemExtent(dv, u8, iloc.payload, itemID);
|
||
if (!ext || ext.length <= 8) throw new Error('HEIC 的 EXIF 位置无效');
|
||
|
||
function finish(bytes) {
|
||
var tiff = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
|
||
if (tiff + 8 > bytes.length) tiff = 4;
|
||
return parseTIFF(bytes, tiff);
|
||
}
|
||
// Exif item 已经读进来了就直接解析,否则按偏移再取那一小段
|
||
if (ext.offset >= 0 && ext.offset + ext.length <= u8.length) {
|
||
return finish(u8.subarray(ext.offset, ext.offset + ext.length));
|
||
}
|
||
return readRange(file, ext.offset, ext.offset + ext.length).then(finish);
|
||
}
|
||
|
||
// ---------- 对外入口 ----------
|
||
|
||
function read(file) {
|
||
return readRange(file, 0, HEAD_BYTES).then(function (head) {
|
||
if (head.length >= 2 && head[0] === 0xFF && head[1] === 0xD8) {
|
||
return Promise.resolve(parseJPEG(head));
|
||
}
|
||
if (head.length >= 12 && str(head, 4, 4) === 'ftyp') {
|
||
return parseHEIF(file, head);
|
||
}
|
||
return Promise.reject(new Error('不认识的图片格式(支持 JPEG 与 HEIC/HEIF)'));
|
||
});
|
||
}
|
||
|
||
window.rondExif = { read: read };
|
||
})();
|