exifer
Version:
A lightweight Exif image meta-data decipher
499 lines (435 loc) • 17.3 kB
JavaScript
const SIZES = {
1: 1, // BYTE - 8-bit unsigned integer
2: 1, // ASCII - 8-bit bytes w/ last byte null
3: 2, // SHORT - 16-bit unsigned integer
4: 4, // LONG - 32-bit unsigned integer
5: 8, // RATIONAL - 64-bit unsigned fraction
7: 1, // UNDEFINED - 8-bit untyped data
9: 4, // SLONG - 32-bit signed integer
10: 8, // SRATIONAL - 64-bit signed fraction (2x32-bit signed integers)
13: 4 // IFD
};
const TYPES = {
byte: 1,
ascii: 2,
short: 3,
long: 4,
rational: 5,
undefined: 7,
slong: 9,
srational: 10,
ifd: 13
};
const read = {
[TYPES.byte](view, offset, littleEndian) {
return view.getUint8(offset, littleEndian);
},
[TYPES.short](view, offset, littleEndian) {
return view.getUint16(offset, littleEndian);
},
[TYPES.long](view, offset, littleEndian) {
return view.getUint32(offset, littleEndian);
},
[TYPES.rational](view, offset, littleEndian) {
return this[TYPES.long](view, offset, littleEndian) / this[TYPES.long](view, offset + 4, littleEndian);
},
[TYPES.slong](view, offset, littleEndian) {
return view.getInt32(offset, littleEndian);
},
[TYPES.srational](view, offset, littleEndian) {
return this[TYPES.slong](view, offset, littleEndian) / this[TYPES.slong](view, offset + 4, littleEndian);
},
[TYPES.ifd](view, offset, littleEndian) {
return this[TYPES.long](view, offset, littleEndian);
},
[TYPES.ascii](view, offset, littleEndian) {
return this[TYPES.byte](view, offset, littleEndian);
},
[TYPES.undefined](view, state, littleEndian) {
return this[TYPES.byte](view, state, littleEndian);
}
};
const getSize = name => SIZES[TYPES[name]];
const arrayToString = arr => arr.map(c => String.fromCharCode(c)).join('');
const viewToString = (view, offset, count) => arrayToString(viewToValue(view, {offset, count}));
function viewToValue(view, {offset, type = TYPES.byte, count, littleEndian}) {
const value = [];
for (let valueIndex = 0; valueIndex < count; valueIndex++) {
value.push(read[type](view, offset, littleEndian));
offset += SIZES[type];
}
return value;
}
function parseViewValue(value, type = TYPES.ascii) {
if (type === TYPES.ascii) {
value = arrayToString(value);
return value.endsWith('\0') ? value.slice(0, -1) : value;
}
if (value.length === 1) value = value[0];
return value;
}
/**
* This file is heavly based on ExifReader,
* but simplified and with minor modifications:
* https://github.com/mattiasw/ExifReader/blob/master/src/image-header.js
*/
const MIN_TIFF_DATA_BUFFER_LENGTH = 4;
const MIN_JPEG_DATA_BUFFER_LENGTH = 2;
const TIFF_ID = 0x2a;
const TIFF_ID_OFFSET = 2;
const TIFF_FILE_HEADER_OFFSET = 0;
const JPEG_ID = 0xffd8; // JPEG magic bytes header
const JPEG_ID_SIZE = 2;
const LITTLE_ENDIAN = 0x4949;
const APP_ID_OFFSET = 4;
const APP_MARKER_SIZE = 2;
const TIFF_HEADER_OFFSET = 10; // From start of APP1 marker.
const IPTC_DATA_OFFSET = 18; // From start of APP13 marker.
const XMP_DATA_OFFSET = 33; // From start of APP1 marker.
const XMP_EXTENDED_DATA_OFFSET = 79; // From start of APP1 marker including GUID, total length, and offset.
const SOF0_MARKER = 0xffc0;
const SOF2_MARKER = 0xffc2;
const DHT_MARKER = 0xffc4;
const DQT_MARKER = 0xffdb;
const DRI_MARKER = 0xffdd;
const SOS_MARKER = 0xffda;
const APP0_MARKER = 0xffe0;
const APP1_MARKER = 0xffe1;
const APP13_MARKER = 0xffed;
const APP15_MARKER = 0xffef;
const COMMENT_MARKER = 0xfffe;
// 0x45786966 - "E", "X", "I", "F"
const APP1_EXIF_IDENTIFIER = `Exif`;
// 0x68747470 - "H", "T", "T", "P"
const APP1_XMP_IDENTIFIER = `http://ns.adobe.com/xap/1.0/\x00`;
const APP1_XMP_EXTENDED_IDENTIFIER = 'http://ns.adobe.com/xmp/extension/\x00';
const APP13_IPTC_IDENTIFIER = `Photoshop 3.0`;
const hasTiffMarker = view => {
const littleEndian = view.getUint16(0) === LITTLE_ENDIAN;
return view.getUint16(TIFF_ID_OFFSET, littleEndian) === TIFF_ID;
};
const isTiffFile = view => view.byteLength >= MIN_TIFF_DATA_BUFFER_LENGTH && hasTiffMarker(view);
const isJpegFile = view => view.byteLength >= MIN_JPEG_DATA_BUFFER_LENGTH && view.getUint16(0, false) === JPEG_ID;
function* iterateAppMarkers(view) {
let appMarkerPosition = JPEG_ID_SIZE;
let xmpChunks = [];
let fieldLength;
if (isTiffFile(view)) return yield {type: `tiff`, offset: TIFF_FILE_HEADER_OFFSET};
if (!isJpegFile(view)) throw new Error(`Invalid image format`);
while (appMarkerPosition + APP_ID_OFFSET + 5 <= view.byteLength) {
if (isApp1ExifMarker(view, appMarkerPosition)) {
fieldLength = view.getUint16(appMarkerPosition + APP_MARKER_SIZE, false);
let offset = appMarkerPosition + TIFF_HEADER_OFFSET;
yield {type: `tiff`, offset};
} else if (isApp1XmpMarker(view, appMarkerPosition)) {
fieldLength = view.getUint16(appMarkerPosition + APP_MARKER_SIZE, false);
xmpChunks.push(getXmpChunkDetails(appMarkerPosition, fieldLength));
} else if (isApp1ExtendedXmpMarker(view, appMarkerPosition)) {
fieldLength = view.getUint16(appMarkerPosition + APP_MARKER_SIZE, false);
xmpChunks.push(getExtendedXmpChunkDetails(appMarkerPosition, fieldLength));
} else if (isApp13PhotoshopMarker(view, appMarkerPosition)) {
fieldLength = view.getUint16(appMarkerPosition + APP_MARKER_SIZE, false);
let offset = appMarkerPosition + IPTC_DATA_OFFSET;
yield {type: `iptc`, offset};
} else if (isAppMarker(view, appMarkerPosition)) {
fieldLength = view.getUint16(appMarkerPosition + APP_MARKER_SIZE, false);
} else {
break;
}
appMarkerPosition += APP_MARKER_SIZE + fieldLength;
}
if (xmpChunks.length) yield {type: `xmp`, offset: xmpChunks};
}
function isApp1ExifMarker(view, appMarkerPosition) {
const markerIdLength = APP1_EXIF_IDENTIFIER.length;
return (
view.getUint16(appMarkerPosition, false) === APP1_MARKER &&
viewToString(view, appMarkerPosition + APP_ID_OFFSET, markerIdLength) === APP1_EXIF_IDENTIFIER &&
view.getUint8(appMarkerPosition + APP_ID_OFFSET + markerIdLength, false) === 0x00
);
}
function isApp1XmpMarker(view, appMarkerPosition) {
return view.getUint16(appMarkerPosition, false) === APP1_MARKER && isXmpIdentifier(view, appMarkerPosition);
}
function isXmpIdentifier(view, appMarkerPosition) {
const markerIdLength = APP1_XMP_IDENTIFIER.length;
return viewToString(view, appMarkerPosition + APP_ID_OFFSET, markerIdLength) === APP1_XMP_IDENTIFIER;
}
function isApp1ExtendedXmpMarker(view, appMarkerPosition) {
return view.getUint16(appMarkerPosition, false) === APP1_MARKER && isExtendedXmpIdentifier(view, appMarkerPosition);
}
function isExtendedXmpIdentifier(view, appMarkerPosition) {
const markerIdLength = APP1_XMP_EXTENDED_IDENTIFIER.length;
return viewToString(view, appMarkerPosition + APP_ID_OFFSET, markerIdLength) === APP1_XMP_EXTENDED_IDENTIFIER;
}
function getXmpChunkDetails(appMarkerPosition, fieldLength) {
return {
dataOffset: appMarkerPosition + XMP_DATA_OFFSET,
length: fieldLength - (XMP_DATA_OFFSET - APP_MARKER_SIZE)
};
}
function getExtendedXmpChunkDetails(appMarkerPosition, fieldLength) {
return {
dataOffset: appMarkerPosition + XMP_EXTENDED_DATA_OFFSET,
length: fieldLength - (XMP_EXTENDED_DATA_OFFSET - APP_MARKER_SIZE)
};
}
function isApp13PhotoshopMarker(view, appMarkerPosition) {
const markerIdLength = APP13_IPTC_IDENTIFIER.length;
return (
view.getUint16(appMarkerPosition, false) === APP13_MARKER &&
viewToString(view, appMarkerPosition + APP_ID_OFFSET, markerIdLength) === APP13_IPTC_IDENTIFIER &&
view.getUint8(appMarkerPosition + APP_ID_OFFSET + markerIdLength, false) === 0x00
);
}
function isAppMarker(view, appMarkerPosition) {
const appMarker = view.getUint16(appMarkerPosition, false);
return (
(appMarker >= APP0_MARKER && appMarker <= APP15_MARKER) ||
appMarker === COMMENT_MARKER ||
appMarker === SOF0_MARKER ||
appMarker === SOF2_MARKER ||
appMarker === DHT_MARKER ||
appMarker === DQT_MARKER ||
appMarker === DRI_MARKER ||
appMarker === SOS_MARKER
);
}
const IFD_EXIF = 'ExifIFDPointer';
const IFD_GPS = 'GPSInfoIFDPointer';
const IFD_INTEROP = 'InteroperabilityIFDPointer';
const exif = {
0x8825: {name: IFD_GPS},
0x8769: {name: IFD_EXIF},
0xa005: {name: IFD_INTEROP},
0x010f: {name: 'Make'},
0x0110: {name: 'Model'},
0x0112: {name: 'Orientation'},
0x011a: {name: 'XResolution'},
0x011b: {name: 'YResolution'},
0x0131: {name: 'Software'},
0x0132: {name: 'ModifyDate'},
0x8827: {name: 'ISO'},
0x9209: {name: 'Flash'},
0xa002: {name: 'PixelXDimension'},
0xa003: {name: 'PixelYDimension'}
};
const gps = {
0x0002: {name: 'GPSLatitude'},
0x0006: {name: 'GPSAltitude'},
0x0004: {name: 'GPSLongitude'}
};
const iptc = {
0x0250: {name: 'Byline'},
0x0255: {name: 'BylineTitle'},
0x026e: {name: 'Credit'},
0x0274: {name: 'CopyrightNotice'},
0x0276: {name: 'Contact'}
};
const BYTES_8BIM = 0x3842494d; // "8", "B", "I", "M"
const BYTES_8BIM_SIZE = 4;
const RESOURCE_BLOCK_HEADER_SIZE = BYTES_8BIM_SIZE + 8;
const RESOURCE_BLOCK_SIZE_OFFSET = 10;
const NAA_RESOURCE_BLOCK_TYPE = 0x0404;
const TAG_HEADER_SIZE = 5;
const TAG_CODE_OFFSET = 1;
const TAG_SIZE_OFFSET = 3;
const TAG_LEAD_BYTE = 0x1c;
const blockPadding = size => (size % 2 !== 0 ? 1 : 0);
const isNaaResourceBlock = type => type === NAA_RESOURCE_BLOCK_TYPE;
const isLeadByteMissing = (view, dataOffset) => view.getUint8(dataOffset) !== TAG_LEAD_BYTE;
function parseIptc(view, dataOffset, extraTags = {}) {
const iptc$1 = {...iptc, ...extraTags.iptc};
const {naaSize, dataOffset: newDataOffset} = getNaaResourceBlock(view, dataOffset);
return readTags(view, naaSize, newDataOffset, iptc$1);
}
function getNaaResourceBlock(view, dataOffset) {
while (dataOffset + RESOURCE_BLOCK_HEADER_SIZE <= view.byteLength) {
if (view.getUint32(dataOffset, false) !== BYTES_8BIM) throw new Error(`Not an IPTC resource block`);
const resourceType = view.getUint16(dataOffset + BYTES_8BIM_SIZE, false);
const resourceSize = view.getUint16(dataOffset + RESOURCE_BLOCK_SIZE_OFFSET, false);
if (isNaaResourceBlock(resourceType)) return {naaSize: resourceSize, dataOffset};
dataOffset += RESOURCE_BLOCK_HEADER_SIZE + resourceSize + blockPadding(resourceSize);
}
throw new Error(`No IPTC NAA resource block`);
}
function readTags(view, naaSize, dataOffset, tags) {
const result = {};
dataOffset += RESOURCE_BLOCK_HEADER_SIZE;
const endOfBlockOffset = dataOffset + naaSize;
while (dataOffset < endOfBlockOffset && dataOffset < view.byteLength) {
if (isLeadByteMissing(view, dataOffset)) continue;
const tag = view.getUint16(dataOffset + TAG_CODE_OFFSET, false);
const count = view.getUint16(dataOffset + TAG_SIZE_OFFSET, false);
const value = viewToValue(view, {offset: dataOffset + TAG_HEADER_SIZE, count});
if (value && tags[tag]) readTagValue(value, result, tags[tag]);
dataOffset += TAG_HEADER_SIZE + count;
}
return result;
}
function readTagValue(value, result, {name, parse = x => x, raw}) {
value = parse(raw ? value : arrayToString(value));
result[name] = !result[name] ? value : Array.isArray(result[name]) ? [...result[name], value] : [result[name], value];
}
const TIFF_ENDIAN_ASSERTION = 0x002a;
const TIFF_ENDIAN_ASSERTION_OFFSET = 2;
const TIFF_BYTE_ORDER = 0;
const TIFF_IFD_OFFSET = 4;
const ORDER_LITTLE_ENDIAN = 0x4949; // "I", "I"
const FIELD_SIZE = 12;
const TAG_TYPE_OFFSET = getSize('short');
const TAG_COUNT_OFFSET = TAG_TYPE_OFFSET + getSize('short');
const TAG_VALUE_OFFSET = TAG_COUNT_OFFSET + getSize('long');
function parseExif(view, tiffHeaderOffset, extraTags = {}) {
const exif$1 = {...exif, ...extraTags.exif};
const gps$1 = {...gps, ...extraTags.gps};
const littleEndian = isLittleEndian(view, tiffHeaderOffset);
const ifd0Position = findIfd0Position(view, tiffHeaderOffset, littleEndian);
return parseTiffSegment(view, ifd0Position, tiffHeaderOffset, littleEndian, exif$1, gps$1);
}
function parseTiffSegment(view, ifdPosition, tiffHeaderOffset, littleEndian, exif, gps) {
const ifd0 = readTags$1(view, exif, ifdPosition, tiffHeaderOffset, littleEndian);
if (ifd0[IFD_GPS])
Object.assign(ifd0, readTags$1(view, gps, tiffHeaderOffset + ifd0[IFD_GPS], tiffHeaderOffset, littleEndian));
if (ifd0[IFD_INTEROP])
Object.assign(ifd0, readTags$1(view, exif, tiffHeaderOffset + ifd0[IFD_INTEROP], tiffHeaderOffset, littleEndian));
const entries = view.getUint16(ifdPosition, littleEndian);
const ifd1Position = view.getUint32(ifdPosition + 2 + entries * FIELD_SIZE, littleEndian);
if (ifdPosition !== 0)
Object.assign(ifd0, readTags$1(view, exif, tiffHeaderOffset + ifd1Position, tiffHeaderOffset, littleEndian));
return ifd0;
}
/**
* IFD p. 20 - 4.6.2
*/
function readTags$1(view, tags, ifdPosition, tiffHeaderOffset, littleEndian) {
const result = {};
const fieldIterator = iterateIfdFields(view, ifdPosition, littleEndian);
for (const offset of fieldIterator) {
const tag = view.getUint16(offset, littleEndian);
const type = view.getUint16(offset + TAG_TYPE_OFFSET, littleEndian);
const count = view.getUint32(offset + TAG_COUNT_OFFSET, littleEndian);
const state = {tag, type, count, offset, tiffHeaderOffset, littleEndian};
const valueOffset = getValueOffset(view, state);
if (!tags[tag] || !valueFitsInView(view, valueOffset, state)) continue;
const value = viewToValue(view, {...state, offset: valueOffset});
const {name, parse = x => x, raw} = tags[tag];
result[name] = parse(raw ? value : parseViewValue(value, type));
}
return result;
}
/**
* Check if tag value fits in offset slot
*/
function getValueOffset(view, state) {
if (SIZES[state.type] * state.count <= getSize('long')) return state.offset + TAG_VALUE_OFFSET;
return state.tiffHeaderOffset + view.getUint32(state.offset + TAG_VALUE_OFFSET, state.littleEndian);
}
function valueFitsInView(view, valueOffset, {tiffHeaderOffset, type, count}) {
return tiffHeaderOffset + valueOffset + SIZES[type] * count <= view.byteLength;
}
/**
* IFD p. 19 - 4.6.2
* - num of IFD fields (short)
* - IFD:
* - tag (short)
* - type (short)
* - count (long)
* - value offset (long)
* - IFD...
*/
function* iterateIfdFields(view, ifdFieldOffset, littleEndian) {
const numOfIfdFields = view.getUint16(ifdFieldOffset, littleEndian);
const fieldLength = FIELD_SIZE;
for (let i = 0; i < numOfIfdFields; i++) {
const currentOffset = ifdFieldOffset + i * fieldLength + 2;
yield currentOffset;
}
}
/**
* TIFF Header p.16
* - byte order (short). `0x4949` = little = "II", `0x4d4d` = 'MM' = big
* - 42 (short). `0x002a` fixed.
* - offset of IFD (long). Minimum is `0x00000008` (8).
*/
function findIfd0Position(view, tiffHeaderOffset, littleEndian) {
const endianAssertionValue = view.getUint16(tiffHeaderOffset + TIFF_ENDIAN_ASSERTION_OFFSET, littleEndian);
if (endianAssertionValue !== TIFF_ENDIAN_ASSERTION)
throw new Error(`Invalid JPEG format: littleEndian ${littleEndian}, assertion: 0x${endianAssertionValue}`);
const ifdDistance = view.getUint32(tiffHeaderOffset + TIFF_IFD_OFFSET, littleEndian);
return tiffHeaderOffset + ifdDistance;
}
/**
* TIFF Header p.16
* - expect `0x4949` little endian.
*/
function isLittleEndian(view, tiffHeaderOffset) {
const endian = view.getUint16(tiffHeaderOffset + TIFF_BYTE_ORDER, false);
return endian === ORDER_LITTLE_ENDIAN;
}
const XMP_START = `<x:xmpmeta`;
const XMP_END = `x:xmpmeta>`;
function parseXmp(view, chunks) {
const xmp = extract(view, chunks)
.map(chunk => viewToString(chunk, 0, chunk.byteLength))
.join('\n');
const from = xmp.indexOf(XMP_START);
const to = xmp.indexOf(XMP_END) + XMP_START.length;
return {xmp: xmp.slice(from, to)};
}
function extract(view, chunks) {
if (!chunks.length) return [];
const completeChunks = [combine(view, chunks.slice(0, 1))];
if (chunks.length > 1) completeChunks.push(combine(view, chunks.slice(1)));
return completeChunks;
}
function combine(view, chunks) {
const totalLength = chunks.reduce((size, chunk) => size + chunk.length, 0);
const combinedChunks = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
const slice = view.buffer.slice(chunk.dataOffset, chunk.dataOffset + chunk.length);
combinedChunks.set(new Uint8Array(slice), offset);
offset += chunk.length;
}
return new DataView(combinedChunks.buffer);
}
const parsers = {
tiff: parseExif,
iptc: parseIptc,
xmp: parseXmp
};
/**
* @see https://www.exif.org/Exif2-2.PDF
* @see https://dev.exiv2.org/projects/exiv2/wiki/The_Metadata_in_JPEG_files
* @see https://exiftool.org/index.html
*/
async function exifer(input, opts = {}) {
let view = await prepareDataView(input);
let tags = {};
for (const {type, offset} of iterateAppMarkers(view)) {
if (opts[`skip${type}`]) continue;
Object.assign(tags, parsers[type](view, offset, opts.tags));
}
return tags;
}
async function prepareDataView(input) {
let arrayBuffer;
if (typeof File !== `undefined` && input instanceof File) {
arrayBuffer = await readFile(input);
} else if (typeof Buffer !== `undefined` && input instanceof Buffer) {
arrayBuffer = input.buffer;
} else {
arrayBuffer = input;
}
return new DataView(arrayBuffer);
}
async function readFile(file) {
const arrayBuffer = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsArrayBuffer(file);
});
return arrayBuffer;
}
export default exifer;