'use strict'; ////////////////////////////////////////////////////////////////////////// // Helpers // function error(message, code) { var err = new Error(message); err.code = code; return err; } // Convert number to 0xHH string // function to_hex(number) { var n = number.toString(16).toUpperCase(); for (var i = 2 - n.length; i > 0; i--) n = '0' + n; return '0x' + n; } function utf8_encode(str) { try { return unescape(encodeURIComponent(str)); } catch (_) { return str; } } function utf8_decode(str) { try { return decodeURIComponent(escape(str)); } catch (_) { return str; } } // Check if input is a Uint8Array // function is_uint8array(bin) { return Object.prototype.toString.call(bin) === '[object Uint8Array]'; } ////////////////////////////////////////////////////////////////////////// // Exif parser // // Input: // - jpeg_bin: Uint8Array - jpeg file // - exif_start: Number - start of TIFF header (after Exif\0\0) // - exif_end: Number - end of Exif segment // - on_entry: Number - callback // function ExifParser(jpeg_bin, exif_start, exif_end) { // Uint8Array, exif without signature (which isn't included in offsets) this.input = jpeg_bin.subarray(exif_start, exif_end); // offset correction for `on_entry` callback this.start = exif_start; // Check TIFF header (includes byte alignment and first IFD offset) var sig = String.fromCharCode.apply(null, this.input.subarray(0, 4)); if (sig !== 'II\x2A\0' && sig !== 'MM\0\x2A') { throw error('invalid TIFF signature', 'EBADDATA'); } // true if motorola (big endian) byte alignment, false if intel this.big_endian = sig[0] === 'M'; } ExifParser.prototype.each = function (on_entry) { // allow premature exit this.aborted = false; var offset = this.read_uint32(4); this.ifds_to_read = [ { id: 0, offset: offset } ]; while (this.ifds_to_read.length > 0 && !this.aborted) { var i = this.ifds_to_read.shift(); if (!i.offset) continue; this.scan_ifd(i.id, i.offset, on_entry); } }; ExifParser.prototype.filter = function (on_entry) { var ifds = {}; // make sure IFD0 always exists ifds.ifd0 = { id: 0, entries: [] }; this.each(function (entry) { if (on_entry(entry) === false && !entry.is_subifd_link) return; if (entry.is_subifd_link && entry.count !== 1 && entry.format !== 4) return; // filter out bogus links if (!ifds['ifd' + entry.ifd]) { ifds['ifd' + entry.ifd] = { id: entry.ifd, entries: [] }; } ifds['ifd' + entry.ifd].entries.push(entry); }); // thumbnails are not supported just yet, so delete all information related to it delete ifds.ifd1; // Calculate output size var length = 8; Object.keys(ifds).forEach(function (ifd_no) { length += 2; ifds[ifd_no].entries.forEach(function (entry) { length += 12 + (entry.data_length > 4 ? Math.ceil(entry.data_length / 2) * 2 : 0); }); length += 4; }); this.output = new Uint8Array(length); this.output[0] = this.output[1] = (this.big_endian ? 'M' : 'I').charCodeAt(0); this.write_uint16(2, 0x2A); var offset = 8; var self = this; this.write_uint32(4, offset); Object.keys(ifds).forEach(function (ifd_no) { ifds[ifd_no].written_offset = offset; var ifd_start = offset; var ifd_end = ifd_start + 2 + ifds[ifd_no].entries.length * 12 + 4; offset = ifd_end; self.write_uint16(ifd_start, ifds[ifd_no].entries.length); ifds[ifd_no].entries.sort(function (a, b) { // IFD entries must be in order of increasing tag IDs return a.tag - b.tag; }).forEach(function (entry, idx) { var entry_offset = ifd_start + 2 + idx * 12; self.write_uint16(entry_offset, entry.tag); self.write_uint16(entry_offset + 2, entry.format); self.write_uint32(entry_offset + 4, entry.count); if (entry.is_subifd_link) { // filled in later if (ifds['ifd' + entry.tag]) ifds['ifd' + entry.tag].link_offset = entry_offset + 8; } else if (entry.data_length <= 4) { self.output.set( self.input.subarray(entry.data_offset - self.start, entry.data_offset - self.start + 4), entry_offset + 8 ); } else { self.write_uint32(entry_offset + 8, offset); self.output.set( self.input.subarray(entry.data_offset - self.start, entry.data_offset - self.start + entry.data_length), offset ); offset += Math.ceil(entry.data_length / 2) * 2; } }); var next_ifd = ifds['ifd' + (ifds[ifd_no].id + 1)]; if (next_ifd) next_ifd.link_offset = ifd_end - 4; }); Object.keys(ifds).forEach(function (ifd_no) { if (ifds[ifd_no].written_offset && ifds[ifd_no].link_offset) { self.write_uint32(ifds[ifd_no].link_offset, ifds[ifd_no].written_offset); } }); if (this.output.length !== offset) throw error('internal error: incorrect buffer size allocated'); return this.output; }; ExifParser.prototype.read_uint16 = function (offset) { var d = this.input; if (offset + 2 > d.length) throw error('unexpected EOF', 'EBADDATA'); return this.big_endian ? d[offset] * 0x100 + d[offset + 1] : d[offset] + d[offset + 1] * 0x100; }; ExifParser.prototype.read_uint32 = function (offset) { var d = this.input; if (offset + 4 > d.length) throw error('unexpected EOF', 'EBADDATA'); return this.big_endian ? d[offset] * 0x1000000 + d[offset + 1] * 0x10000 + d[offset + 2] * 0x100 + d[offset + 3] : d[offset] + d[offset + 1] * 0x100 + d[offset + 2] * 0x10000 + d[offset + 3] * 0x1000000; }; ExifParser.prototype.write_uint16 = function (offset, value) { var d = this.output; if (this.big_endian) { d[offset] = (value >>> 8) & 0xFF; d[offset + 1] = value & 0xFF; } else { d[offset] = value & 0xFF; d[offset + 1] = (value >>> 8) & 0xFF; } }; ExifParser.prototype.write_uint32 = function (offset, value) { var d = this.output; if (this.big_endian) { d[offset] = (value >>> 24) & 0xFF; d[offset + 1] = (value >>> 16) & 0xFF; d[offset + 2] = (value >>> 8) & 0xFF; d[offset + 3] = value & 0xFF; } else { d[offset] = value & 0xFF; d[offset + 1] = (value >>> 8) & 0xFF; d[offset + 2] = (value >>> 16) & 0xFF; d[offset + 3] = (value >>> 24) & 0xFF; } }; ExifParser.prototype.is_subifd_link = function (ifd, tag) { return (ifd === 0 && tag === 0x8769) || // SubIFD (ifd === 0 && tag === 0x8825) || // GPS Info (ifd === 0x8769 && tag === 0xA005); // Interop IFD }; // Returns byte length of a single component of a given format // ExifParser.prototype.exif_format_length = function (format) { switch (format) { case 1: // byte case 2: // ascii case 6: // sbyte case 7: // undefined return 1; case 3: // short case 8: // sshort return 2; case 4: // long case 9: // slong case 11: // float return 4; case 5: // rational case 10: // srational case 12: // double return 8; default: // unknown type return 0; } }; // Reads Exif data // ExifParser.prototype.exif_format_read = function (format, offset) { var v; switch (format) { case 1: // byte case 2: // ascii v = this.input[offset]; return v; case 6: // sbyte v = this.input[offset]; return v | (v & 0x80) * 0x1fffffe; case 3: // short v = this.read_uint16(offset); return v; case 8: // sshort v = this.read_uint16(offset); return v | (v & 0x8000) * 0x1fffe; case 4: // long v = this.read_uint32(offset); return v; case 9: // slong v = this.read_uint32(offset); return v | 0; case 5: // rational case 10: // srational case 11: // float case 12: // double return null; // not implemented case 7: // undefined return null; // blob default: // unknown type return null; } }; ExifParser.prototype.scan_ifd = function (ifd_no, offset, on_entry) { var entry_count = this.read_uint16(offset); offset += 2; for (var i = 0; i < entry_count; i++) { var tag = this.read_uint16(offset); var format = this.read_uint16(offset + 2); var count = this.read_uint32(offset + 4); var comp_length = this.exif_format_length(format); var data_length = count * comp_length; var data_offset = data_length <= 4 ? offset + 8 : this.read_uint32(offset + 8); var is_subifd_link = false; if (data_offset + data_length > this.input.length) { throw error('unexpected EOF', 'EBADDATA'); } var value = []; var comp_offset = data_offset; for (var j = 0; j < count; j++, comp_offset += comp_length) { var item = this.exif_format_read(format, comp_offset); if (item === null) { value = null; break; } value.push(item); } if (Array.isArray(value) && format === 2) { try { value = utf8_decode(String.fromCharCode.apply(null, value)); } catch (_) { value = null; } if (value && value[value.length - 1] === '\0') value = value.slice(0, -1); } if (this.is_subifd_link(ifd_no, tag)) { if (Array.isArray(value) && Number.isInteger(value[0]) && value[0] > 0) { this.ifds_to_read.push({ id: tag, offset: value[0] }); is_subifd_link = true; } } var entry = { is_big_endian: this.big_endian, ifd: ifd_no, tag: tag, format: format, count: count, entry_offset: offset + this.start, data_length: data_length, data_offset: data_offset + this.start, value: value, is_subifd_link: is_subifd_link }; if (on_entry(entry) === false) { this.aborted = true; return; } offset += 12; } if (ifd_no === 0) { this.ifds_to_read.push({ id: 1, offset: this.read_uint32(offset) }); } }; // Check whether input is a JPEG image // // Input: // - jpeg_bin: Uint8Array - jpeg file // // Returns true if it is and false otherwise // module.exports.is_jpeg = function (jpeg_bin) { return jpeg_bin.length >= 4 && jpeg_bin[0] === 0xFF && jpeg_bin[1] === 0xD8 && jpeg_bin[2] === 0xFF; }; // Call an iterator on each segment in the given JPEG image // // Input: // - jpeg_bin: Uint8Array - jpeg file // - on_segment: Function - callback executed on each JPEG marker segment // - segment: Object // - code: Number - marker type (2nd byte, e.g. 0xE0 for APP0) // - offset: Number - offset of the first byte (0xFF) relative to `jpeg_bin` start // - length: Number - length of the entire marker segment including first two bytes and length // - 2 for standalone markers // - 4+length for markers with data // // Iteration stops when `EOI` (0xFFD9) marker is reached or if `on_segment` // function returns `false`. // module.exports.jpeg_segments_each = function (jpeg_bin, on_segment) { if (!is_uint8array(jpeg_bin)) { throw error('Invalid argument (jpeg_bin), Uint8Array expected', 'EINVAL'); } if (typeof on_segment !== 'function') { throw error('Invalid argument (on_segment), Function expected', 'EINVAL'); } if (!module.exports.is_jpeg(jpeg_bin)) { throw error('Unknown file format', 'ENOTJPEG'); } var offset = 0, length = jpeg_bin.length, inside_scan = false; for (;;) { var segment_code, segment_length; if (offset + 1 >= length) throw error('Unexpected EOF', 'EBADDATA'); var byte1 = jpeg_bin[offset]; var byte2 = jpeg_bin[offset + 1]; if (byte1 === 0xFF && byte2 === 0xFF) { // padding segment_code = 0xFF; segment_length = 1; } else if (byte1 === 0xFF && byte2 !== 0) { // marker segment_code = byte2; segment_length = 2; if ((0xD0 <= segment_code && segment_code <= 0xD9) || segment_code === 0x01) { // standalone markers, according to JPEG 1992, // http://www.w3.org/Graphics/JPEG/itu-t81.pdf, see Table B.1 } else { if (offset + 3 >= length) throw error('Unexpected EOF', 'EBADDATA'); segment_length += jpeg_bin[offset + 2] * 0x100 + jpeg_bin[offset + 3]; if (segment_length < 2) throw error('Invalid segment length', 'EBADDATA'); if (offset + segment_length - 1 >= length) throw error('Unexpected EOF', 'EBADDATA'); } if (inside_scan) { if (segment_code >= 0xD0 && segment_code <= 0xD7) { // reset markers } else { inside_scan = false; } } if (segment_code === 0xDA /* SOS */) inside_scan = true; } else if (inside_scan) { // entropy-encoded segment for (var pos = offset + 1; ; pos++) { // scan until we find FF if (pos >= length) throw error('Unexpected EOF', 'EBADDATA'); if (jpeg_bin[pos] === 0xFF) { if (pos + 1 >= length) throw error('Unexpected EOF', 'EBADDATA'); if (jpeg_bin[pos + 1] !== 0) { segment_code = 0; segment_length = pos - offset; break; } } } } else { throw error('Unexpected byte at segment start: ' + to_hex(byte1) + ' (offset ' + to_hex(offset) + ')', 'EBADDATA'); } if (on_segment({ code: segment_code, offset: offset, length: segment_length }) === false) break; if (segment_code === 0xD9 /* EOI */) break; offset += segment_length; } }; // Replace or remove segments in the given JPEG image // // Input: // - jpeg_bin: Uint8Array - jpeg file // - on_segment: Function - callback executed on each JPEG marker segment // - segment: Object // - code: Number - marker type (2nd byte, e.g. 0xE0 for APP0) // - offset: Number - offset of the first byte (0xFF) relative to `jpeg_bin` start // - length: Number - length of the entire marker segment including first two bytes and length // - 2 for standalone markers // - 4+length for markers with data // // `on_segment` function should return one of the following: // - `false` - segment is removed from the output // - Uint8Array - segment is replaced with the new data // - [ Uint8Array ] - segment is replaced with the new data // - anything else - segment is copied to the output as is // // Any data after `EOI` (0xFFD9) marker is removed. // module.exports.jpeg_segments_filter = function (jpeg_bin, on_segment) { if (!is_uint8array(jpeg_bin)) { throw error('Invalid argument (jpeg_bin), Uint8Array expected', 'EINVAL'); } if (typeof on_segment !== 'function') { throw error('Invalid argument (on_segment), Function expected', 'EINVAL'); } var ranges = []; var out_length = 0; module.exports.jpeg_segments_each(jpeg_bin, function (segment) { var new_segment = on_segment(segment); if (is_uint8array(new_segment)) { ranges.push({ data: new_segment }); out_length += new_segment.length; } else if (Array.isArray(new_segment)) { new_segment.filter(is_uint8array).forEach(function (s) { ranges.push({ data: s }); out_length += s.length; }); } else if (new_segment !== false) { var new_range = { start: segment.offset, end: segment.offset + segment.length }; if (ranges.length > 0 && ranges[ranges.length - 1].end === new_range.start) { ranges[ranges.length - 1].end = new_range.end; } else { ranges.push(new_range); } out_length += segment.length; } }); var result = new Uint8Array(out_length); var offset = 0; ranges.forEach(function (range) { var data = range.data || jpeg_bin.subarray(range.start, range.end); result.set(data, offset); offset += data.length; }); return result; }; // Call an iterator on each Exif entry in the given JPEG image // // Input: // - jpeg_bin: Uint8Array - jpeg file // - on_entry: Function - callback executed on each Exif entry // - entry: Object // - is_big_endian: Boolean - whether Exif uses big or little endian byte alignment // - ifd: Number - IFD identifier (0 for IFD0, 1 for IFD1, 0x8769 for SubIFD, // 0x8825 for GPS Info, 0xA005 for Interop IFD) // - tag: Number - exif entry tag (0x0110 - camera name, 0x0112 - orientation, etc. - see Exif spec) // - format: Number - exif entry format (1 - byte, 2 - ascii, 3 - short, etc. - see Exif spec) // - count: Number - number of components of the given format inside data // (usually 1, or string length for ascii format) // - entry_offset: Number - start of Exif entry (entry length is always 12, so not included) // - data_offset: Number - start of data attached to Exif entry (will overlap with entry if length <= 4) // - data_length: Number - length of data attached to Exif entry // - value: Array|String|Null - our best attempt at parsing data (not all formats supported right now) // - is_subifd_link: Boolean - whether this entry is recognized to be a link to subifd (can't filter these out) // // Iteration stops early if iterator returns `false`. // // If Exif wasn't found anywhere (before start of the image data, SOS), // iterator is never executed. // module.exports.jpeg_exif_tags_each = function (jpeg_bin, on_exif_entry) { if (!is_uint8array(jpeg_bin)) { throw error('Invalid argument (jpeg_bin), Uint8Array expected', 'EINVAL'); } if (typeof on_exif_entry !== 'function') { throw error('Invalid argument (on_exif_entry), Function expected', 'EINVAL'); } /* eslint-disable consistent-return */ module.exports.jpeg_segments_each(jpeg_bin, function (segment) { if (segment.code === 0xDA /* SOS */) return false; // look for APP1 segment and compare header with 'Exif\0\0' if (segment.code === 0xE1 && segment.length >= 10 && jpeg_bin[segment.offset + 4] === 0x45 && jpeg_bin[segment.offset + 5] === 0x78 && jpeg_bin[segment.offset + 6] === 0x69 && jpeg_bin[segment.offset + 7] === 0x66 && jpeg_bin[segment.offset + 8] === 0x00 && jpeg_bin[segment.offset + 9] === 0x00) { new ExifParser(jpeg_bin, segment.offset + 10, segment.offset + segment.length).each(on_exif_entry); return false; } }); }; // Remove Exif entries in the given JPEG image // // Input: // - jpeg_bin: Uint8Array - jpeg file // - on_entry: Function - callback executed on each Exif entry // - entry: Object // - is_big_endian: Boolean - whether Exif uses big or little endian byte alignment // - ifd: Number - IFD identifier (0 for IFD0, 1 for IFD1, 0x8769 for SubIFD, // 0x8825 for GPS Info, 0xA005 for Interop IFD) // - tag: Number - exif entry tag (0x0110 - camera name, 0x0112 - orientation, etc. - see Exif spec) // - format: Number - exif entry format (1 - byte, 2 - ascii, 3 - short, etc. - see Exif spec) // - count: Number - number of components of the given format inside data // (usually 1, or string length for ascii format) // - entry_offset: Number - start of Exif entry (entry length is always 12, so not included) // - data_offset: Number - start of data attached to Exif entry (will overlap with entry if length <= 4) // - data_length: Number - length of data attached to Exif entry // - value: Array|String|Null - our best attempt at parsing data (not all formats supported right now) // - is_subifd_link: Boolean - whether this entry is recognized to be a link to subifd (can't filter these out) // // This function removes following from Exif: // - all entries where iterator returned false (except subifd links which are mandatory) // - IFD1 and thumbnail image (the purpose of this function is to reduce file size, // so thumbnail is usually the first thing to go) // - all other data that isn't in IFD0, SubIFD, GPSIFD, InteropIFD // (theoretically possible proprietary extensions, I haven't seen any of these yet) // // Changing data inside Exif entries is NOT supported yet (modifying `entry` object inside callback may break stuff). // // If Exif wasn't found anywhere (before start of the image data, SOS), // iterator is never executed, and original JPEG is returned as is. // module.exports.jpeg_exif_tags_filter = function (jpeg_bin, on_exif_entry) { if (!is_uint8array(jpeg_bin)) { throw error('Invalid argument (jpeg_bin), Uint8Array expected', 'EINVAL'); } if (typeof on_exif_entry !== 'function') { throw error('Invalid argument (on_exif_entry), Function expected', 'EINVAL'); } var stop_search = false; return module.exports.jpeg_segments_filter(jpeg_bin, function (segment) { if (stop_search) return; if (segment.code === 0xDA /* SOS */) stop_search = true; // look for APP1 segment and compare header with 'Exif\0\0' if (segment.code === 0xE1 && segment.length >= 10 && jpeg_bin[segment.offset + 4] === 0x45 && jpeg_bin[segment.offset + 5] === 0x78 && jpeg_bin[segment.offset + 6] === 0x69 && jpeg_bin[segment.offset + 7] === 0x66 && jpeg_bin[segment.offset + 8] === 0x00 && jpeg_bin[segment.offset + 9] === 0x00) { var new_exif = new ExifParser(jpeg_bin, segment.offset + 10, segment.offset + segment.length) .filter(on_exif_entry); if (!new_exif) return false; var header = new Uint8Array(10); header.set(jpeg_bin.slice(segment.offset, segment.offset + 10)); header[2] = ((new_exif.length + 8) >>> 8) & 0xFF; header[3] = (new_exif.length + 8) & 0xFF; stop_search = true; return [ header, new_exif ]; } }); }; // Inserts a custom comment marker segment into JPEG file. // // Input: // - jpeg_bin: Uint8Array - jpeg file // - comment: String // // Comment is inserted after first two bytes (FFD8, SOI). // // If JFIF (APP0) marker exists immediately after SOI (as mandated by the JFIF // spec), we insert comment after it instead. // module.exports.jpeg_add_comment = function (jpeg_bin, comment) { var comment_inserted = false, segment_count = 0; return module.exports.jpeg_segments_filter(jpeg_bin, function (segment) { segment_count++; if (segment_count === 1 && segment.code === 0xD8 /* SOI */) return; if (segment_count === 2 && segment.code === 0xE0 /* APP0 */) return; if (comment_inserted) return; comment = utf8_encode(comment); // comment segment var csegment = new Uint8Array(5 + comment.length); var offset = 0; csegment[offset++] = 0xFF; csegment[offset++] = 0xFE; csegment[offset++] = ((comment.length + 3) >>> 8) & 0xFF; csegment[offset++] = (comment.length + 3) & 0xFF; comment.split('').forEach(function (c) { csegment[offset++] = c.charCodeAt(0) & 0xFF; }); csegment[offset++] = 0; comment_inserted = true; return [ csegment, jpeg_bin.subarray(segment.offset, segment.offset + segment.length) ]; }); };