UNPKG

exifer

Version:

A lightweight Exif image meta-data decipher

499 lines (435 loc) 17.3 kB
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;