import {AppSegmentParserBase} from '../parser.ts' import {segmentParsers} from '../plugins.ts' import {TAG_IFD_EXIF, TAG_IFD_GPS, TAG_IFD_INTEROP, TAG_MAKERNOTE, TAG_USERCOMMENT, TAG_XMP, TAG_IPTC, TAG_ICC} from '../tags.ts' import {TAG_GPS_LATREF, TAG_GPS_LAT, TAG_GPS_LONREF, TAG_GPS_LON} from '../tags.ts' import {TIFF_LITTLE_ENDIAN, TIFF_BIG_ENDIAN} from '../util/helpers.ts' import {isEmpty, normalizeString} from '../util/helpers.ts' import {throwError, estimateMetadataSize} from '../util/helpers.ts' import {tiffBlocks} from '../options.ts' 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 { // Field declarations populated during parseHeader / parseBlock / // translation. `declare` keeps them type-only — assignments happen in // the methods below. `any` for tag value containers because the // per-block types are heterogenous (numbers, strings, dates, raw // arrays, etc.). /* eslint-disable @typescript-eslint/no-explicit-any */ declare le: boolean declare headerParsed: boolean declare ifd0Offset?: number declare ifd1Offset?: number declare exifOffset?: number declare interopOffset?: number declare gpsOffset?: number declare ifd0?: any declare ifd1?: any declare exif?: any declare gps?: any declare interop?: any declare iptc?: any declare icc?: any declare xmp?: any declare ifd1Parsed?: boolean declare makerNote?: unknown declare userComment?: unknown /* eslint-enable @typescript-eslint/no-explicit-any */ // TODO: future API //tagsOutsideChunk = [] parseHeader() { // Detect endian 11th byte of TIFF (1st after header) const byteOrder = this.chunk.getUint16(0) 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 const onlyPick = pick.size > 0 const nothingToSkip = skip.size === 0 const entriesCount = this.chunk.getUint16(offset) offset += 2 for (let i = 0; i < entriesCount; i++) { const 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) { const {chunk} = this const type = chunk.getUint16(offset + 2) const valueCount = chunk.getUint32(offset + 4) const valueSize = SIZE_LOOKUP[type] const 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. const ArrayType = getTypedArray(type) const arr = new ArrayType(valueCount) // rational numbers are stored as two integers that we divide when parsing. const offsetIncrement = valueSize for (let i = 0; i < valueCount; i++) { arr[i] = this.parseTagValue(type, offset) offset += offsetIncrement } return arr } } parseTagValue(type, offset) { const {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() const {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() const ifd0Entries = this.chunk.getUint16(this.ifd0Offset) const 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) { const block = new Map this[blockKey] = block this.parseTags(offset, blockKey, block) return block } async parseIfd0Block() { if (this.ifd0) return const {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. const 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)) const 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) { const 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 const 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)) const latArr = gps.get(TAG_GPS_LAT) as [number, number, number] const lonArr = gps.get(TAG_GPS_LON) as [number, number, number] gps.set('latitude', ConvertDMSToDD(...latArr, gps.get(TAG_GPS_LATREF))) gps.set('longitude', ConvertDMSToDD(...lonArr, 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) const offset = this.ifd1.get(THUMB_OFFSET) const length = this.ifd1.get(THUMB_LENGTH) return this.chunk.getUint8Array(offset, length) } get image() {return this.ifd0} get thumbnail() {return this.ifd1} createOutput() { const tiff: Record = {} 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 (const [blockKey, block] of Object.entries(tiff)) this.assignObjectToOutput(root, blockKey, block) } } } function ConvertDMSToDD(degrees, minutes, seconds, direction) { let dd = degrees + (minutes / 60) + (seconds / (60*60)) if (direction === 'S' || direction === 'W') dd *= -1 return dd } segmentParsers.set('tiff', TiffExif)