UNPKG

exifr

Version:

📷 The fastest and most versatile JavaScript EXIF reading library.

427 lines (381 loc) • 15.2 kB
import {AppSegmentParserBase} from '../parser.mjs' import {segmentParsers} from '../plugins.mjs' import {TAG_IFD_EXIF, TAG_IFD_GPS, TAG_IFD_INTEROP, TAG_MAKERNOTE, TAG_USERCOMMENT, TAG_XMP, TAG_IPTC, TAG_ICC} from '../tags.mjs' import {TAG_GPS_LATREF, TAG_GPS_LAT, TAG_GPS_LONREF, TAG_GPS_LON} from '../tags.mjs' import {TIFF_LITTLE_ENDIAN, TIFF_BIG_ENDIAN} from '../util/helpers.mjs' import {isEmpty, normalizeString} from '../util/helpers.mjs' import {throwError, estimateMetadataSize} from '../util/helpers.mjs' import {tiffBlocks} from '../options.mjs' const MALFORMED = 'Malformed EXIF data' const THUMB_OFFSET = 0x0201 const THUMB_LENGTH = 0x0202 const BYTE = 1 const ASCII = 2 const SHORT = 3 const LONG = 4 const RATIONAL = 5 const SBYTE = 6 const UNDEFINED = 7 const SSHORT = 8 const SLONG = 9 const SRATIONAL = 10 const FLOAT = 11 const DOUBLE = 12 const IFD = 13 const SIZE_LOOKUP = [ undefined, // nothing at index 0 1, // BYTE - 8-bit unsigned integer 1, // ASCII - 8-bit bytes w/ last byte null 2, // SHORT - 16-bit unsigned integer 4, // LONG - 32-bit unsigned integer 8, // RATIONAL - 64-bit unsigned fraction of two 32-bit unsigned integers 1, // SBYTE - 8-bit signed integer 1, // UNDEFINED - 8-bit untyped data 2, // SSHORT - 16-bit signed integer 4, // SLONG - 32-bit signed integer 8, // SRATIONAL - 64-bit signed fraction of two 32-bit signed integers 4, // FLOAT, - 32-bit IEEE floating point 8, // DOUBLE - 64-bit IEEE floating point // https://sno.phy.queensu.ca/~phil/exiftool/standards.html 4 // IFD (sometimes used instead of 4 LONG) ] function getTypedArray(type) { switch (type) { case BYTE : return Uint8Array case SHORT : return Uint16Array case LONG : return Uint32Array case RATIONAL : return Array case SBYTE : return Int8Array case SSHORT : return Int16Array case SLONG : return Int32Array case SRATIONAL: return Array case FLOAT : return Float32Array case DOUBLE : return Float64Array default: return Array } } // WARNING: In .tif files, exif can be before ifd0 // - namely issue-metadata-extractor-152.tif offsets are: EXIF 2468122, IFD0 2468716, GPS 2468550 // jpg wraps tiff into app1 segment. export class TiffCore extends AppSegmentParserBase { // TODO: future API //tagsOutsideChunk = [] parseHeader() { // Detect endian 11th byte of TIFF (1st after header) var byteOrder = this.chunk.getUint16() if (byteOrder === TIFF_LITTLE_ENDIAN) this.le = true // little endian else if (byteOrder === TIFF_BIG_ENDIAN) this.le = false // big endian // else // throwError('Invalid EXIF data: expected byte order marker (0x4949 or 0x4D4D).') this.chunk.le = this.le /* // Bytes 8 & 9 are expected to be 00 2A. if (this.chunk.getUint16(2) !== 0x002A) throwError('Invalid EXIF data: expected 0x002A.') */ this.headerParsed = true } parseTags(offset, blockKey, block = new Map) { let {pick, skip} = this.options[blockKey] pick = new Set(pick) // clone data from options because we will modify it here let onlyPick = pick.size > 0 let nothingToSkip = skip.size === 0 let entriesCount = this.chunk.getUint16(offset) offset += 2 for (let i = 0; i < entriesCount; i++) { let tag = this.chunk.getUint16(offset) if (onlyPick) { if (pick.has(tag)) { // We have a list only of tags to pick, this tag is one of them, so read it. block.set(tag, this.parseTag(offset, tag, blockKey)) pick.delete(tag) if (pick.size === 0) break } } else if (nothingToSkip || !skip.has(tag)) { // We're not limiting what tags to pick. Also this tag is not on a blacklist. block.set(tag, this.parseTag(offset, tag, blockKey)) } offset += 12 } return block } parseTag(offset, tag, blockKey) { let {chunk} = this let type = chunk.getUint16(offset + 2) let valueCount = chunk.getUint32(offset + 4) let valueSize = SIZE_LOOKUP[type] let totalSize = valueSize * valueCount if (totalSize <= 4) offset = offset + 8 else offset = chunk.getUint32(offset + 8) if (type < BYTE || type > IFD) throwError(`Invalid TIFF value type. block: ${blockKey.toUpperCase()}, tag: ${tag.toString(16)}, type: ${type}, offset ${offset}`) if (offset > chunk.byteLength) { // TODO: future API //this.tagsOutsideChunk.push({tag, offset, type, valueCount, valueSize, totalSize}) throwError(`Invalid TIFF value offset. block: ${blockKey.toUpperCase()}, tag: ${tag.toString(16)}, type: ${type}, offset ${offset} is outside of chunk size ${chunk.byteLength}`) } if (type === BYTE) // type 1 return chunk.getUint8Array(offset, valueCount) // ascii strings, array of 8bits/1byte values. if (type === ASCII) // type 2 return normalizeString(chunk.getString(offset, valueCount)) // undefined/buffers of 8bit/1byte values. if (type === UNDEFINED) // type 7 return chunk.getUint8Array(offset, valueCount) // Now that special cases are solved, we can return the normal uint/int value(s). if (valueCount === 1) { // Return single value. return this.parseTagValue(type, offset) } else { // Return array of values. let ArrayType = getTypedArray(type) let arr = new ArrayType(valueCount) // rational numbers are stored as two integers that we divide when parsing. let offsetIncrement = valueSize for (let i = 0; i < valueCount; i++) { arr[i] = this.parseTagValue(type, offset) offset += offsetIncrement } return arr } } parseTagValue(type, offset) { let {chunk} = this switch (type) { case BYTE : return chunk.getUint8(offset) case SHORT : return chunk.getUint16(offset) case LONG : return chunk.getUint32(offset) case RATIONAL : return chunk.getUint32(offset) / chunk.getUint32(offset + 4) case SBYTE : return chunk.getInt8(offset) case SSHORT : return chunk.getInt16(offset) case SLONG : return chunk.getInt32(offset) case SRATIONAL: return chunk.getInt32(offset) / chunk.getInt32(offset + 4) case FLOAT : return chunk.getFloat(offset) case DOUBLE : return chunk.getDouble(offset) case 13: return chunk.getUint32(offset) default: throwError(`Invalid tiff type ${type}`) } } } const TAG_FILESOURCE = 0xa300 const TAG_SCENETYPE = 0xa301 /* JPEG with EXIF segment starts with App1 header (FF E1, length, 'Exif\0\0') and then follows the TIFF. Whereas .tif file format starts with the TIFF structure right away. APP1 HEADER (only in JPEG) - FF E1 - segment marker - 2Bytes - segment length - 45 78 69 66 00 00 - string 'Exif\0\0' APP1 CONTENT - TIFF HEADER (2b byte order, 2b tiff id, 4b offset of ifd1) - IFD0 - Exif IFD - Interop IFD - GPS IFD - IFD1 */ export class TiffExif extends TiffCore { static type = 'tiff' static headerLength = 10 // .tif files do no have any APPn segments. and usually start right with TIFF header // .jpg files can have multiple APPn segments. They always have APP1 whic is a wrapper for TIFF. // We support both jpg and tiff so we're not looking for app1 segment but directly for TIFF // because app1 in jpg is only container for tiff. static canHandle(view, offset) { return view.getUint8(offset + 1) === 0xE1 && view.getUint32(offset + 4) === 0x45786966 // 'Exif' && view.getUint16(offset + 8) === 0x0000 // followed by '\0' } // APP1 includes TIFF formatted values, grouped into IFD blocks (IFD0, Exif, Interop, GPS, IFD1) async parse() { this.parseHeader() let {options} = this // WARNING: In .tif files, exif can be before ifd0 (issue-metadata-extractor-152.tif has: EXIF 2468122, IFD0 2468716) if (options.ifd0.enabled) await this.parseIfd0Block() // APP1 - IFD0 if (options.exif.enabled) await this.safeParse('parseExifBlock') // APP1 - EXIF IFD if (options.gps.enabled) await this.safeParse('parseGpsBlock') // APP1 - GPS IFD if (options.interop.enabled) await this.safeParse('parseInteropBlock') // APP1 - Interop IFD if (options.ifd1.enabled) await this.safeParse('parseThumbnailBlock') // APP1 - IFD1 return this.createOutput() //return this.output } // this is ugly but needed for async-to-promise babel plugin to work safeParse(methodName) { let result = this[methodName]() // Ugly IE fix, async functions always return promises, but not when transpiled. if (result.catch !== undefined) result = result.catch(this.handleError) return result } findIfd0Offset() { if (this.ifd0Offset === undefined) this.ifd0Offset = this.chunk.getUint32(4) } findIfd1Offset() { if (this.ifd1Offset === undefined) { this.findIfd0Offset() let ifd0Entries = this.chunk.getUint16(this.ifd0Offset) let temp = this.ifd0Offset + 2 + (ifd0Entries * 12) // IFD1 offset is number of bytes from start of TIFF header where thumbnail info is. this.ifd1Offset = this.chunk.getUint32(temp) } } parseBlock(offset, blockKey) { let block = new Map this[blockKey] = block this.parseTags(offset, blockKey, block) return block } async parseIfd0Block() { if (this.ifd0) return let {file} = this // Read the IFD0 segment with basic info about the image // (width, height, maker, model and pointers to another segments) this.findIfd0Offset() if (this.ifd0Offset < 8) throwError(MALFORMED) if (!file.chunked && this.ifd0Offset > file.byteLength) throwError(`IFD0 offset points to outside of file.\nthis.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${file.byteLength}`) //await this.ensureBlockChunk(this.ifd0Offset, estimateMetadataSize(this.options)) if (file.tiff) await file.ensureChunk(this.ifd0Offset, estimateMetadataSize(this.options)) // Parse IFD0 block. let ifd0 = this.parseBlock(this.ifd0Offset, 'ifd0') // Cancel if the ifd0 is empty (imaged created from scratch in photoshop). if (ifd0.size === 0) return // Store offsets of other blocks in the TIFF segment. this.exifOffset = ifd0.get(TAG_IFD_EXIF) this.interopOffset = ifd0.get(TAG_IFD_INTEROP) this.gpsOffset = ifd0.get(TAG_IFD_GPS) this.xmp = ifd0.get(TAG_XMP) this.iptc = ifd0.get(TAG_IPTC) this.icc = ifd0.get(TAG_ICC) //this.photoshop = ifd0.get(0x8649) // IFD0 segment also contains offset pointers to another segments deeper within the EXIF. if (this.options.sanitize) { ifd0.delete(TAG_IFD_EXIF) ifd0.delete(TAG_IFD_INTEROP) ifd0.delete(TAG_IFD_GPS) ifd0.delete(TAG_XMP) ifd0.delete(TAG_IPTC) ifd0.delete(TAG_ICC) } return ifd0 } // EXIF block of TIFF of APP1 segment // 0x8769 async parseExifBlock() { if (this.exif) return if (!this.ifd0) await this.parseIfd0Block() if (this.exifOffset === undefined) return if (this.file.tiff) await this.file.ensureChunk(this.exifOffset, estimateMetadataSize(this.options)) let exif = this.parseBlock(this.exifOffset, 'exif') if (!this.interopOffset) this.interopOffset = exif.get(TAG_IFD_INTEROP) this.makerNote = exif.get(TAG_MAKERNOTE) this.userComment = exif.get(TAG_USERCOMMENT) if (this.options.sanitize) { exif.delete(TAG_IFD_INTEROP) exif.delete(TAG_MAKERNOTE) exif.delete(TAG_USERCOMMENT) } this.unpack(exif, TAG_FILESOURCE) this.unpack(exif, TAG_SCENETYPE) return exif } unpack(map, key) { let value = map.get(key) if (value && value.length === 1) map.set(key, value[0]) } // GPS block of TIFF of APP1 segment // 0x8825 async parseGpsBlock() { if (this.gps) return if (!this.ifd0) await this.parseIfd0Block() if (this.gpsOffset === undefined) return let gps = this.parseBlock(this.gpsOffset, 'gps') if (gps && gps.has(TAG_GPS_LAT) && gps.has(TAG_GPS_LON)) { // TODO: assign this to this.translated or this.output when blocks are broken down to separate classes //gps.latitude = ConvertDMSToDD(...gps.get(TAG_GPS_LAT), gps.get(TAG_GPS_LATREF)) //gps.longitude = ConvertDMSToDD(...gps.get(TAG_GPS_LON), gps.get(TAG_GPS_LONREF)) gps.set('latitude', ConvertDMSToDD(...gps.get(TAG_GPS_LAT), gps.get(TAG_GPS_LATREF))) gps.set('longitude', ConvertDMSToDD(...gps.get(TAG_GPS_LON), gps.get(TAG_GPS_LONREF))) } return gps } // INTEROP block of TIFF of APP1 segment // 0xA005 async parseInteropBlock() { if (this.interop) return if (!this.ifd0) await this.parseIfd0Block() if (this.interopOffset === undefined && !this.exif) await this.parseExifBlock() if (this.interopOffset === undefined) return return this.parseBlock(this.interopOffset, 'interop') } // THUMBNAIL block of TIFF of APP1 segment // parsing this block is skipped when mergeOutput is true because thumbnail block contains with the same tags like ifd0 block // and one would override the other. async parseThumbnailBlock(force = false) { if (this.ifd1 || this.ifd1Parsed) return if (this.options.mergeOutput && !force) return this.findIfd1Offset() if (this.ifd1Offset > 0) { this.parseBlock(this.ifd1Offset, 'ifd1') this.ifd1Parsed = true } return this.ifd1 } // THUMBNAIL buffer of TIFF of APP1 segment async extractThumbnail() { if (!this.headerParsed) this.parseHeader() if (!this.ifd1Parsed) await this.parseThumbnailBlock(true) if (this.ifd1 === undefined) return // TODO: replace 'ThumbnailOffset' & 'ThumbnailLength' by raw keys (when tag dict is not included) let offset = this.ifd1.get(THUMB_OFFSET) let length = this.ifd1.get(THUMB_LENGTH) return this.chunk.getUint8Array(offset, length) } get image() {return this.ifd0} get thumbnail() {return this.ifd1} createOutput() { let tiff = {} let block, blockKey, blockOutput for (blockKey of tiffBlocks) { block = this[blockKey] if (isEmpty(block)) continue if (this.canTranslate) blockOutput = this.translateBlock(block, blockKey) else blockOutput = Object.fromEntries(block) if (this.options.mergeOutput) { // NOTE: Not assigning thumbnail because it contains the same tags as ifd0. if (blockKey === 'ifd1') continue Object.assign(tiff, blockOutput) } else { tiff[blockKey] = blockOutput } } if (this.makerNote) tiff.makerNote = this.makerNote if (this.userComment) tiff.userComment = this.userComment return tiff } assignToOutput(root, tiff) { if (this.globalOptions.mergeOutput) { // xmp contains only properties Object.assign(root, tiff) } else { for (let [blockKey, block] of Object.entries(tiff)) this.assignObjectToOutput(root, blockKey, block) } } } function ConvertDMSToDD(degrees, minutes, seconds, direction) { var dd = degrees + (minutes / 60) + (seconds / (60*60)) if (direction === 'S' || direction === 'W') dd *= -1 return dd } segmentParsers.set('tiff', TiffExif)