@evshiron/exifr
Version:
📷 The fastest and most versatile JavaScript EXIF reading library.
391 lines (340 loc) • 13.6 kB
JavaScript
import {TAG_MAKERNOTE, TAG_USERCOMMENT} from './tags.mjs'
import {TAG_IFD_EXIF, TAG_IFD_GPS, TAG_IFD_INTEROP} from './tags.mjs'
import {TAG_XMP, TAG_IPTC, TAG_ICC} from './tags.mjs'
import {tagKeys} from './tags.mjs'
import * as platform from './util/platform.mjs'
import {throwError} from './util/helpers.mjs'
import {segmentParsers, throwNotLoaded} from './plugins.mjs'
export const chunkedProps = [
'chunked',
'firstChunkSize',
'firstChunkSizeNode',
'firstChunkSizeBrowser',
'chunkSize',
'chunkLimit',
]
// List of other segments besides the tiff/exif itself
export const otherSegments = ['jfif', 'xmp', 'icc', 'iptc', 'ihdr']
// List of all other segments
export const segments = ['tiff', ...otherSegments]
// WARNING: this order is necessary for correctly assigning pick tags.
export const tiffBlocks = ['ifd0', 'ifd1', 'exif', 'gps', 'interop']
export const segmentsAndBlocks = [...segments, ...tiffBlocks]
export const tiffExtractables = ['makerNote', 'userComment']
export const inheritables = ['translateKeys', 'translateValues', 'reviveValues', 'multiSegment']
export const allFormatters = [...inheritables, 'sanitize', 'mergeOutput', 'silentErrors']
class SharedOptions {
get translate() {
return this.translateKeys
|| this.translateValues
|| this.reviveValues
}
}
class SubOptions extends SharedOptions {
enabled = false
skip = new Set
pick = new Set
deps = new Set // tags required by other blocks or segments (IFD pointers, makernotes)
translateKeys = false
translateValues = false
reviveValues = false
get needed() {
return this.enabled
|| this.deps.size > 0
}
constructor(key, defaultValue, userValue, parent) {
super()
this.key = key
this.enabled = defaultValue // todo: rename to extract
this.parse = this.enabled
this.applyInheritables(parent)
this.canBeFiltered = tiffBlocks.includes(key)
if (this.canBeFiltered)
this.dict = tagKeys.get(key)
if (userValue !== undefined) {
if (Array.isArray(userValue)) {
this.parse = this.enabled = true
if (this.canBeFiltered && userValue.length > 0)
this.translateTagSet(userValue, this.pick)
} else if (typeof userValue === 'object') {
this.enabled = true
this.parse = userValue.parse !== false
if (this.canBeFiltered) {
let {pick, skip} = userValue
if (pick && pick.length > 0) this.translateTagSet(pick, this.pick)
if (skip && skip.length > 0) this.translateTagSet(skip, this.skip)
}
this.applyInheritables(userValue)
} else if (userValue === true || userValue === false) {
this.parse = this.enabled = userValue
} else {
throwError(`Invalid options argument: ${userValue}`)
}
}
}
applyInheritables(origin) {
let key, val
for (key of inheritables) {
val = origin[key]
if (val !== undefined) this[key] = val
}
}
translateTagSet(inputArray, outputSet) {
if (this.dict) {
let {tagKeys, tagValues} = this.dict
let tag, index
for (tag of inputArray) {
if (typeof tag === 'string') {
index = tagValues.indexOf(tag)
if (index === -1) index = tagKeys.indexOf(Number(tag))
if (index !== -1) outputSet.add(Number(tagKeys[index]))
} else {
outputSet.add(tag)
}
}
} else {
for (let tag of inputArray) outputSet.add(tag)
}
}
finalizeFilters() {
if (!this.enabled && this.deps.size > 0) {
this.enabled = true
addToSet(this.pick, this.deps)
} else if (this.enabled && this.pick.size > 0) {
addToSet(this.pick, this.deps)
}
}
}
var defaults = {
// APP Segments
jfif: false, // jpeg only (jpeg file header)
tiff: true,
xmp: false,
icc: false,
iptc: false,
// TIFF BLOCKS
ifd0: true, // image
ifd1: false, // thumbnail
exif: true,
gps: true,
interop: false, // jpeg only
// undefined because we don't want Jpeg or Heic file parser to pick it up.
// Png parser will use Ihdr implicitly unless it's disabled by user.
ihdr: undefined, // png only (png file header)
// Notable TIFF tags
makerNote: false,
userComment: false,
// TODO: to be developed in future version, this is just a proposal for future api
multiSegment: false,
// FILTERS
// Array of tags that will be excluded when parsing.
// Saves performance because the tags aren't read at all and thus not further processed.
// Cannot be used along with 'pick' array.
skip: [],
// Array of the only tags that will be parsed. Those that are not specified will be ignored.
// Extremely saves performance because only selected few tags are processed.
// Useful for extracting few informations from a batch of many photos.
// Cannot be used along with 'skip' array.
pick: [],
// OUTPUT FORMATTERS
translateKeys: true,
translateValues: true,
reviveValues: true,
// Removes IFD pointers and other artifacts (useless for user) from output.
sanitize: true,
// Changes output format by merging all segments and blocks into single object.
// NOTE = Causes loss of thumbnail EXIF data.
mergeOutput: true,
// Fails silently and logs the file errors in output.errors instead of throwing error.
silentErrors: true,
// CHUNKED READER
// true - forces reading the whole file
// undefined - allows reading additional chunks of size `chunkSize` (chunked mode)
// false - does not allow reading additional chunks beyond `firstChunkSize` (chunked mode)
chunked: true,
// Size of the chunk that can be scanned for EXIF.
firstChunkSize: undefined,
// Size of the chunk that can be scanned for EXIF. Used by node.js.
firstChunkSizeNode: 512,
// In browser its sometimes better to download larger chunk in hope that it contains the
// whole EXIF (and not just its begining like in case of firstChunkSizeNode) in prevetion
// of additional loading and fetching.
firstChunkSizeBrowser: 65536, // 64kb
// Size of subsequent chunks that are read after first chunk (if needed)
chunkSize: 65536, // 64kb
// Maximum amount of additional chunks allowed to read in chunk mode.
// If the requested segments aren't parsed within N chunks (64*3 = 192kb) they probably aren't in the file.
chunkLimit: 5,
}
var existingInstances = new Map
export class Options extends SharedOptions {
// exporting for user to change
static default = defaults
static useCached(userOptions) {
let options = existingInstances.get(userOptions)
if (options !== undefined) return options
options = new this(userOptions)
existingInstances.set(userOptions, options)
return options
}
constructor(userOptions) {
super()
if (userOptions === true)
this.setupFromTrue()
else if (userOptions === undefined)
this.setupFromUndefined()
else if (Array.isArray(userOptions))
this.setupFromArray(userOptions)
else if (typeof userOptions === 'object')
this.setupFromObject(userOptions)
else
throwError(`Invalid options argument ${userOptions}`)
if (this.firstChunkSize === undefined)
this.firstChunkSize = platform.browser ? this.firstChunkSizeBrowser : this.firstChunkSizeNode
// thumbnail contains the same tags as ifd0. they're not necessary when `mergeOutput`
if (this.mergeOutput) this.ifd1.enabled = false
// translate global pick/skip tags & copy them to local segment/block settings
// handle the tiff->ifd0->exif->makernote pick dependency tree.
// this also adds picks to blocks & segments to efficiently parse through tiff.
this.filterNestedSegmentTags()
this.traverseTiffDependencyTree()
this.checkLoadedPlugins()
}
setupFromUndefined() {
let key
for (key of chunkedProps) this[key] = defaults[key]
for (key of allFormatters) this[key] = defaults[key]
for (key of tiffExtractables) this[key] = defaults[key]
for (key of segmentsAndBlocks) this[key] = new SubOptions(key, defaults[key], undefined, this)
}
setupFromTrue() {
let key
for (key of chunkedProps) this[key] = defaults[key]
for (key of allFormatters) this[key] = defaults[key]
for (key of tiffExtractables) this[key] = true
for (key of segmentsAndBlocks) this[key] = new SubOptions(key, true, undefined, this)
}
setupFromArray(userOptions) {
let key
for (key of chunkedProps) this[key] = defaults[key]
for (key of allFormatters) this[key] = defaults[key]
for (key of tiffExtractables) this[key] = defaults[key]
for (key of segmentsAndBlocks) this[key] = new SubOptions(key, false, undefined, this)
this.setupGlobalFilters(userOptions, undefined, tiffBlocks)
}
setupFromObject(userOptions) {
tiffBlocks.ifd0 = tiffBlocks.ifd0 || tiffBlocks.image
tiffBlocks.ifd1 = tiffBlocks.ifd1 || tiffBlocks.thumbnail
let key
// needed for adding additional (and internal options properties like stopAfterSos for jpg)
Object.assign(this, userOptions)
for (key of chunkedProps) this[key] = getDefined(userOptions[key], defaults[key])
for (key of allFormatters) this[key] = getDefined(userOptions[key], defaults[key])
for (key of tiffExtractables) this[key] = getDefined(userOptions[key], defaults[key])
for (key of segments) this[key] = new SubOptions(key, defaults[key], userOptions[key], this)
for (key of tiffBlocks) this[key] = new SubOptions(key, defaults[key], userOptions[key], this.tiff)
this.setupGlobalFilters(userOptions.pick, userOptions.skip, tiffBlocks, segmentsAndBlocks)
if (userOptions.tiff === true)
this.batchEnableWithBool(tiffBlocks, true)
else if (userOptions.tiff === false)
this.batchEnableWithUserValue(tiffBlocks, userOptions)
else if (Array.isArray(userOptions.tiff))
this.setupGlobalFilters(userOptions.tiff, undefined, tiffBlocks)
else if (typeof userOptions.tiff === 'object')
this.setupGlobalFilters(userOptions.tiff.pick, userOptions.tiff.skip, tiffBlocks)
}
batchEnableWithBool(keys, value) {
for (let key of keys)
this[key].enabled = value
}
batchEnableWithUserValue(keys, userOptions) {
for (let key of keys) {
let userOption = userOptions[key]
this[key].enabled = userOption !== false && userOption !== undefined
}
}
setupGlobalFilters(pick, skip, dictKeys, disableableSegsAndBlocks = dictKeys) {
if (pick && pick.length) {
// if we're only picking, we can safely disable all other blocks and segments
for (let blockKey of disableableSegsAndBlocks)
this[blockKey].enabled = false
let entries = findScopesForGlobalTagArray(pick, dictKeys)
for (let [blockKey, tags] of entries) {
addToSet(this[blockKey].pick, tags)
// the blocks of tags from global picks are the only blocks we'll parse.
this[blockKey].enabled = true
}
} else if (skip && skip.length) {
let entries = findScopesForGlobalTagArray(skip, dictKeys)
for (let [segKey, tags] of entries)
addToSet(this[segKey].skip, tags)
}
}
// XMP, IPTC can ICC can be stored as a tag in TIFF (in .tif files).
// This method adds them to skip list if these segments are not requested.
// Also applies to MakerNote and UserComment
filterNestedSegmentTags() {
let {ifd0, exif, xmp, iptc, icc} = this
// not segments, regular but notable TIFF tags
if (this.makerNote) exif.deps.add(TAG_MAKERNOTE)
else exif.skip.add(TAG_MAKERNOTE)
if (this.userComment) exif.deps.add(TAG_USERCOMMENT)
else exif.skip.add(TAG_USERCOMMENT)
// segments that can be stored as tags (but only?? in .tiff)
// note: not adding as deps because that is requested only in .tif file parser
if (!xmp.enabled) ifd0.skip.add(TAG_XMP)
if (!iptc.enabled) ifd0.skip.add(TAG_IPTC)
if (!icc.enabled) ifd0.skip.add(TAG_ICC)
}
// INVESTIGATE: can this be moved to Tiff Segment parser?
traverseTiffDependencyTree() {
let {ifd0, exif, gps, interop} = this
// interop pointer can be often found in EXIF besides IFD0.
if (interop.needed) {
exif.deps.add(TAG_IFD_INTEROP)
ifd0.deps.add(TAG_IFD_INTEROP)
}
// exif needs to go after interop. Exif may be needed for interop, and then ifd0 for exif
if (exif.needed) ifd0.deps.add(TAG_IFD_EXIF)
if (gps.needed) ifd0.deps.add(TAG_IFD_GPS)
this.tiff.enabled = tiffBlocks.some(key => this[key].enabled === true)
|| this.makerNote
|| this.userComment
// reenable all the blocks with pick or deps and lock in deps into picks if needed.
for (let key of tiffBlocks) this[key].finalizeFilters()
}
get onlyTiff() {
let bools = otherSegments.map(key => this[key].enabled)
if (bools.some(bool => bool === true)) return false
return this.tiff.enabled
}
checkLoadedPlugins() {
for (let key of segments)
if (this[key].enabled && !segmentParsers.has(key))
throwNotLoaded('segment parser', key)
}
}
function findScopesForGlobalTagArray(tagArray, dictKeys) {
let scopes = []
let dict, scopedTags, blockKey, tagEntry
for (blockKey of dictKeys) {
dict = tagKeys.get(blockKey)
scopedTags = []
for (tagEntry of dict) {
// NOTE: not expading tagEntry into [key, val] because of performance
if (tagArray.includes(tagEntry[0]) || tagArray.includes(tagEntry[1]))
scopedTags.push(tagEntry[0])
}
if (scopedTags.length)
scopes.push([blockKey, scopedTags])
}
return scopes
}
function getDefined(arg1, arg2) {
if (arg1 !== undefined) return arg1
if (arg2 !== undefined) return arg2
}
function addToSet(target, source) {
for (let item of source)
target.add(item)
}