commit a83111a2ebfe4bff0aea343d4eb3477996dc57a1 Author: icezhb <860435387@qq.com> Date: Fri Jan 3 19:07:17 2025 +0800 Initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..45ce2ea --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Image EXIF Reader + +一个简单的图片 EXIF 数据读取器,支持读取 JPEG、TIFF 等格式图片的 EXIF 元数据。 + +## 功能特点 + +- 支持多种图片格式:JPEG/JPG、TIFF、HEIC/HEIF、PNG、WebP +- 读取常见 EXIF 数据:相机信息、拍摄参数、镜头信息等 +- 简单易用的 API +- 支持浏览器直接使用 +- 轻量级,无依赖 + +## 快速开始 + +### 1. 直接使用 + +html + + + + + +
+ +### 2. NPM 安装 + +bash + +npm install image-exif-reader + +javascript + +import ImageExifReader from 'image-exif-reader'; + +const reader = new ImageExifReader(); +function handleImageUpload(file) { +reader.readExifData(file, (error, exifData) => { +if (error) { +console.error('读取失败:', error); +return; +} +console.log('EXIF 数据:', exifData); +}); +} + +## API 文档 + +### ImageExifReader + +#### readExifData(file, callback) + +读取图片的 EXIF 数据。 + +参数: + +- file: File - 图片文件对象 +- callback: Function(error, exifData) + - error: Error | null - 错误信息 + - exifData: Object - EXIF 数据对象 + +返回数据示例: + +javascript +{ +Make: "Panasonic", +Model: "DC-G9", +DateTime: "2022-03-26 18:20:19", +ExposureTime: "1/125", +FNumber: "f/1.7", +FocalLength: "25mm", +ISOSpeedRatings: 400, +LensModel: "LUMIX G 25mm F1.7" +} + +## 支持的 EXIF 标签 + +### 基本信息 + +- Make: 相机制造商 +- Model: 相机型号 +- DateTime: 拍摄时间 +- Software: 软件信息 + +### 拍摄参数 + +- ExposureTime: 曝光时间 +- FNumber: 光圈值 +- ISOSpeedRatings: ISO 感光度 +- FocalLength: 焦距 +- ExposureMode: 曝光模式 +- WhiteBalance: 白平衡 + +### 镜头信息 + +- LensModel: 镜头型号 +- LensSerialNumber: 镜头序列号 +- FocalLengthIn35mmFilm: 35mm 等效焦距 + +### 图像信息 + +- XResolution: X 轴分辨率 +- YResolution: Y 轴分辨率 +- ResolutionUnit: 分辨率单位 + +## 开发 + +bash + +安装依赖 +npm install +构建项目 +npm run build +运行示例 +npm run dev +运行测试 +npm test + +## 示例 + +查看 `examples` 目录中的完整示例代码。 + +## 浏览器兼容性 + +- Chrome 50+ +- Firefox 50+ +- Safari 11+ +- Edge 18+ + +## 许可证 + +MIT + +## 作者 + +[你的名字] + +## 贡献 + +欢迎提交 Issue 和 Pull Request。 + +## 更新日志 + +### 1.0.0 + +- 初始版本发布 +- 支持 JPEG/TIFF 格式 +- 基础 EXIF 数据读取 diff --git a/examples/app.js b/examples/app.js new file mode 100644 index 0000000..75b9a6c --- /dev/null +++ b/examples/app.js @@ -0,0 +1,4 @@ +document.addEventListener('DOMContentLoaded', function() { + const exifReader = new ImageExifReader(); + // ... 保持原有代码 ... +}); \ No newline at end of file diff --git a/examples/demo.html b/examples/demo.html new file mode 100644 index 0000000..d4e72f6 --- /dev/null +++ b/examples/demo.html @@ -0,0 +1,22 @@ + + + + EXIF Reader Demo + + + +
+ + + + + \ No newline at end of file diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..2f61328 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,21 @@ + + + + + EXIF 读取器示例 + + + +
+

EXIF 读取器示例

