diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3b48e..c33079f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added a GIF parser to bitjs.image. +- Added GIF and JPEG parsers to bitjs.image. - Added a skip() method to ByteStream. ### Changed diff --git a/README.md b/README.md index 08ea990..737f43b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Includes: * bitjs/codecs: Get the codec info of media containers in a ISO RFC6381 MIME type string * bitjs/file: Detect the type of file from its binary signature. - * bitjs/image: Parsing GIF. Conversion of WebP to PNG or JPEG. + * bitjs/image: Parsing GIF, JPEG. Conversion of WebP to PNG or JPEG. * bitjs/io: Low-level classes for interpreting binary data (BitStream ByteStream). For example, reading or peeking at N bits at a time. @@ -108,8 +108,8 @@ const mimeType = findMimeType(someArrayBuffer); ### bitjs.image This package includes code for dealing with binary images. It includes general event-based parsers -for images (GIF only, at the moment). It also includes a module for converting WebP images into -alternative raster graphics formats (PNG/JPG). This latter module is deprecated, now that WebP +for images (GIF and JPEG only, at the moment). It also includes a module for converting WebP images +into alternative raster graphics formats (PNG/JPG). This latter module is deprecated, now that WebP images are well-supported in all browsers. #### GIF Parser @@ -117,8 +117,8 @@ images are well-supported in all browsers. import { GifParser } from './bitjs/image/parsers/gif.js' const parser = new GifParser(someArrayBuffer); -parser.addEventListener('application_extension', evt => { - const appId = evt.applicationExtension.applicationIdentifier +parser.onApplicationExtension(evt => { + const appId = evt.applicationExtension.applicationIdentifier; const appAuthCode = new TextDecoder().decode( evt.applicationExtension.applicationAuthenticationCode); if (appId === 'XMP Data' && appAuthCode === 'XMP') { @@ -130,6 +130,20 @@ parser.addEventListener('application_extension', evt => { parser.start(); ``` +#### JPEG Parser +```javascript +import { JpegParser } from './bitjs/image/parsers/jpeg.js' +import { ExifTagNumber } from './bitjs/image/parsers/exif.js'; + +const parser = new JpegParser(someArrayBuffer); +let exif; +const parser = new JpegParser(ab); +parser.onApp1Exif(evt => { + console.log(evt.exifValueMap.get(ExifTagNumber.IMAGE_DESCRIPTION).stringValue); +}); +await parser.start(); +``` + #### WebP Converter ```javascript import { convertWebPtoPNG, convertWebPtoJPG } from './bitjs/image/webp-shim/webp-shim.js'; diff --git a/image/parsers/README.md b/image/parsers/README.md index 5d86ba9..b656dae 100644 --- a/image/parsers/README.md +++ b/image/parsers/README.md @@ -1,6 +1,6 @@ General-purpose, event-based parsers for digital images. -Currently only supports GIF. +Currently only supports GIF and JPEG. Some nice implementations for HEIF, JPEG, PNG, TIFF here: https://github.com/MikeKovarik/exifr/tree/master/src/file-parsers \ No newline at end of file diff --git a/image/parsers/exif.js b/image/parsers/exif.js new file mode 100644 index 0000000..0719dea --- /dev/null +++ b/image/parsers/exif.js @@ -0,0 +1,234 @@ +import { ByteStream } from '../../io/bytestream.js'; + +/** @enum {number} */ +export const ExifTagNumber = { + // Tags used by IFD0. + IMAGE_DESCRIPTION: 0x010e, + MAKE: 0x010f, + MODEL: 0x0110, + ORIENTATION: 0x0112, + X_RESOLUTION: 0x011a, + Y_RESOLUTION: 0x011b, + RESOLUTION_UNIT: 0x0128, + SOFTWARE: 0x0131, + DATE_TIME: 0x0132, + WHITE_POINT: 0x013e, + PRIMARY_CHROMATICITIES: 0x013f, + Y_CB_CR_COEFFICIENTS: 0x0211, + Y_CB_CR_POSITIONING: 0x0213, + REFERENCE_BLACK_WHITE: 0x0214, + COPYRIGHT: 0x8298, + EXIF_OFFSET: 0x8769, + + // Tags used by Exif SubIFD. + EXPOSURE_TIME: 0x829a, + F_NUMBER: 0x829d, + EXPOSURE_PROGRAM: 0x8822, + ISO_SPEED_RATINGS: 0x8827, + EXIF_VERSION: 0x9000, + DATE_TIME_ORIGINAL: 0x9003, + DATE_TIME_DIGITIZED: 0x9004, + COMPONENT_CONFIGURATION: 0x9101, + COMPRESSED_BITS_PER_PIXEL: 0x9102, + SHUTTER_SPEED_VALUE: 0x9201, + APERTURE_VALUE: 0x9202, + BRIGHTNESS_VALUE: 0x9203, + EXPOSURE_BIAS_VALUE: 0x9204, + MAX_APERTURE_VALUE: 0x9205, + SUBJECT_DISTANCE: 0x9206, + METERING_MODE: 0x9207, + LIGHT_SOURCE: 0x9208, + FLASH: 0x9209, + FOCAL_LENGTH: 0x920a, + MAKER_NOTE: 0x927c, + USER_COMMENT: 0x9286, + FLASH_PIX_VERSION: 0xa000, + COLOR_SPACE: 0xa001, + EXIF_IMAGE_WIDTH: 0xa002, + EXIF_IMAGE_HEIGHT: 0xa003, + RELATED_SOUND_FILE: 0xa004, + EXIF_INTEROPERABILITY_OFFSET: 0xa005, + FOCAL_PLANE_X_RESOLUTION: 0xa20e, + FOCAL_PLANE_Y_RESOLUTION: 0x20f, + FOCAL_PLANE_RESOLUTION_UNIT: 0xa210, + SENSING_METHOD: 0xa217, + FILE_SOURCE: 0xa300, + SCENE_TYPE: 0xa301, + + // Tags used by IFD1. + IMAGE_WIDTH: 0x0100, + IMAGE_LENGTH: 0x0101, + BITS_PER_SAMPLE: 0x0102, + COMPRESSION: 0x0103, + PHOTOMETRIC_INTERPRETATION: 0x0106, + STRIP_OFFSETS: 0x0111, + SAMPLES_PER_PIXEL: 0x0115, + ROWS_PER_STRIP: 0x0116, + STRIP_BYTE_COUNTS: 0x0117, + // X_RESOLUTION, Y_RESOLUTION + PLANAR_CONFIGURATION: 0x011c, + // RESOLUTION_UNIT + JPEG_IF_OFFSET: 0x0201, + JPEG_IF_BYTE_COUNT: 0x0202, + // Y_CB_CR_COEFFICIENTS + Y_CB_CR_SUB_SAMPLING: 0x0212, + // Y_CB_CR_POSITIONING, REFERENCE_BLACK_WHITE +}; + +/** @enum {number} */ +export const ExifDataFormat = { + UNSIGNED_BYTE: 1, + ASCII_STRING: 2, + UNSIGNED_SHORT: 3, + UNSIGNED_LONG: 4, + UNSIGNED_RATIONAL: 5, + SIGNED_BYTE: 6, + UNDEFINED: 7, + SIGNED_SHORT: 8, + SIGNED_LONG: 9, + SIGNED_RATIONAL: 10, + SINGLE_FLOAT: 11, + DOUBLE_FLOAT: 12, +}; + +/** + * @typedef ExifValue + * @property {ExifTagNumber} tagNumber The numerical value of the tag. + * @property {string=} tagName A string representing the tag number. + * @property {ExifDataFormat} dataFormat The data format. + * @property {number=} numericalValue Populated for SIGNED/UNSIGNED BYTE/SHORT/LONG/FLOAT. + * @property {string=} stringValue Populated only for ASCII_STRING. + * @property {number=} numeratorValue Populated only for SIGNED/UNSIGNED RATIONAL. + * @property {number=} denominatorValue Populated only for SIGNED/UNSIGNED RATIONAL. + * @property {number=} numComponents Populated only for UNDEFINED data format. + * @property {number=} offsetValue Populated only for UNDEFINED data format. + */ + +/** + * @param {number} tagNumber + * @param {string} type + * @param {number} len + * @param {number} dataVal + */ +function warnBadLength(tagNumber, type, len, dataVal) { + const hexTag = tagNumber.toString(16); + console.warn(`Tag 0x${hexTag} is ${type} with len=${len} and data=${dataVal}`); +} + +/** + * @param {ByteStream} stream + * @param {ByteStream} lookAheadStream + * @param {boolean} debug + * @returns {ExifValue} + */ +export function getExifValue(stream, lookAheadStream, DEBUG = false) { + const tagNumber = stream.readNumber(2); + let tagName = findNameWithValue(ExifTagNumber, tagNumber); + if (!tagName) { + tagName = `UNKNOWN (0x${tagNumber.toString(16)})`; + } + + let dataFormat = stream.readNumber(2); + + // Handle bad types for special tags. + if (tagNumber === ExifTagNumber.EXIF_OFFSET) { + dataFormat = ExifDataFormat.UNSIGNED_LONG; + } + + const dataFormatName = findNameWithValue(ExifDataFormat, dataFormat); + if (!dataFormatName) throw `Invalid data format: ${dataFormat}`; + + /** @type {ExifValue} */ + const exifValue = { + tagNumber, + tagName, + dataFormat, + }; + + let len = stream.readNumber(4); + switch (dataFormat) { + case ExifDataFormat.UNSIGNED_BYTE: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekNumber(4)); + } + exifValue.numericalValue = stream.readNumber(1); + stream.skip(3); + break; + case ExifDataFormat.ASCII_STRING: + if (len <= 4) { + exifValue.stringValue = stream.readString(4); + } else { + const strOffset = stream.readNumber(4); + exifValue.stringValue = lookAheadStream.tee().skip(strOffset).readString(len - 1); + } + break; + case ExifDataFormat.UNSIGNED_SHORT: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekNumber(4)); + } + exifValue.numericalValue = stream.readNumber(2); + stream.skip(2); + break; + case ExifDataFormat.UNSIGNED_LONG: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekNumber(4)); + } + exifValue.numericalValue = stream.readNumber(4); + break; + case ExifDataFormat.UNSIGNED_RATIONAL: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekNumber(4)); + } + + const uratStream = lookAheadStream.tee().skip(stream.readNumber(4)); + exifValue.numeratorValue = uratStream.readNumber(4); + exifValue.denominatorValue = uratStream.readNumber(4); + break; + case ExifDataFormat.SIGNED_BYTE: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekSignedNumber(4)); + } + exifValue.numericalValue = stream.readSignedNumber(1); + stream.skip(3); + break; + case ExifDataFormat.UNDEFINED: + exifValue.numComponents = len; + exifValue.offsetValue = stream.readNumber(4); + break; + case ExifDataFormat.SIGNED_SHORT: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekSignedNumber(4)); + } + exifValue.numericalValue = stream.readSignedNumber(2); + stream.skip(2); + break; + case ExifDataFormat.SIGNED_LONG: + if (len !== 1) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekSignedNumber(4)); + } + exifValue.numericalValue = stream.readSignedNumber(4); + break; + case ExifDataFormat.SIGNED_RATIONAL: + if (len !== 1 && DEBUG) { + warnBadLength(tagNumber, dataFormatName, len, stream.peekNumber(4)); + } + + const ratStream = lookAheadStream.tee().skip(stream.readNumber(4)); + exifValue.numeratorValue = ratStream.readSignedNumber(4); + exifValue.denominatorValue = ratStream.readSignedNumber(4); + break; + default: + throw `Bad data format: ${dataFormat}`; + } + return exifValue; +} + +/** + * @param {Object} obj A numeric enum. + * @param {number} valToFind The value to find. + * @returns {string|null} + */ +function findNameWithValue(obj, valToFind) { + const entry = Object.entries(obj).find(([k,v]) => v === valToFind); + return entry ? entry[0] : null; +} diff --git a/image/parsers/jpeg.js b/image/parsers/jpeg.js new file mode 100644 index 0000000..855caa3 --- /dev/null +++ b/image/parsers/jpeg.js @@ -0,0 +1,612 @@ +/* + * jpeg.js + * + * An event-based parser for JPEG images. + * + * Licensed under the MIT License + * + * Copyright(c) 2024 Google Inc. + */ + +import { ByteStream } from '../../io/bytestream.js'; +import { ExifTagNumber, getExifValue } from './exif.js'; + +/** + * @typedef {import('./exif.js').ExifValue} ExifValue + */ + +// https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format +// https://www.media.mit.edu/pia/Research/deepview/exif.html +// https://mykb.cipindanci.com/archive/SuperKB/1294/JPEG%20File%20Layout%20and%20Format.htm +// https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf + +let DEBUG = false; + +/** @enum {string} */ +export const JpegParseEventType = { + APP0_MARKER: 'app0_marker', + APP0_EXTENSION: 'app0_extension', + APP1_EXIF: 'app1_exif', + DEFINE_QUANTIZATION_TABLE: 'define_quantization_table', + DEFINE_HUFFMAN_TABLE: 'define_huffman_table', + START_OF_FRAME: 'start_of_frame', + START_OF_SCAN: 'start_of_scan', +} + +/** @enum {number} */ +const JpegSegmentType = { + SOF0: 0xC0, + SOF1: 0xC1, + SOF2: 0xC2, + DHT: 0xC4, + SOI: 0xD8, + EOI: 0xD9, + SOS: 0xDA, + DQT: 0xDB, + APP0: 0xE0, + APP1: 0xE1, +}; + +/** + * @param {Uint8Array} bytes An array of bytes of size 2. + * @returns {JpegSegmentType} Returns the second byte in bytes. + */ +function getJpegMarker(bytes) { + if (bytes.byteLength < 2) throw `Bad bytes length: ${bytes.byteLength}`; + if (bytes[0] !== 0xFF) throw `Bad marker, first byte=0x${bytes[0].toString(16)}`; + return bytes[1]; +} + +/** @enum {number} */ +export const JpegDensityUnits = { + NO_UNITS: 0, + PIXELS_PER_INCH: 1, + PIXELS_PER_CM: 2, +}; + +/** + * @typedef JpegApp0Marker + * @property {string} jfifVersion Like '1.02'. + * @property {JpegDensityUnits} densityUnits + * @property {number} xDensity + * @property {number} yDensity + * @property {number} xThumbnail + * @property {number} yThumbnail + * @property {Uint8Array} thumbnailData RGB data. Size is 3 x thumbnailWidth x thumbnailHeight. + */ + +export class JpegApp0MarkerEvent extends Event { + /** @param {JpegApp0Marker} */ + constructor(segment) { + super(JpegParseEventType.APP0_MARKER); + /** @type {JpegApp0Marker} */ + this.app0Marker = segment; + } +} + +/** @enum {number} */ +export const JpegExtensionThumbnailFormat = { + JPEG: 0x10, + ONE_BYTE_PER_PIXEL_PALETTIZED: 0x11, + THREE_BYTES_PER_PIXEL_RGB: 0x13, +}; + +/** + * @typedef JpegApp0Extension + * @property {JpegExtensionThumbnailFormat} thumbnailFormat + * @property {Uint8Array} thumbnailData Raw thumbnail data + */ + +export class JpegApp0ExtensionEvent extends Event { + /** @param {JpegApp0Extension} */ + constructor(segment) { + super(JpegParseEventType.APP0_EXTENSION); + /** @type {JpegApp0Extension} */ + this.app0Extension = segment; + } +} + +export class JpegApp1ExifEvent extends Event { + /** @param {Map} exifValueMap */ + constructor(exifValueMap) { + super(JpegParseEventType.APP1_EXIF); + /** @type {Map} */ + this.exifValueMap = exifValueMap; + } +} + +/** + * @typedef JpegDefineQuantizationTable + * @property {number} tableNumber Table/component number. + * @property {number} precision (0=byte, 1=word). + * @property {number[]} tableValues 64 numbers representing the quantization table. + */ + +export class JpegDefineQuantizationTableEvent extends Event { + /** @param {JpegDefineQuantizationTable} table */ + constructor(table) { + super(JpegParseEventType.DEFINE_QUANTIZATION_TABLE); + /** @type {JpegDefineQuantizationTable} */ + this.quantizationTable = table; + } +} + +/** @enum {number} */ +export const JpegHuffmanTableType = { + DC: 0, + AC: 1, +}; + +/** + * @typedef JpegDefineHuffmanTable + * @property {number} tableNumber Table/component number (0-3). + * @property {JpegHuffmanTableType} tableType Either DC or AC. + * @property {number[]} numberOfSymbols A 16-byte array specifying the # of symbols of each length. + * @property {number[]} symbols + */ + +export class JpegDefineHuffmanTableEvent extends Event { + /** @param {JpegDefineHuffmanTable} table */ + constructor(table) { + super(JpegParseEventType.DEFINE_HUFFMAN_TABLE); + /** @type {JpegDefineHuffmanTable} */ + this.huffmanTable = table; + } +} + +/** @enum {number} */ +const JpegDctType = { + BASELINE: 0, + EXTENDED_SEQUENTIAL: 1, + PROGRESSIVE: 2, +}; + +/** @enum {number} */ +const JpegComponentType = { + Y: 1, + CB: 2, + CR: 3, + I: 4, + Q: 5, +}; + +/** + * @typedef JpegComponentDetail + * @property {JpegComponentType} componentId + * @property {number} verticalSamplingFactor + * @property {number} horizontalSamplingFactor + * @property {number} quantizationTableNumber + */ + +/** + * @typedef JpegStartOfFrame + * @property {JpegDctType} dctType + * @property {number} dataPrecision + * @property {number} imageHeight + * @property {number} imageWidth + * @property {number} numberOfComponents Usually 1, 3, or 4. + * @property {JpegComponentDetail[]} componentDetails + */ + +export class JpegStartOfFrameEvent extends Event { + /** @param {JpegStartOfFrame} sof */ + constructor(sof) { + super(JpegParseEventType.START_OF_FRAME); + /** @type {JpegStartOfFrame} */ + this.startOfFrame = sof; + } +} + +/** + * @typedef JpegStartOfScan + * @property {number} componentsInScan + * @property {number} componentSelectorY + * @property {number} huffmanTableSelectorY + * @property {number} componentSelectorCb + * @property {number} huffmanTableSelectorCb + * @property {number} componentSelectorCr + * @property {number} huffmanTableSelectorCr + * @property {number} scanStartPositionInBlock + * @property {number} scanEndPositionInBlock + * @property {number} successiveApproximationBitPosition + * @property {Uint8Array} rawImageData + */ + +export class JpegStartOfScanEvent extends Event { + constructor(sos) { + super(JpegParseEventType.START_OF_SCAN); + /** @type {JpegStartOfScan} */ + this.sos = sos; + } +} + +export class JpegParser extends EventTarget { + /** + * @type {ByteStream} + * @private + */ + bstream; + + /** + * @type {boolean} + * @private + */ + hasApp0MarkerSegment = false; + + /** @param {ArrayBuffer} ab */ + constructor(ab) { + super(); + this.bstream = new ByteStream(ab); + } + + /** + * Type-safe way to bind a listener for a JpegApp0MarkerEvent. + * @param {function(JpegApp0MarkerEvent): void} listener + * @returns {JpegParser} for chaining + */ + onApp0Marker(listener) { + super.addEventListener(JpegParseEventType.APP0_MARKER, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a JpegApp0ExtensionEvent. + * @param {function(JpegApp0MarkerEvent): void} listener + * @returns {JpegParser} for chaining + */ + onApp0Extension(listener) { + super.addEventListener(JpegParseEventType.APP0_EXTENSION, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a JpegApp1ExifEvent. + * @param {function(JpegApp1ExifEvent): void} listener + * @returns {JpegParser} for chaining + */ + onApp1Exif(listener) { + super.addEventListener(JpegParseEventType.APP1_EXIF, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a JpegDefineQuantizationTableEvent. + * @param {function(JpegDefineQuantizationTableEvent): void} listener + * @returns {JpegParser} for chaining + */ + onDefineQuantizationTable(listener) { + super.addEventListener(JpegParseEventType.DEFINE_QUANTIZATION_TABLE, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a JpegDefineHuffmanTableEvent. + * @param {function(JpegDefineHuffmanTableEvent): void} listener + * @returns {JpegParser} for chaining + */ + onDefineHuffmanTable(listener) { + super.addEventListener(JpegParseEventType.DEFINE_HUFFMAN_TABLE, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a JpegStartOfFrameEvent. + * @param {function(JpegStartOfFrameEvent): void} listener + * @returns {JpegParser} for chaining + */ + onStartOfFrame(listener) { + super.addEventListener(JpegParseEventType.START_OF_FRAME, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a JpegStartOfScanEvent. + * @param {function(JpegStartOfScanEvent): void} listener + * @returns {JpegParser} for chaining + */ + onStartOfScan(listener) { + super.addEventListener(JpegParseEventType.START_OF_SCAN, listener); + return this; + } + + /** @returns {Promise} A Promise that resolves when the parsing is complete. */ + async start() { + const segmentType = getJpegMarker(this.bstream.readBytes(2)); + if (segmentType !== JpegSegmentType.SOI) throw `Did not start with a SOI`; + + let jpegMarker; + do { + jpegMarker = getJpegMarker(this.bstream.readBytes(2)); + + if (jpegMarker === JpegSegmentType.APP0) { + this.bstream.setBigEndian(); + const length = this.bstream.readNumber(2); + const skipAheadStream = this.bstream.tee().skip(length - 2); + + const identifier = this.bstream.readString(4); + if (identifier === 'JFIF') { + if (this.hasApp0MarkerSegment) throw `JFIF found after JFIF`; + if (this.bstream.readNumber(1) !== 0) throw 'No null byte terminator for JFIF'; + + this.hasApp0MarkerSegment = true; + const majorVer = `${this.bstream.readNumber(1)}.`; + const minorVer = `${this.bstream.readNumber(1)}`.padStart(2, '0'); + const densityUnits = this.bstream.readNumber(1); + const xDensity = this.bstream.readNumber(2); + const yDensity = this.bstream.readNumber(2); + const xThumbnail = this.bstream.readNumber(1); + const yThumbnail = this.bstream.readNumber(1); + + /** @type {JpegApp0Marker} */ + let app0MarkerSegment = { + jfifVersion: `${majorVer}${minorVer}`, + densityUnits, + xDensity, + yDensity, + xThumbnail, + yThumbnail, + thumbnailData: this.bstream.readBytes(3 * xThumbnail * yThumbnail), + }; + this.dispatchEvent(new JpegApp0MarkerEvent(app0MarkerSegment)); + } + else if (identifier === 'JFXX') { + if (!this.hasApp0MarkerSegment) throw `JFXX found without JFIF`; + if (this.bstream.readNumber(1) !== 0) throw 'No null byte terminator for JFXX'; + + const thumbnailFormat = this.bstream.readNumber(1); + if (!Object.values(JpegExtensionThumbnailFormat).includes(thumbnailFormat)) { + throw `Bad Extension Thumbnail Format: ${thumbnailFormat}`; + } + + // The JFXX segment has length (2), 'JFXX' (4), null byte (1), thumbnail format (1) + const thumbnailData = this.bstream.readBytes(length - 8); + + /** @type {JpegApp0Extension} */ + let app0ExtensionSegment = { + thumbnailFormat, + thumbnailData, + }; + this.dispatchEvent(new JpegApp0ExtensionEvent(app0ExtensionSegment)); + } + else { + throw `Bad APP0 identifier: ${identifier}`; + } + + this.bstream = skipAheadStream; + } // End of APP0 + else if (jpegMarker === JpegSegmentType.APP1) { + this.bstream.setBigEndian(); + const length = this.bstream.readNumber(2); + const skipAheadStream = this.bstream.tee().skip(length - 2); + + const identifier = this.bstream.readString(4); + if (identifier !== 'Exif') { + // TODO: Handle XMP. + // console.log(identifier + this.bstream.readString(length - 2 - 4)); + this.bstream = skipAheadStream; + continue; + } + if (this.bstream.readNumber(2) !== 0) throw `No null byte termination`; + + const lookAheadStream = this.bstream.tee(); + const tiffByteAlign = this.bstream.readString(2); + if (tiffByteAlign === 'II') { + this.bstream.setLittleEndian(); + lookAheadStream.setLittleEndian(); + } else if (tiffByteAlign === 'MM') { + this.bstream.setBigEndian(); + lookAheadStream.setBigEndian(); + } else { + throw `Invalid TIFF byte align symbol: ${tiffByteAlign}`; + } + + const tiffMarker = this.bstream.readNumber(2); + if (tiffMarker !== 0x002A) { + throw `Invalid marker, not 0x002a: 0x${tiffMarker.toString(16)}`; + } + + /** @type {Map> 4); + if (precision !== 0 && precision !== 1) throw `Weird value for DQT precision: ${precision}`; + + const valSize = precision === 0 ? 1 : 2; + const tableValues = new Array(64); + for (let v = 0; v < 64; ++v) { + tableValues[v] = this.bstream.readNumber(valSize); + } + + /** @type {JpegDefineQuantizationTable} */ + const table = { + tableNumber, + precision, + tableValues, + }; + this.dispatchEvent(new JpegDefineQuantizationTableEvent(table)); + + ptr += (1 + valSize * 64); + } + } // End of DQT + else if (jpegMarker === JpegSegmentType.DHT) { + this.bstream.setBigEndian(); + const length = this.bstream.readNumber(2); + let ptr = 2; + + while (ptr < length) { + const firstByte = this.bstream.readNumber(1); + const tableNumber = (firstByte & 0xF); + const tableType = ((firstByte & 0xF0) >> 4); + if (tableNumber > 3) throw `Weird DHT table number = ${tableNumber}`; + if (tableType !== 0 && tableType !== 1) throw `Weird DHT table type = ${tableType}`; + + const numberOfSymbols = Array.from(this.bstream.readBytes(16)); + let numCodes = 0; + for (let symbolLength = 1; symbolLength <= 16; ++symbolLength) { + const numSymbolsAtLength = numberOfSymbols[symbolLength - 1]; + numCodes += numSymbolsAtLength; + } + if (numCodes > 256) throw `Bad # of DHT codes: ${numCodes}`; + + const symbols = Array.from(this.bstream.readBytes(numCodes)); + + /** @type {JpegDefineHuffmanTable} */ + const table = { + tableNumber, + tableType, + numberOfSymbols, + symbols, + }; + this.dispatchEvent(new JpegDefineHuffmanTableEvent(table)); + + ptr += (1 + 16 + numCodes); + } + if (ptr !== length) throw `Bad DHT ptr: ${ptr}!`; + } // End of DHT + else if (jpegMarker === JpegSegmentType.SOF0 + || jpegMarker === JpegSegmentType.SOF1 + || jpegMarker === JpegSegmentType.SOF2) { + this.bstream.setBigEndian(); + const length = this.bstream.readNumber(2); + + const dctType = (jpegMarker - JpegSegmentType.SOF0); + if (![0, 1, 2].includes(dctType)) throw `Weird DCT type: ${dctType}`; + + const dataPrecision = this.bstream.readNumber(1); + const imageHeight = this.bstream.readNumber(2); + const imageWidth = this.bstream.readNumber(2); + const numberOfComponents = this.bstream.readNumber(1); + const componentDetails = []; + for (let c = 0; c < numberOfComponents; ++c) { + const componentId = this.bstream.readNumber(1); + const nextByte = this.bstream.readNumber(1); + const verticalSamplingFactor = (nextByte & 0xF); + const horizontalSamplingFactor = ((nextByte & 0xF0) >> 4); + const quantizationTableNumber = this.bstream.readNumber(1); + + componentDetails.push({ + componentId, + verticalSamplingFactor, + horizontalSamplingFactor, + quantizationTableNumber, + }); + } + + /** @type {JpegStartOfFrame} */ + const sof = { + dctType, + dataPrecision, + imageHeight, + imageWidth, + numberOfComponents, + componentDetails, + }; + + this.dispatchEvent(new JpegStartOfFrameEvent(sof)); + } // End of SOF0, SOF1, SOF2 + else if (jpegMarker === JpegSegmentType.SOS) { + this.bstream.setBigEndian(); + const length = this.bstream.readNumber(2); + // console.log(`Inside SOS with length = ${length}`); + if (length !== 12) throw `Bad length in SOS header: ${length}`; + + /** @type {JpegStartOfScan} */ + const sos = { + componentsInScan: this.bstream.readNumber(1), + componentSelectorY: this.bstream.readNumber(1), + huffmanTableSelectorY: this.bstream.readNumber(1), + componentSelectorCb: this.bstream.readNumber(1), + huffmanTableSelectorCb: this.bstream.readNumber(1), + componentSelectorCr: this.bstream.readNumber(1), + huffmanTableSelectorCr: this.bstream.readNumber(1), + scanStartPositionInBlock: this.bstream.readNumber(1), + scanEndPositionInBlock: this.bstream.readNumber(1), + successiveApproximationBitPosition: this.bstream.readNumber(1), + }; + + const rawImageDataStream = this.bstream.tee(); + let numBytes = 0; + // Immediately after SOS header is the compressed image data until the EOI marker is seen. + // Seek until we find the EOI marker. + while (true) { + if (this.bstream.readNumber(1) === 0xFF && + this.bstream.peekNumber(1) === JpegSegmentType.EOI) { + jpegMarker = this.bstream.readNumber(1); + break; + } else { + numBytes++; + } + } + + // NOTE: The below will have the null bytes after every 0xFF value. + sos.rawImageData = rawImageDataStream.readBytes(numBytes); + + this.dispatchEvent(new JpegStartOfScanEvent(sos)); + } // End of SOS + else { + this.bstream.setBigEndian(); + const length = this.bstream.peekNumber(2); + if (DEBUG) console.log(`Unsupported JPEG marker 0xff${jpegMarker.toString(16)} with length ${length}`); + this.bstream.skip(length); + } + } while (jpegMarker !== JpegSegmentType.EOI); + } + + /** + * Reads an Image File Directory from stream. + * @param {ByteStream} stream The stream to extract the Exif value descriptor. + * @param {ByteStream} lookAheadStream The lookahead stream if the offset is used. + * @param {Map { + it('extracts Exif and SOF', async () => { + const nodeBuf = fs.readFileSync(FILE_LONG_DESC); + const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length); + + /** @type {Map { exif = evt.exifValueMap }) + .onStartOfFrame(evt => { sof = evt.startOfFrame }); + await parser.start(); + + const descVal = exif.get(ExifTagNumber.IMAGE_DESCRIPTION); + expect(descVal.dataFormat).equals(ExifDataFormat.ASCII_STRING); + + const LONG_DESC = 'Operation Mountain Viper put the soldiers of A Company, 2nd Battalion 22nd ' + + 'Infantry Division, 10th Mountain in the Afghanistan province of Daychopan to search for ' + + 'Taliban and or weapon caches that could be used against U.S. and allied forces. Soldiers ' + + 'quickly walk to the ramp of the CH-47 Chinook cargo helicopter that will return them to ' + + 'Kandahar Army Air Field. (U.S. Army photo by Staff Sgt. Kyle Davis) (Released)'; + expect(descVal.stringValue).equals(LONG_DESC); + expect(exif.get(ExifTagNumber.EXIF_IMAGE_HEIGHT).numericalValue).equals(sof.imageHeight); + expect(exif.get(ExifTagNumber.EXIF_IMAGE_WIDTH).numericalValue).equals(sof.imageWidth); + }); +}); diff --git a/tests/image-testfiles/long_description.jpg b/tests/image-testfiles/long_description.jpg new file mode 100644 index 0000000..c5dfe67 Binary files /dev/null and b/tests/image-testfiles/long_description.jpg differ