Skip to content

Commit

Permalink
feat: support LZW compression
Browse files Browse the repository at this point in the history
  • Loading branch information
targos committed Aug 4, 2020
1 parent e520e9e commit 20fbb50
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 15 deletions.
File renamed without changes.
File renamed without changes.
Binary file added img/grey8-lzw.tif
Binary file not shown.
3 changes: 3 additions & 0 deletions src/__tests__/decode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ function readImage(file: string): Buffer {

const files = [
'color8.tif',
'color8-lzw.tif',
'color16.tif',
'color16-lzw.tif',
'grey8.tif',
'grey8-lzw.tif',
'grey16.tif',
'whiteIsZero.tif',
];
Expand Down
29 changes: 29 additions & 0 deletions src/horizontalDifferencing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Section 14: Differencing Predictor (p. 64)

export function applyHorizontalDifferencing(
data: Uint8Array,
width: number,
): void {
let i = 0;
while (i < data.length) {
for (let j = 1; j < width; j++) {
data[i + j] = (data[i + j] + data[i + j - 1]) & 255;
}
i += width;
}
}

export function applyHorizontalDifferencingColor(
data: Uint8Array,
width: number,
): void {
let i = 0;
while (i < data.length) {
for (let j = 3; j < width * 3; j += 3) {
data[i + j] = (data[i + j] + data[i + j - 3]) & 255;
data[i + j + 1] = (data[i + j + 1] + data[i + j - 2]) & 255;
data[i + j + 2] = (data[i + j + 2] + data[i + j - 1]) & 255;
}
i += width * 3;
}
}
124 changes: 124 additions & 0 deletions src/lzw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { IOBuffer } from 'iobuffer';

const CLEAR_CODE = 256;
const EOI_CODE = 257;
// 0-255 from the table + 256 for clear code + 257 for end of information code.
const TABLE_START = 258;
const MIN_BIT_LENGTH = 9;

class LzwDecoder {
private stripArray: Uint8Array;
private currentBit: number;
private stringTable: Map<number, number[]>;
private tableLength: number;
private currentBitLength: number;
private outData: IOBuffer;

public constructor(data: DataView) {
this.stripArray = new Uint8Array(
data.buffer,
data.byteOffset,
data.byteLength,
);
const table = new Map<number, number[]>();
for (let i = 0; i < 256; i++) {
table.set(i, [i]);
}
this.currentBit = 0;
this.stringTable = table;
this.tableLength = TABLE_START;
this.currentBitLength = MIN_BIT_LENGTH;
this.outData = new IOBuffer(data.byteLength);
}

public decode(): DataView {
let code = 0;
let oldCode = 0;
while ((code = this.getNextCode()) !== EOI_CODE) {
if (code === CLEAR_CODE) {
this.initializeTable();
code = this.getNextCode();
if (code === EOI_CODE) {
break;
}
this.writeString(this.stringFromCode(code));
oldCode = code;
} else if (this.isInTable(code)) {
this.writeString(this.stringFromCode(code));
this.addStringToTable(
this.stringFromCode(oldCode).concat(this.stringFromCode(code)[0]),
);
oldCode = code;
} else {
const outString = this.stringFromCode(oldCode).concat(
this.stringFromCode(oldCode)[0],
);
this.writeString(outString);
this.addStringToTable(outString);
oldCode = code;
}
}
const outArray = this.outData.toArray();
return new DataView(
outArray.buffer,
outArray.byteOffset,
outArray.byteLength,
);
}

private initializeTable(): void {
this.tableLength = TABLE_START;
this.currentBitLength = MIN_BIT_LENGTH;
}

private writeString(string: number[]): void {
this.outData.writeBytes(string);
}

private stringFromCode(code: number): number[] {
// At this point, `code` must be in the table.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return this.stringTable.get(code);
}

private isInTable(code: number): boolean {
return code < this.tableLength;
}

private addStringToTable(string: number[]): void {
this.stringTable.set(this.tableLength++, string);
if (this.tableLength + 1 === 2 ** this.currentBitLength) {
this.currentBitLength++;
}
}

private getNextCode(): number {
const d = this.currentBit % 8;
const a = this.currentBit >>> 3;
const de = 8 - d;
const ef = this.currentBit + this.currentBitLength - (a + 1) * 8;
let fg = 8 * (a + 2) - (this.currentBit + this.currentBitLength);
const dg = (a + 2) * 8 - this.currentBit;
fg = Math.max(0, fg);
let chunk1 = this.stripArray[a] & (2 ** (8 - d) - 1);
chunk1 <<= this.currentBitLength - de;
let chunks = chunk1;
if (a + 1 < this.stripArray.length) {
let chunk2 = this.stripArray[a + 1] >>> fg;
chunk2 <<= Math.max(0, this.currentBitLength - dg);
chunks += chunk2;
}
if (ef > 8 && a + 2 < this.stripArray.length) {
const hi = (a + 3) * 8 - (this.currentBit + this.currentBitLength);
const chunk3 = this.stripArray[a + 2] >>> hi;
chunks += chunk3;
}
this.currentBit += this.currentBitLength;
return chunks;
}
}

export function decompressLzw(stripData: DataView): DataView {
return new LzwDecoder(stripData).decode();
}
74 changes: 59 additions & 15 deletions src/tiffDecoder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { IOBuffer } from 'iobuffer';

import {
applyHorizontalDifferencing,
applyHorizontalDifferencingColor,
} from './horizontalDifferencing';
import IFD from './ifd';
import { getByteLength, readData } from './ifdValue';
import { decompressLzw } from './lzw';
import TiffIfd from './tiffIfd';
import { BufferType, IDecodeOptions, IFDKind, DataArray } from './types';

Expand Down Expand Up @@ -172,6 +177,7 @@ export default class TIFFDecoder extends IOBuffer {
default:
throw unsupported('image type', ifd.type);
}
this.applyPredictor(ifd);
if (ifd.type === 0) {
// WhiteIsZero: we invert the values
const bitDepth = validateBitDepth(ifd.bitsPerSample);
Expand All @@ -191,7 +197,6 @@ export default class TIFFDecoder extends IOBuffer {
const size = width * height;
const data = getDataArray(size, 1, bitDepth, sampleFormat);

const compression = ifd.compression;
const rowsPerStrip = ifd.rowsPerStrip;
const maxPixels = rowsPerStrip * width;
const stripOffsets = ifd.stripOffsets;
Expand All @@ -210,25 +215,34 @@ export default class TIFFDecoder extends IOBuffer {
let length = remainingPixels > maxPixels ? maxPixels : remainingPixels;
remainingPixels -= length;

switch (compression) {
case 1: // No compression
pixel = this.fillUncompressed(
bitDepth,
sampleFormat,
data,
stripData,
pixel,
length,
);
let dataToFill = stripData;

switch (ifd.compression) {
case 1: {
// No compression, nothing to do
break;
case 5: // LZW
throw unsupported('Compression', 'LZW');
}
case 5: {
// LZW compression
dataToFill = decompressLzw(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', compression);
throw unsupported('Compression', 'PackBits');
default:
throw new Error(`invalid compression: ${compression}`);
throw new Error(`invalid compression: ${ifd.compression}`);
}

pixel = this.fillUncompressed(
bitDepth,
sampleFormat,
data,
dataToFill,
pixel,
length,
);
}

ifd.data = data;
Expand All @@ -252,6 +266,36 @@ export default class TIFFDecoder extends IOBuffer {
throw unsupported('bitDepth', bitDepth);
}
}

private applyPredictor(ifd: TiffIfd): void {
const bitDepth = validateBitDepth(ifd.bitsPerSample);
switch (ifd.predictor) {
case 1: {
// No prediction scheme, nothing to do
break;
}
case 2: {
if (bitDepth === 8) {
if (ifd.samplesPerPixel === 1) {
applyHorizontalDifferencing(ifd.data as Uint8Array, ifd.width);
} else if (ifd.samplesPerPixel === 3) {
applyHorizontalDifferencingColor(ifd.data as Uint8Array, ifd.width);
} else {
throw new Error(
'Horizontal differencing is only supported for images with 1 or 3 samples per pixel',
);
}
} else {
throw new Error(
'Horizontal differencing is only supported for 8-bit images',
);
}
break;
}
default:
throw new Error(`invalid predictor: ${ifd.predictor}`);
}
}
}

function getDataArray(
Expand Down

0 comments on commit 20fbb50

Please sign in to comment.