exifr
Version:
📷 The fastest and most versatile JavaScript EXIF reading library.
427 lines (381 loc) • 15.2 kB
JavaScript
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)