+ +
+
+
+ + + + + \ No newline at end of file diff --git a/imageExifReader.js b/imageExifReader.js new file mode 100644 index 0000000..543d0b1 --- /dev/null +++ b/imageExifReader.js @@ -0,0 +1,486 @@ +(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; +})); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8f605f7 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "image-exif-reader", + "version": "1.0.0", + "description": "一个简单的图片 EXIF 数据读取器", + "main": "dist/imageExifReader.min.js", + "module": "src/imageExifReader.js", + "types": "types/index.d.ts", + "scripts": { + "build": "webpack --mode production", + "prepare": "npm run build" + }, + "files": [ + "dist", + "src", + "types" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/yourusername/image-exif-reader.git" + }, + "keywords": ["exif", "image", "metadata"], + "author": "zhengice", + "license": "MIT", + "devDependencies": { + "webpack": "^5.89.0", + "webpack-cli": "^5.1.4" + } +} \ No newline at end of file diff --git a/src/imageExifReader.js b/src/imageExifReader.js new file mode 100644 index 0000000..9dffa68 --- /dev/null +++ b/src/imageExifReader.js @@ -0,0 +1,135 @@ +class ImageExifReader { + constructor() { + this.tags = { + // 基本 EXIF 标签 + 0x0100: "ImageWidth", + 0x0101: "ImageHeight", + 0x0112: "Orientation", + 0x0132: "DateTime", + 0x010F: "Make", + 0x0110: "Model", + 0x0128: "ResolutionUnit", + 0x011A: "XResolution", + 0x011B: "YResolution", + 0x0213: "YCbCrPositioning", + 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' + }; + } + + readExifData(file, callback) { + try { + const reader = new FileReader(); + reader.onerror = () => callback(new Error('文件读取失败')); + reader.onload = (e) => { + try { + const exifData = this.parseExif(e.target.result, file.type); + callback(null, exifData); + } catch (error) { + callback(error); + } + }; + reader.readAsArrayBuffer(file); + } catch (error) { + callback(error); + } + } + + parseExif(arrayBuffer, fileType) { + const dataView = new DataView(arrayBuffer); + const exifData = {}; + + try { + switch (fileType) { + case 'image/jpeg': + case 'image/jpg': + if (dataView.getUint16(0, false) !== 0xFFD8) { + throw new Error('不是有效的 JPEG 图片'); + } + + let offset = 2; + while (offset < dataView.byteLength) { + 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; + } + + return this.validateData(exifData) ? exifData : {}; + } catch (error) { + return {}; + } + } + + // ... 其他方法保持不变,但删除所有 console 输出 ... +} + +// 支持多种模块系统 +if (typeof module !== 'undefined' && module.exports) { + module.exports = ImageExifReader; +} + +export default ImageExifReader; + +if (typeof window !== 'undefined') { + window.ImageExifReader = ImageExifReader; +} \ No newline at end of file diff --git a/test.html b/test.html new file mode 100644 index 0000000..6877877 --- /dev/null +++ b/test.html @@ -0,0 +1,38 @@ + + + + + EXIF 读取器测试 + + + +
+

EXIF 读取器测试

+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/test.js b/test.js new file mode 100644 index 0000000..70df35b --- /dev/null +++ b/test.js @@ -0,0 +1,6 @@ +const ImageExifReader = require('image-exif-reader'); +// 或 +import ImageExifReader from 'image-exif-reader'; + +const reader = new ImageExifReader(); +console.log('插件加载成功'); \ No newline at end of file diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..9057cae --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,18 @@ +interface ExifData { + Make?: string; + Model?: string; + DateTime?: string; + ExposureTime?: string; + FNumber?: string; + FocalLength?: string; + ISOSpeedRatings?: number; + LensModel?: string; + [key: string]: any; +} + +declare class ImageExifReader { + constructor(); + readExifData(file: File, callback: (error: Error | null, data?: ExifData) => void): void; +} + +export default ImageExifReader; \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..9e77434 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,15 @@ +const path = require('path'); + +module.exports = { + entry: './src/imageExifReader.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'imageExifReader.min.js', + library: { + name: 'ImageExifReader', + type: 'umd', + export: 'default' + }, + globalObject: 'this' + } +}; \ No newline at end of file