fast-png
Version:
PNG image decoder and encoder written entirely in JavaScript
600 lines (565 loc) • 17.9 kB
text/typescript
import { IOBuffer } from 'iobuffer';
import { Inflate as Inflator, inflate } from 'pako';
import { checkCrc } from './helpers/crc.ts';
import { decodeInterlaceAdam7 } from './helpers/decode_interlace_adam7.ts';
import { decodeInterlaceNull } from './helpers/decode_interlace_null.ts';
import { checkSignature } from './helpers/signature.ts';
import { decodetEXt, readKeyword, textChunkName } from './helpers/text.ts';
import {
BlendOpType,
ColorType,
CompressionMethod,
DisposeOpType,
FilterMethod,
InterlaceMethod,
} from './internal_types.ts';
import type {
ApngFrame,
BitDepth,
DecodedApng,
DecodedApngFrame,
DecodedPng,
DecoderInputType,
IndexedColors,
PngDecoderOptions,
ResolutionUnitSpecifier,
} from './types.ts';
export default class PngDecoder extends IOBuffer {
private readonly _checkCrc: boolean;
private _inflator: Inflator;
private readonly _png: DecodedPng;
private readonly _apng: DecodedApng;
private _end: boolean;
private _hasPalette: boolean;
private _palette: IndexedColors;
private _hasTransparency: boolean;
private _transparency: Uint16Array;
private _compressionMethod: CompressionMethod;
private _filterMethod: FilterMethod;
private _interlaceMethod: InterlaceMethod;
private _colorType: ColorType;
private _isAnimated: boolean;
private _numberOfFrames: number;
private _numberOfPlays: number;
private _frames: ApngFrame[];
private _writingDataChunks: boolean;
public constructor(data: DecoderInputType, options: PngDecoderOptions = {}) {
super(data);
const { checkCrc = false } = options;
this._checkCrc = checkCrc;
this._inflator = new Inflator();
this._png = {
width: -1,
height: -1,
channels: -1,
data: new Uint8Array(0),
depth: 1,
text: {},
};
this._apng = {
width: -1,
height: -1,
channels: -1,
depth: 1,
numberOfFrames: 1,
numberOfPlays: 0,
text: {},
frames: [],
};
this._end = false;
this._hasPalette = false;
this._palette = [];
this._hasTransparency = false;
this._transparency = new Uint16Array(0);
this._compressionMethod = CompressionMethod.UNKNOWN;
this._filterMethod = FilterMethod.UNKNOWN;
this._interlaceMethod = InterlaceMethod.UNKNOWN;
this._colorType = ColorType.UNKNOWN;
this._isAnimated = false;
this._numberOfFrames = 1;
this._numberOfPlays = 0;
this._frames = [];
this._writingDataChunks = false;
// PNG is always big endian
// https://www.w3.org/TR/PNG/#7Integers-and-byte-order
this.setBigEndian();
}
public decode(): DecodedPng {
checkSignature(this);
while (!this._end) {
const length = this.readUint32();
const type = this.readChars(4);
this.decodeChunk(length, type);
}
this.decodeImage();
return this._png;
}
public decodeApng(): DecodedApng {
checkSignature(this);
while (!this._end) {
const length = this.readUint32();
const type = this.readChars(4);
this.decodeApngChunk(length, type);
}
this.decodeApngImage();
return this._apng;
}
// https://www.w3.org/TR/PNG/#5Chunk-layout
private decodeChunk(length: number, type: string): void {
const offset = this.offset;
switch (type) {
// 11.2 Critical chunks
case 'IHDR': // 11.2.2 IHDR Image header
this.decodeIHDR();
break;
case 'PLTE': // 11.2.3 PLTE Palette
this.decodePLTE(length);
break;
case 'IDAT': // 11.2.4 IDAT Image data
this.decodeIDAT(length);
break;
case 'IEND': // 11.2.5 IEND Image trailer
this._end = true;
break;
// 11.3 Ancillary chunks
case 'tRNS': // 11.3.2.1 tRNS Transparency
this.decodetRNS(length);
break;
case 'iCCP': // 11.3.3.3 iCCP Embedded ICC profile
this.decodeiCCP(length);
break;
case textChunkName: // 11.3.4.3 tEXt Textual data
decodetEXt(this._png.text, this, length);
break;
case 'pHYs': // 11.3.5.3 pHYs Physical pixel dimensions
this.decodepHYs();
break;
default:
this.skip(length);
break;
}
if (this.offset - offset !== length) {
throw new Error(`Length mismatch while decoding chunk ${type}`);
}
if (this._checkCrc) {
checkCrc(this, length + 4, type);
} else {
this.skip(4);
}
}
private decodeApngChunk(length: number, type: string): void {
const offset = this.offset;
if (type !== 'fdAT' && type !== 'IDAT' && this._writingDataChunks) {
this.pushDataToFrame();
}
switch (type) {
case 'acTL':
this.decodeACTL();
break;
case 'fcTL':
this.decodeFCTL();
break;
case 'fdAT':
this.decodeFDAT(length);
break;
default:
this.decodeChunk(length, type);
this.offset = offset + length;
break;
}
if (this.offset - offset !== length) {
throw new Error(`Length mismatch while decoding chunk ${type}`);
}
if (this._checkCrc) {
checkCrc(this, length + 4, type);
} else {
this.skip(4);
}
}
// https://www.w3.org/TR/PNG/#11IHDR
private decodeIHDR(): void {
const image = this._png;
image.width = this.readUint32();
image.height = this.readUint32();
image.depth = checkBitDepth(this.readUint8());
const colorType = this.readUint8() as ColorType;
this._colorType = colorType;
let channels: number;
switch (colorType) {
case ColorType.GREYSCALE:
channels = 1;
break;
case ColorType.TRUECOLOUR:
channels = 3;
break;
case ColorType.INDEXED_COLOUR:
channels = 1;
break;
case ColorType.GREYSCALE_ALPHA:
channels = 2;
break;
case ColorType.TRUECOLOUR_ALPHA:
channels = 4;
break;
// Kept for exhaustiveness.
// eslint-disable-next-line unicorn/no-useless-switch-case
case ColorType.UNKNOWN:
default:
throw new Error(`Unknown color type: ${colorType}`);
}
this._png.channels = channels;
this._compressionMethod = this.readUint8() as CompressionMethod;
if (this._compressionMethod !== CompressionMethod.DEFLATE) {
throw new Error(
`Unsupported compression method: ${this._compressionMethod}`,
);
}
this._filterMethod = this.readUint8() as FilterMethod;
this._interlaceMethod = this.readUint8() as InterlaceMethod;
}
private decodeACTL(): void {
this._numberOfFrames = this.readUint32();
this._numberOfPlays = this.readUint32();
this._isAnimated = true;
}
private decodeFCTL(): void {
const image: ApngFrame = {
sequenceNumber: this.readUint32(),
width: this.readUint32(),
height: this.readUint32(),
xOffset: this.readUint32(),
yOffset: this.readUint32(),
delayNumber: this.readUint16(),
delayDenominator: this.readUint16(),
disposeOp: this.readUint8(),
blendOp: this.readUint8(),
data: new Uint8Array(0),
};
this._frames.push(image);
}
// https://www.w3.org/TR/PNG/#11PLTE
private decodePLTE(length: number): void {
if (length % 3 !== 0) {
throw new RangeError(
`PLTE field length must be a multiple of 3. Got ${length}`,
);
}
const l = length / 3;
this._hasPalette = true;
const palette: IndexedColors = [];
this._palette = palette;
for (let i = 0; i < l; i++) {
palette.push([this.readUint8(), this.readUint8(), this.readUint8()]);
}
}
// https://www.w3.org/TR/PNG/#11IDAT
private decodeIDAT(length: number): void {
this._writingDataChunks = true;
const dataLength = length;
const dataOffset = this.offset + this.byteOffset;
this._inflator.push(new Uint8Array(this.buffer, dataOffset, dataLength));
if (this._inflator.err) {
throw new Error(
`Error while decompressing the data: ${this._inflator.err}`,
);
}
this.skip(length);
}
private decodeFDAT(length: number): void {
this._writingDataChunks = true;
let dataLength = length;
let dataOffset = this.offset + this.byteOffset;
dataOffset += 4;
dataLength -= 4;
this._inflator.push(new Uint8Array(this.buffer, dataOffset, dataLength));
if (this._inflator.err) {
throw new Error(
`Error while decompressing the data: ${this._inflator.err}`,
);
}
this.skip(length);
}
// https://www.w3.org/TR/PNG/#11tRNS
private decodetRNS(length: number): void {
switch (this._colorType) {
case ColorType.GREYSCALE:
case ColorType.TRUECOLOUR: {
if (length % 2 !== 0) {
throw new RangeError(
`tRNS chunk length must be a multiple of 2. Got ${length}`,
);
}
if (length / 2 > this._png.width * this._png.height) {
throw new Error(
`tRNS chunk contains more alpha values than there are pixels (${
length / 2
} vs ${this._png.width * this._png.height})`,
);
}
this._hasTransparency = true;
this._transparency = new Uint16Array(length / 2);
for (let i = 0; i < length / 2; i++) {
this._transparency[i] = this.readUint16();
}
break;
}
case ColorType.INDEXED_COLOUR: {
if (length > this._palette.length) {
throw new Error(
`tRNS chunk contains more alpha values than there are palette colors (${length} vs ${this._palette.length})`,
);
}
let i = 0;
for (; i < length; i++) {
const alpha = this.readByte();
this._palette[i].push(alpha);
}
for (; i < this._palette.length; i++) {
this._palette[i].push(255);
}
break;
}
// Kept for exhaustiveness.
/* eslint-disable unicorn/no-useless-switch-case */
case ColorType.UNKNOWN:
case ColorType.GREYSCALE_ALPHA:
case ColorType.TRUECOLOUR_ALPHA:
default: {
throw new Error(
`tRNS chunk is not supported for color type ${this._colorType}`,
);
}
/* eslint-enable unicorn/no-useless-switch-case */
}
}
// https://www.w3.org/TR/PNG/#11iCCP
private decodeiCCP(length: number): void {
const name = readKeyword(this);
const compressionMethod = this.readUint8();
if (compressionMethod !== CompressionMethod.DEFLATE) {
throw new Error(
`Unsupported iCCP compression method: ${compressionMethod}`,
);
}
const compressedProfile = this.readBytes(length - name.length - 2);
this._png.iccEmbeddedProfile = {
name,
profile: inflate(compressedProfile),
};
}
// https://www.w3.org/TR/PNG/#11pHYs
private decodepHYs(): void {
const ppuX = this.readUint32();
const ppuY = this.readUint32();
const unitSpecifier = this.readByte();
this._png.resolution = {
x: ppuX,
y: ppuY,
unit: unitSpecifier as ResolutionUnitSpecifier,
};
}
private decodeApngImage() {
this._apng.width = this._png.width;
this._apng.height = this._png.height;
this._apng.channels = this._png.channels;
this._apng.depth = this._png.depth;
this._apng.numberOfFrames = this._numberOfFrames;
this._apng.numberOfPlays = this._numberOfPlays;
this._apng.text = this._png.text;
this._apng.resolution = this._png.resolution;
for (let i = 0; i < this._numberOfFrames; i++) {
const newFrame: DecodedApngFrame = {
sequenceNumber: this._frames[i].sequenceNumber,
delayNumber: this._frames[i].delayNumber,
delayDenominator: this._frames[i].delayDenominator,
data:
this._apng.depth === 8
? new Uint8Array(
this._apng.width * this._apng.height * this._apng.channels,
)
: new Uint16Array(
this._apng.width * this._apng.height * this._apng.channels,
),
};
const frame = this._frames.at(i);
if (frame) {
frame.data = decodeInterlaceNull({
data: frame.data as Uint8Array,
width: frame.width,
height: frame.height,
channels: this._apng.channels,
depth: this._apng.depth,
});
if (this._hasPalette) {
this._apng.palette = this._palette;
}
if (this._hasTransparency) {
this._apng.transparency = this._transparency;
}
if (
i === 0 ||
(frame.xOffset === 0 &&
frame.yOffset === 0 &&
frame.width === this._png.width &&
frame.height === this._png.height)
) {
newFrame.data = frame.data;
} else {
const prevFrame = this._apng.frames.at(i - 1);
this.disposeFrame(frame, prevFrame as DecodedApngFrame, newFrame);
this.addFrameDataToCanvas(newFrame, frame);
}
this._apng.frames.push(newFrame);
}
}
return this._apng;
}
private disposeFrame(
frame: ApngFrame,
prevFrame: DecodedApngFrame,
imageFrame: DecodedApngFrame,
): void {
switch (frame.disposeOp) {
case DisposeOpType.NONE:
break;
case DisposeOpType.BACKGROUND:
for (let row = 0; row < this._png.height; row++) {
for (let col = 0; col < this._png.width; col++) {
const index = (row * frame.width + col) * this._png.channels;
for (let channel = 0; channel < this._png.channels; channel++) {
imageFrame.data[index + channel] = 0;
}
}
}
break;
case DisposeOpType.PREVIOUS:
imageFrame.data.set(prevFrame.data);
break;
default:
throw new Error('Unknown disposeOp');
}
}
private addFrameDataToCanvas(
imageFrame: DecodedApngFrame,
frame: ApngFrame,
): void {
const maxValue = 1 << this._png.depth;
const calculatePixelIndices = (row: number, col: number) => {
const index =
((row + frame.yOffset) * this._png.width + frame.xOffset + col) *
this._png.channels;
const frameIndex = (row * frame.width + col) * this._png.channels;
return { index, frameIndex };
};
switch (frame.blendOp) {
case BlendOpType.SOURCE:
for (let row = 0; row < frame.height; row++) {
for (let col = 0; col < frame.width; col++) {
const { index, frameIndex } = calculatePixelIndices(row, col);
for (let channel = 0; channel < this._png.channels; channel++) {
imageFrame.data[index + channel] =
frame.data[frameIndex + channel];
}
}
}
break;
// https://www.w3.org/TR/png-3/#13Alpha-channel-processing
case BlendOpType.OVER:
for (let row = 0; row < frame.height; row++) {
for (let col = 0; col < frame.width; col++) {
const { index, frameIndex } = calculatePixelIndices(row, col);
for (let channel = 0; channel < this._png.channels; channel++) {
const sourceAlpha =
frame.data[frameIndex + this._png.channels - 1] / maxValue;
const foregroundValue =
channel % (this._png.channels - 1) === 0
? 1
: frame.data[frameIndex + channel];
const value = Math.floor(
sourceAlpha * foregroundValue +
(1 - sourceAlpha) * imageFrame.data[index + channel],
);
imageFrame.data[index + channel] += value;
}
}
}
break;
default:
throw new Error('Unknown blendOp');
}
}
private decodeImage(): void {
if (this._inflator.err) {
throw new Error(
`Error while decompressing the data: ${this._inflator.err}`,
);
}
const data = this._isAnimated
? (this._frames?.at(0) as ApngFrame).data
: this._inflator.result;
if (this._filterMethod !== FilterMethod.ADAPTIVE) {
throw new Error(`Filter method ${this._filterMethod} not supported`);
}
if (this._interlaceMethod === InterlaceMethod.NO_INTERLACE) {
this._png.data = decodeInterlaceNull({
data: data as Uint8Array,
width: this._png.width,
height: this._png.height,
channels: this._png.channels,
depth: this._png.depth,
});
} else if (this._interlaceMethod === InterlaceMethod.ADAM7) {
this._png.data = decodeInterlaceAdam7({
data: data as Uint8Array,
width: this._png.width,
height: this._png.height,
channels: this._png.channels,
depth: this._png.depth,
});
} else {
throw new Error(
`Interlace method ${this._interlaceMethod} not supported`,
);
}
if (this._hasPalette) {
this._png.palette = this._palette;
}
if (this._hasTransparency) {
this._png.transparency = this._transparency;
}
}
private pushDataToFrame() {
const result = this._inflator.result;
const lastFrame = this._frames.at(-1);
if (lastFrame) {
lastFrame.data = result as Uint8Array;
} else {
this._frames.push({
sequenceNumber: 0,
width: this._png.width,
height: this._png.height,
xOffset: 0,
yOffset: 0,
delayNumber: 0,
delayDenominator: 0,
disposeOp: DisposeOpType.NONE,
blendOp: BlendOpType.SOURCE,
data: result as Uint8Array,
});
}
this._inflator = new Inflator();
this._writingDataChunks = false;
}
}
function checkBitDepth(value: number): BitDepth {
if (
value !== 1 &&
value !== 2 &&
value !== 4 &&
value !== 8 &&
value !== 16
) {
throw new Error(`invalid bit depth: ${value}`);
}
return value;
}