486 lines
18 KiB
JavaScript
486 lines
18 KiB
JavaScript
(function (root, factory) {
|
|
if (typeof define === 'function' && define.amd) {
|
|
// AMD
|
|
define([], factory);
|
|
} else if (typeof module === 'object' && module.exports) {
|
|
// Node.js
|
|
module.exports = factory();
|
|
} else {
|
|
// Browser globals
|
|
root.ImageExifReader = factory();
|
|
}
|
|
}(typeof self !== 'undefined' ? self : this, function () {
|
|
|
|
function ImageExifReader() {
|
|
this.debug = true;
|
|
this.tags = {
|
|
// 基本 EXIF 标签
|
|
0x0100: "ImageWidth",
|
|
0x0101: "ImageHeight",
|
|
0x0112: "Orientation",
|
|
0x0132: "DateTime",
|
|
0x010F: "Make",
|
|
0x0110: "Model",
|
|
// 富士相机特有的标签
|
|
0x0128: "ResolutionUnit",
|
|
0x011A: "XResolution",
|
|
0x011B: "YResolution",
|
|
0x0213: "YCbCrPositioning",
|
|
// EXIF IFD 指针和常用标签
|
|
0x8769: "ExifIFDPointer",
|
|
0x829A: "ExposureTime",
|
|
0x829D: "FNumber",
|
|
0x8827: "ISOSpeedRatings",
|
|
0x9003: "DateTimeOriginal",
|
|
0x9004: "DateTimeDigitized",
|
|
0x920A: "FocalLength",
|
|
0xA402: "ExposureMode",
|
|
0xA403: "WhiteBalance",
|
|
0xA406: "SceneCaptureType",
|
|
0x9286: "UserComment",
|
|
0xA405: "FocalLengthIn35mmFilm",
|
|
0xA420: "ImageUniqueID",
|
|
0xA431: "SerialNumber",
|
|
0xA432: "LensInfo",
|
|
0xA433: "LensMake",
|
|
0xA434: "LensModel",
|
|
0xA435: "LensSerialNumber"
|
|
};
|
|
|
|
// 添加标签类型定义
|
|
this.tagTypes = {
|
|
Make: 'string',
|
|
Model: 'string',
|
|
DateTime: 'datetime',
|
|
DateTimeOriginal: 'datetime',
|
|
DateTimeDigitized: 'datetime',
|
|
ExposureTime: 'exposuretime',
|
|
FNumber: 'fnumber',
|
|
FocalLength: 'focallength',
|
|
ISOSpeedRatings: 'number',
|
|
LensModel: 'string',
|
|
LensSerialNumber: 'string',
|
|
XResolution: 'resolution',
|
|
YResolution: 'resolution'
|
|
};
|
|
}
|
|
|
|
ImageExifReader.prototype.readExifData = function(file, callback) {
|
|
// 支持的图片类型
|
|
const supportedTypes = [
|
|
'image/jpeg',
|
|
'image/jpg',
|
|
'image/tiff',
|
|
'image/heic',
|
|
'image/heif',
|
|
'image/png',
|
|
'image/webp'
|
|
];
|
|
|
|
if (!supportedTypes.some(type => file.type.startsWith(type))) {
|
|
callback(new Error(`不支持的文件类型: ${file.type}`));
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const exifData = this.parseExif(e.target.result, file.type);
|
|
callback(null, exifData);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
};
|
|
reader.onerror = (error) => {
|
|
callback(error);
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
};
|
|
|
|
ImageExifReader.prototype.parseExif = function(arrayBuffer, fileType) {
|
|
const dataView = new DataView(arrayBuffer);
|
|
let offset = 0;
|
|
const length = dataView.byteLength;
|
|
let exifData = {};
|
|
|
|
try {
|
|
switch (fileType) {
|
|
case 'image/jpeg':
|
|
case 'image/jpg':
|
|
if (dataView.getUint16(0, false) !== 0xFFD8) {
|
|
throw new Error('不是有效的 JPEG 图片');
|
|
}
|
|
|
|
// 搜索 APP1 标记
|
|
offset = 2;
|
|
while (offset < length) {
|
|
const marker = dataView.getUint16(offset, false);
|
|
offset += 2;
|
|
|
|
if (marker === 0xFFE1) {
|
|
const segmentLength = dataView.getUint16(offset, false);
|
|
const exifHeader = this.getStringFromBuffer(dataView, offset + 2, 4);
|
|
|
|
if (exifHeader === 'Exif') {
|
|
const tiffOffset = offset + 8;
|
|
const byteOrder = dataView.getUint16(tiffOffset, false);
|
|
const bigEnd = byteOrder === 0x4D4D;
|
|
|
|
if (dataView.getUint16(tiffOffset + 2, !bigEnd) === 42) {
|
|
const firstIFDOffset = dataView.getUint32(tiffOffset + 4, !bigEnd);
|
|
if (firstIFDOffset > 0) {
|
|
this.readIFD(dataView, tiffOffset + firstIFDOffset, !bigEnd, exifData);
|
|
}
|
|
}
|
|
}
|
|
|
|
offset += segmentLength;
|
|
} else if (marker === 0xFFDA) {
|
|
break; // 到达图像数据
|
|
} else {
|
|
offset += dataView.getUint16(offset, false);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'image/tiff':
|
|
this.parseTiffHeader(dataView, exifData);
|
|
break;
|
|
|
|
case 'image/heic':
|
|
case 'image/heif':
|
|
// HEIF 文件以 ftyp 开头
|
|
const brand = this.getStringFromBuffer(dataView, 4, 4);
|
|
if (!['heic', 'heix', 'hevc', 'hevx'].includes(brand)) {
|
|
throw new Error('不是有效的 HEIC/HEIF 图片');
|
|
}
|
|
this.parseHeicMetadata(dataView, exifData);
|
|
return exifData;
|
|
|
|
case 'image/png':
|
|
// PNG 文件以 89 50 4E 47 0D 0A 1A 0A 开头
|
|
if (dataView.getUint32(0, false) !== 0x89504E47) {
|
|
throw new Error('不是有效的 PNG 图片');
|
|
}
|
|
this.parsePngMetadata(dataView, exifData);
|
|
return exifData;
|
|
|
|
case 'image/webp':
|
|
// WebP 文件以 RIFF....WEBP 开头
|
|
const webpHeader = this.getStringFromBuffer(dataView, 0, 4);
|
|
if (webpHeader !== 'RIFF') {
|
|
throw new Error('不是有效的 WebP 图片');
|
|
}
|
|
this.parseWebpMetadata(dataView, exifData);
|
|
return exifData;
|
|
|
|
default:
|
|
throw new Error(`不支持的文件类型: ${fileType}`);
|
|
}
|
|
|
|
return exifData;
|
|
} catch (error) {
|
|
console.error('解析元数据时出错:', error);
|
|
return exifData;
|
|
}
|
|
};
|
|
|
|
ImageExifReader.prototype.parseTiffHeader = function(dataView, exifData) {
|
|
try {
|
|
// 检查字节序
|
|
const byteOrder = dataView.getUint16(0, false);
|
|
const bigEnd = byteOrder === 0x4D4D; // 'MM' = big endian
|
|
|
|
// 验证 TIFF 版本号 (应该是 42)
|
|
const tiffMagic = dataView.getUint16(2, !bigEnd);
|
|
if (tiffMagic !== 42) {
|
|
throw new Error('无效的 TIFF 版本号');
|
|
}
|
|
|
|
// 获取第一个 IFD 的偏移量
|
|
const ifdOffset = dataView.getUint32(4, !bigEnd);
|
|
if (ifdOffset < 8 || ifdOffset > dataView.byteLength) {
|
|
throw new Error('无效的 IFD 偏移量');
|
|
}
|
|
|
|
// 读取 IFD
|
|
this.readIFD(dataView, ifdOffset, !bigEnd, exifData);
|
|
|
|
return exifData;
|
|
} catch (error) {
|
|
console.error('解析 TIFF 头部时出错:', error);
|
|
return exifData;
|
|
}
|
|
};
|
|
|
|
ImageExifReader.prototype.parseHeicMetadata = function(dataView, exifData) {
|
|
// HEIC/HEIF 解析逻辑
|
|
};
|
|
|
|
ImageExifReader.prototype.parsePngMetadata = function(dataView, exifData) {
|
|
// PNG 解析逻辑
|
|
};
|
|
|
|
ImageExifReader.prototype.parseWebpMetadata = function(dataView, exifData) {
|
|
// WebP 解析逻辑
|
|
};
|
|
|
|
ImageExifReader.prototype.readIFD = function(dataView, startOffset, bigEnd, exifData) {
|
|
try {
|
|
const entries = dataView.getUint16(startOffset, bigEnd);
|
|
console.log(`IFD 位置: ${startOffset}, 条目数: ${entries}`);
|
|
|
|
if (entries > 50 || entries === 0) {
|
|
console.warn(`可疑的 IFD 条目数量: ${entries}`);
|
|
return;
|
|
}
|
|
|
|
let offset = startOffset + 2;
|
|
const baseOffset = startOffset - 8;
|
|
|
|
for (let i = 0; i < entries; i++) {
|
|
const tag = dataView.getUint16(offset, bigEnd);
|
|
const type = dataView.getUint16(offset + 2, bigEnd);
|
|
const numValues = dataView.getUint32(offset + 4, bigEnd);
|
|
const valueOffset = dataView.getUint32(offset + 8, bigEnd);
|
|
|
|
if (type < 1 || type > 12 || numValues > 65535 || valueOffset > dataView.byteLength) {
|
|
offset += 12;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (this.tags[tag]) {
|
|
const value = this.readTagValue(dataView, type, numValues, valueOffset, bigEnd, offset, tag);
|
|
if (value !== undefined) {
|
|
exifData[this.tags[tag]] = value;
|
|
console.log(`读取标签 ${this.tags[tag]}: ${value}`);
|
|
}
|
|
}
|
|
|
|
if (tag === 0x8769) {
|
|
console.log('找到 EXIF IFD 指针,继续解析...');
|
|
const exifOffset = baseOffset + valueOffset;
|
|
if (exifOffset > 0 && exifOffset < dataView.byteLength) {
|
|
this.readIFD(dataView, exifOffset, bigEnd, exifData);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`处理标签 0x${tag.toString(16)} 时出错:`, e);
|
|
}
|
|
|
|
offset += 12;
|
|
}
|
|
} catch (e) {
|
|
console.error('解析 IFD 时出错:', e);
|
|
}
|
|
};
|
|
|
|
ImageExifReader.prototype.readTagValue = function(dataView, type, numValues, valueOffset, bigEnd, baseOffset, tag) {
|
|
try {
|
|
const tagName = this.tags[tag];
|
|
const tagType = this.tagTypes[tagName];
|
|
const tiffOffset = 12;
|
|
|
|
let actualOffset;
|
|
if (type === 2) {
|
|
actualOffset = numValues <= 4 ? baseOffset + 8 : tiffOffset + valueOffset;
|
|
} else if (type === 5) {
|
|
actualOffset = tiffOffset + valueOffset;
|
|
} else {
|
|
actualOffset = numValues <= 4 ? baseOffset + 8 : tiffOffset + valueOffset;
|
|
}
|
|
|
|
let value;
|
|
switch (type) {
|
|
case 2: // ascii string
|
|
if (numValues <= 4) {
|
|
const bytes = new Uint8Array(4);
|
|
for (let i = 0; i < 4; i++) {
|
|
bytes[i] = (valueOffset >> (i * 8)) & 0xFF;
|
|
}
|
|
value = this.decodeString(bytes.slice(0, numValues - 1));
|
|
} else {
|
|
value = this.getStringFromBuffer(dataView, actualOffset, numValues - 1);
|
|
}
|
|
break;
|
|
|
|
case 5: // unsigned rational
|
|
try {
|
|
const numerator = dataView.getUint32(actualOffset, bigEnd);
|
|
const denominator = dataView.getUint32(actualOffset + 4, bigEnd);
|
|
if (denominator === 0) {
|
|
return undefined;
|
|
}
|
|
value = numerator / denominator;
|
|
|
|
switch (tagName) {
|
|
case 'FocalLength':
|
|
if (value > 0 && value < 1000) {
|
|
value = Math.round(value);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
break;
|
|
case 'FNumber':
|
|
if (value > 0) {
|
|
value = Math.round(value * 10) / 10;
|
|
} else {
|
|
return undefined;
|
|
}
|
|
break;
|
|
case 'ExposureTime':
|
|
if (value > 0) {
|
|
if (value < 1) {
|
|
const denomRounded = Math.round(1/value);
|
|
value = `1/${denomRounded}`;
|
|
} else {
|
|
value = value.toFixed(1);
|
|
}
|
|
} else {
|
|
return undefined;
|
|
}
|
|
break;
|
|
case 'XResolution':
|
|
case 'YResolution':
|
|
if (value > 0) {
|
|
value = Math.round(value);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
console.error('读取 Rational 值时出错:', e);
|
|
return undefined;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
value = this.readBasicType(dataView, type, numValues, actualOffset, bigEnd);
|
|
}
|
|
|
|
if (value !== undefined && value !== '' && tagType) {
|
|
switch (tagType) {
|
|
case 'datetime':
|
|
if (typeof value === 'string' && value.length >= 19) {
|
|
const match = value.match(/(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/);
|
|
if (match) {
|
|
value = `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}:${match[6]}`;
|
|
} else {
|
|
return undefined;
|
|
}
|
|
} else {
|
|
return undefined;
|
|
}
|
|
break;
|
|
case 'fnumber':
|
|
value = `f/${value}`;
|
|
break;
|
|
case 'focallength':
|
|
value = `${value}mm`;
|
|
break;
|
|
case 'string':
|
|
value = this.cleanString(value);
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return value || undefined;
|
|
} catch (error) {
|
|
console.error('读取标签值时出错:', error, {
|
|
tag: `0x${tag.toString(16)}`,
|
|
tagName,
|
|
type,
|
|
numValues,
|
|
valueOffset
|
|
});
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
ImageExifReader.prototype.readBasicType = function(dataView, type, numValues, offset, bigEnd) {
|
|
switch (type) {
|
|
case 1: // unsigned byte
|
|
if (numValues === 1) {
|
|
return dataView.getUint8(offset);
|
|
}
|
|
var values = new Array(numValues);
|
|
for (var i = 0; i < numValues; i++) {
|
|
values[i] = dataView.getUint8(offset + i);
|
|
}
|
|
return values;
|
|
case 3: // unsigned short
|
|
if (numValues === 1) {
|
|
return dataView.getUint16(offset, bigEnd);
|
|
}
|
|
var values = new Array(numValues);
|
|
for (var i = 0; i < numValues; i++) {
|
|
values[i] = dataView.getUint16(offset + i * 2, bigEnd);
|
|
}
|
|
return values;
|
|
case 4: // unsigned long
|
|
if (numValues === 1) {
|
|
return dataView.getUint32(offset, bigEnd);
|
|
}
|
|
var values = new Array(numValues);
|
|
for (var i = 0; i < numValues; i++) {
|
|
values[i] = dataView.getUint32(offset + i * 4, bigEnd);
|
|
}
|
|
return values;
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
ImageExifReader.prototype.getStringFromBuffer = function(dataView, start, length) {
|
|
try {
|
|
const bytes = [];
|
|
for (let i = 0; i < length; i++) {
|
|
const byte = dataView.getUint8(start + i);
|
|
if (byte === 0) break;
|
|
bytes.push(byte);
|
|
}
|
|
return this.decodeString(new Uint8Array(bytes));
|
|
} catch (error) {
|
|
console.error('读取字符串时出错:', error, {start, length});
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// 改进字符串清理方法
|
|
ImageExifReader.prototype.cleanString = function(str) {
|
|
if (typeof str !== 'string') return '';
|
|
// 移除控制字符、不可打印字符和特殊字符
|
|
return str.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
|
|
.replace(/[^\x20-\x7E\u4E00-\u9FFF]/g, '') // 只保留基本ASCII和中文字符
|
|
.replace(/\u0000/g, '')
|
|
.trim();
|
|
};
|
|
|
|
// 改进字符串解码方法
|
|
ImageExifReader.prototype.decodeString = function(bytes) {
|
|
try {
|
|
// 兼容性更好的字符串解码
|
|
return bytes.reduce((str, byte) => {
|
|
return str + String.fromCharCode(byte);
|
|
}, '');
|
|
} catch (e) {
|
|
console.error('字符串解码失败:', e);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// 添加一个辅助方法来检查数据块的有效性
|
|
ImageExifReader.prototype.isValidChunk = function(offset, length, totalLength) {
|
|
return offset >= 0 &&
|
|
length > 0 &&
|
|
offset + length <= totalLength &&
|
|
length < totalLength;
|
|
};
|
|
|
|
return ImageExifReader;
|
|
}));
|