diff --git a/README.md b/README.md index 9b063f5..0eaf6ba 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ npm i tiff The library can currently decode greyscale and RGB images (8, 16 or 32 bits). It supports LZW compression and images with an additional alpha channel. -### Extensions +#### [Adobe Photoshop extensions](./TIFFphotoshop.pdf) -Images compressed with Zlib/deflate algorithm are also supported. +Images compressed with the Zlib/deflate algorithm and JPEG are supported. ## API diff --git a/TIFFphotoshop.pdf b/TIFFphotoshop.pdf new file mode 100644 index 0000000..e4822d4 Binary files /dev/null and b/TIFFphotoshop.pdf differ diff --git a/img/color8-jpeg.tif b/img/color8-jpeg.tif new file mode 100644 index 0000000..344f0cc Binary files /dev/null and b/img/color8-jpeg.tif differ diff --git a/img/tiled.tif b/img/tiled.tif new file mode 100644 index 0000000..a77f7b8 Binary files /dev/null and b/img/tiled.tif differ diff --git a/package.json b/package.json index 3df6058..7a6ba28 100644 --- a/package.json +++ b/package.json @@ -38,14 +38,16 @@ }, "dependencies": { "iobuffer": "^5.0.2", + "jpeg-js": "^0.4.3", "pako": "^2.0.3" }, "devDependencies": { "@types/jest": "^26.0.20", - "@types/node": "^14.14.20", + "@types/node": "^14.14.22", "@types/pako": "^1.0.1", - "eslint": "^7.17.0", + "eslint": "^7.18.0", "eslint-config-cheminfo-typescript": "^8.0.5", + "image-js": "^0.31.4", "jest": "^26.6.3", "prettier": "^2.2.1", "rimraf": "^3.0.2", diff --git a/src/__tests__/decode.test.ts b/src/__tests__/decode.test.ts index 959aaba..7ee6712 100644 --- a/src/__tests__/decode.test.ts +++ b/src/__tests__/decode.test.ts @@ -33,6 +33,13 @@ const files: TiffFile[] = [ bitsPerSample: 8, components: 3, }, + { + name: 'color8-jpeg.tif', + width: 160, + height: 120, + bitsPerSample: 8, + components: 3, + }, { name: 'color8-alpha.tif', width: 800, @@ -129,6 +136,13 @@ const files: TiffFile[] = [ components: 4, alpha: true, }, + { + name: 'tiled.tif', + width: 1, + height: 1, + bitsPerSample: 8, + components: 1, + }, ]; const cases = files.map( (file) => [file.name, file, readImage(file.name)] as const, diff --git a/src/colorConversion.ts b/src/colorConversion.ts new file mode 100644 index 0000000..1a33415 --- /dev/null +++ b/src/colorConversion.ts @@ -0,0 +1,14 @@ +import TiffIfd from './tiffIfd'; + +export function convertWhiteIsZero(ifd: TiffIfd) { + // WhiteIsZero: we invert the values + const bitDepth = ifd.bitsPerSample; + const maxValue = Math.pow(2, bitDepth) - 1; + for (let i = 0; i < ifd.data.length; i++) { + ifd.data[i] = maxValue - ifd.data[i]; + } +} + +export function convertYCbCr(ifd: TiffIfd) { + throw new Error('TODO: convert YCbCr'); +} diff --git a/src/photoshopJpeg.ts b/src/photoshopJpeg.ts new file mode 100644 index 0000000..49bb7e2 --- /dev/null +++ b/src/photoshopJpeg.ts @@ -0,0 +1,24 @@ +import { decode } from 'jpeg-js'; + +export function decompressPhotoshopJpeg( + stripData: DataView, + jpegTables: number[] | undefined, +): DataView { + console.log(jpegTables); + // if (jpegTables) { + // throw new Error('Images compressed with JPEGTables are not supported'); + // } + const decoded = decode( + new Uint8Array( + stripData.buffer, + stripData.byteOffset, + stripData.byteLength, + ), + { + colorTransform: false, + useTArray: true, + formatAsRGBA: false, + }, + ); + throw new Error('todo: decompress JPEG'); +} diff --git a/src/readStripData.ts b/src/readStripData.ts new file mode 100644 index 0000000..1a1b28e --- /dev/null +++ b/src/readStripData.ts @@ -0,0 +1,111 @@ +import type TIFFDecoder from './tiffDecoder'; +import TiffIfd from './tiffIfd'; +import { DataArray } from './types'; +import { decompressData, getDataArray, unsupported } from './utils'; + +export function readStripData(decoder: TIFFDecoder, ifd: TiffIfd) { + const width = ifd.width; + const height = ifd.height; + + const bitDepth = ifd.bitsPerSample; + const sampleFormat = ifd.sampleFormat; + const size = width * height * ifd.samplesPerPixel; + const data = getDataArray(size, bitDepth, sampleFormat); + + const rowsPerStrip = ifd.rowsPerStrip as number; + const maxPixels = rowsPerStrip * width * ifd.samplesPerPixel; + const stripOffsets = ifd.stripOffsets as number[]; + const stripByteCounts = ifd.stripByteCounts as number[]; + + let remainingPixels = size; + let pixel = 0; + for (let i = 0; i < stripOffsets.length; i++) { + const stripData = new DataView( + decoder.buffer, + stripOffsets[i], + stripByteCounts[i], + ); + + // Last strip can be smaller + const length = remainingPixels > maxPixels ? maxPixels : remainingPixels; + remainingPixels -= length; + + const dataToFill = decompressData(stripData, ifd); + + pixel = fillUncompressed( + decoder, + bitDepth, + sampleFormat, + data, + dataToFill, + pixel, + length, + ); + } + + ifd.data = data; +} + +function fillUncompressed( + decoder: TIFFDecoder, + bitDepth: number, + sampleFormat: number, + data: DataArray, + stripData: DataView, + pixel: number, + length: number, +): number { + if (bitDepth === 8) { + return fill8bit(data, stripData, pixel, length); + } else if (bitDepth === 16) { + return fill16bit(data, stripData, pixel, length, decoder.isLittleEndian()); + } else if (bitDepth === 32 && sampleFormat === 3) { + return fillFloat32( + data, + stripData, + pixel, + length, + decoder.isLittleEndian(), + ); + } else { + throw unsupported('bitDepth', bitDepth); + } +} + +function fill8bit( + dataTo: DataArray, + dataFrom: DataView, + index: number, + length: number, +): number { + for (let i = 0; i < length; i++) { + dataTo[index++] = dataFrom.getUint8(i); + } + return index; +} + +function fill16bit( + dataTo: DataArray, + dataFrom: DataView, + index: number, + length: number, + littleEndian: boolean, +): number { + for (let i = 0; i < length * 2; i += 2) { + dataTo[index++] = dataFrom.getUint16(i, littleEndian); + } + return index; +} + +function fillFloat32( + dataTo: DataArray, + dataFrom: DataView, + index: number, + length: number, + littleEndian: boolean, +): number { + for (let i = 0; i < length * 4; i += 4) { + dataTo[index++] = dataFrom.getFloat32(i, littleEndian); + } + return index; +} diff --git a/src/readTileData.ts b/src/readTileData.ts new file mode 100644 index 0000000..f81f717 --- /dev/null +++ b/src/readTileData.ts @@ -0,0 +1,31 @@ +import type TIFFDecoder from './tiffDecoder'; +import TiffIfd from './tiffIfd'; +import { decompressData, getDataArray } from './utils'; + +export function readTileData(decoder: TIFFDecoder, ifd: TiffIfd) { + const width = ifd.width; + const height = ifd.height; + + const bitDepth = ifd.bitsPerSample; + const sampleFormat = ifd.sampleFormat; + const size = width * height * ifd.samplesPerPixel; + const data = getDataArray(size, bitDepth, sampleFormat); + + const tileLength = ifd.tileLength as number; + const tileWidth = ifd.tileWidth as number; + const tileByteCounts = ifd.tileByteCounts as number[]; + const tileOffsets = ifd.tileOffsets as number[]; + + // Iterate on each tile + for (let i = 0; i < tileOffsets.length; i++) { + const tileData = new DataView( + decoder.buffer, + tileOffsets[i], + tileByteCounts[i], + ); + + const dataToFill = decompressData(tileData, ifd.compression); + + console.log(dataToFill); + } +} diff --git a/src/tiffDecoder.ts b/src/tiffDecoder.ts index 6eb2043..3b56c96 100644 --- a/src/tiffDecoder.ts +++ b/src/tiffDecoder.ts @@ -1,15 +1,17 @@ import { IOBuffer } from 'iobuffer'; +import { convertWhiteIsZero, convertYCbCr } from './colorConversion'; import { applyHorizontalDifferencing8Bit, applyHorizontalDifferencing16Bit, } from './horizontalDifferencing'; import IFD from './ifd'; import { getByteLength, readData } from './ifdValue'; -import { decompressLzw } from './lzw'; +import { readStripData } from './readStripData'; +import { readTileData } from './readTileData'; import TiffIfd from './tiffIfd'; -import { BufferType, IDecodeOptions, IFDKind, DataArray } from './types'; -import { decompressZlib } from './zlib'; +import { BufferType, IDecodeOptions, IFDKind } from './types'; +import { unsupported } from './utils'; const defaultOptions: IDecodeOptions = { ignoreImageData: false, @@ -168,109 +170,22 @@ export default class TIFFDecoder extends IOBuffer { if (orientation && orientation !== 1) { throw unsupported('orientation', orientation); } - switch (ifd.type) { - case 0: // WhiteIsZero - case 1: // BlackIsZero - case 2: // RGB - case 3: // Palette color - this.readStripData(ifd); - break; - default: - throw unsupported('image type', ifd.type); + checkIfdType(ifd.type); + + if (ifd.hasStrips) { + readStripData(this, ifd); + } else if (ifd.hasTiles) { + readTileData(this, ifd); + } else { + throw new Error('cannot read TIFF without strip or tile data'); } + this.applyPredictor(ifd); this.convertAlpha(ifd); if (ifd.type === 0) { - // WhiteIsZero: we invert the values - const bitDepth = ifd.bitsPerSample; - const maxValue = Math.pow(2, bitDepth) - 1; - for (let i = 0; i < ifd.data.length; i++) { - ifd.data[i] = maxValue - ifd.data[i]; - } - } - } - - private readStripData(ifd: TiffIfd): void { - const width = ifd.width; - const height = ifd.height; - - const bitDepth = ifd.bitsPerSample; - const sampleFormat = ifd.sampleFormat; - const size = width * height * ifd.samplesPerPixel; - const data = getDataArray(size, bitDepth, sampleFormat); - - const rowsPerStrip = ifd.rowsPerStrip; - const maxPixels = rowsPerStrip * width * ifd.samplesPerPixel; - const stripOffsets = ifd.stripOffsets; - const stripByteCounts = ifd.stripByteCounts; - - let remainingPixels = size; - let pixel = 0; - for (let i = 0; i < stripOffsets.length; i++) { - let stripData = new DataView( - this.buffer, - stripOffsets[i], - stripByteCounts[i], - ); - - // Last strip can be smaller - let length = remainingPixels > maxPixels ? maxPixels : remainingPixels; - remainingPixels -= length; - - let dataToFill = stripData; - - switch (ifd.compression) { - case 1: { - // No compression, nothing to do - break; - } - case 5: { - // LZW compression - dataToFill = decompressLzw(stripData); - break; - } - case 8: { - // Zlib compression - dataToFill = decompressZlib(stripData); - break; - } - case 2: // CCITT Group 3 1-Dimensional Modified Huffman run length encoding - throw unsupported('Compression', 'CCITT Group 3'); - case 32773: // PackBits compression - throw unsupported('Compression', 'PackBits'); - default: - throw unsupported('Compression', ifd.compression); - } - - pixel = this.fillUncompressed( - bitDepth, - sampleFormat, - data, - dataToFill, - pixel, - length, - ); - } - - ifd.data = data; - } - - private fillUncompressed( - bitDepth: number, - sampleFormat: number, - data: DataArray, - stripData: DataView, - pixel: number, - length: number, - ): number { - if (bitDepth === 8) { - return fill8bit(data, stripData, pixel, length); - } else if (bitDepth === 16) { - return fill16bit(data, stripData, pixel, length, this.isLittleEndian()); - } else if (bitDepth === 32 && sampleFormat === 3) { - return fillFloat32(data, stripData, pixel, length, this.isLittleEndian()); - } else { - throw unsupported('bitDepth', bitDepth); + convertWhiteIsZero(ifd); + } else if (ifd.type === 6) { + convertYCbCr(ifd); } } @@ -319,63 +234,19 @@ export default class TIFFDecoder extends IOBuffer { } } -function getDataArray( - size: number, - bitDepth: number, - sampleFormat: number, -): DataArray { - if (bitDepth === 8) { - return new Uint8Array(size); - } else if (bitDepth === 16) { - return new Uint16Array(size); - } else if (bitDepth === 32 && sampleFormat === 3) { - return new Float32Array(size); - } else { - throw unsupported( - 'bit depth / sample format', - `${bitDepth} / ${sampleFormat}`, - ); +function checkIfdType(type: number): void { + if ( + // WhiteIsZero + type !== 0 && + // BlackIsZero + type !== 1 && + // RGB + type !== 2 && + // Palette color + type !== 3 && + // YCbCr (Class Y) + type !== 6 + ) { + throw unsupported('image type', type); } } - -function fill8bit( - dataTo: DataArray, - dataFrom: DataView, - index: number, - length: number, -): number { - for (let i = 0; i < length; i++) { - dataTo[index++] = dataFrom.getUint8(i); - } - return index; -} - -function fill16bit( - dataTo: DataArray, - dataFrom: DataView, - index: number, - length: number, - littleEndian: boolean, -): number { - for (let i = 0; i < length * 2; i += 2) { - dataTo[index++] = dataFrom.getUint16(i, littleEndian); - } - return index; -} - -function fillFloat32( - dataTo: DataArray, - dataFrom: DataView, - index: number, - length: number, - littleEndian: boolean, -): number { - for (let i = 0; i < length * 4; i += 4) { - dataTo[index++] = dataFrom.getFloat32(i, littleEndian); - } - return index; -} - -function unsupported(type: string, value: any): Error { - return new Error(`Unsupported ${type}: ${value}`); -} diff --git a/src/tiffIfd.ts b/src/tiffIfd.ts index 2910fda..918b7a4 100644 --- a/src/tiffIfd.ts +++ b/src/tiffIfd.ts @@ -35,6 +35,12 @@ export default class TiffIfd extends Ifd { date.setHours(Number(result[4]), Number(result[5]), Number(result[6])); return date; } + public get hasStrips(): boolean { + return this.rowsPerStrip !== undefined; + } + public get hasTiles(): boolean { + return this.tileLength !== undefined; + } // IFD fields public get newSubfileType(): number { @@ -81,7 +87,7 @@ export default class TiffIfd extends Ifd { public get imageDescription(): string | undefined { return this.get('ImageDescription'); } - public get stripOffsets(): number[] { + public get stripOffsets(): number[] | undefined { return alwaysArray(this.get('StripOffsets')); } public get orientation(): number { @@ -90,10 +96,10 @@ export default class TiffIfd extends Ifd { public get samplesPerPixel(): number { return this.get('SamplesPerPixel') || 1; } - public get rowsPerStrip(): number { + public get rowsPerStrip(): number | undefined { return this.get('RowsPerStrip'); } - public get stripByteCounts(): number[] { + public get stripByteCounts(): number[] | undefined { return alwaysArray(this.get('StripByteCounts')); } public get minSampleValue(): number { @@ -146,6 +152,18 @@ export default class TiffIfd extends Ifd { } return palette; } + public get tileWidth(): number | undefined { + return this.get('TileWidth'); + } + public get tileLength(): number | undefined { + return this.get('TileLength'); + } + public get tileOffsets(): number[] | undefined { + return this.get('TileOffsets'); + } + public get tileByteCounts(): number[] | undefined { + return this.get('TileByteCounts'); + } } function alwaysArray(value: number | number[]): number[] { diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f054dd8 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,57 @@ +import { decompressLzw } from './lzw'; +import { decompressPhotoshopJpeg } from './photoshopJpeg'; +import TiffIfd from './tiffIfd'; +import { DataArray } from './types'; +import { decompressZlib } from './zlib'; + +export function unsupported(type: string, value: any): Error { + return new Error(`Unsupported ${type}: ${value}`); +} + +export function getDataArray( + size: number, + bitDepth: number, + sampleFormat: number, +): DataArray { + if (bitDepth === 8) { + return new Uint8Array(size); + } else if (bitDepth === 16) { + return new Uint16Array(size); + } else if (bitDepth === 32 && sampleFormat === 3) { + return new Float32Array(size); + } else { + throw unsupported( + 'bit depth / sample format', + `${bitDepth} / ${sampleFormat}`, + ); + } +} + +export function decompressData(data: DataView, ifd: TiffIfd): DataView { + const { compression } = ifd; + switch (compression) { + case 1: { + // No compression, nothing to do + return data; + } + case 5: { + // LZW compression + return decompressLzw(data); + } + case 7: { + // Photoshop JPEG compression + const jpegTables = ifd.get('JPEGTables'); + return decompressPhotoshopJpeg(data, jpegTables); + } + case 8: { + // Photoshop Zlib compression + return decompressZlib(data); + } + case 2: // CCITT Group 3 1-Dimensional Modified Huffman run length encoding + throw unsupported('Compression', 'CCITT Group 3'); + case 32773: // PackBits compression + throw unsupported('Compression', 'PackBits'); + default: + throw unsupported('Compression', compression); + } +}