| import { IOBuffer } from 'iobuffer'; |
| import { inflate, Inflate as Inflator } from 'pako'; |
|
|
| import { checkCrc } from './helpers/crc'; |
| import { decodeInterlaceAdam7 } from './helpers/decodeInterlaceAdam7'; |
| import { decodeInterlaceNull } from './helpers/decodeInterlaceNull'; |
| import { checkSignature } from './helpers/signature'; |
| import { decodetEXt, readKeyword, textChunkName } from './helpers/text'; |
| import { |
| ColorType, |
| CompressionMethod, |
| DisposeOpType, |
| FilterMethod, |
| InterlaceMethod, |
| BlendOpType, |
| } from './internalTypes'; |
| import type { |
| BitDepth, |
| DecodedPng, |
| DecodedApng, |
| DecodedApngFrame, |
| ApngFrame, |
| DecoderInputType, |
| IndexedColors, |
| PngDecoderOptions, |
| } from './types'; |
|
|
| 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; |
| |
| |
| 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; |
| } |
|
|
| |
| private decodeChunk(length: number, type: string): void { |
| const offset = this.offset; |
| switch (type) { |
| |
| case 'IHDR': |
| this.decodeIHDR(); |
| break; |
| case 'PLTE': |
| this.decodePLTE(length); |
| break; |
| case 'IDAT': |
| this.decodeIDAT(length); |
| break; |
| case 'IEND': |
| this._end = true; |
| break; |
| |
| case 'tRNS': |
| this.decodetRNS(length); |
| break; |
| case 'iCCP': |
| this.decodeiCCP(length); |
| break; |
| case textChunkName: |
| decodetEXt(this._png.text, this, length); |
| break; |
| case 'pHYs': |
| 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); |
| } |
| } |
|
|
| |
| 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; |
| |
| |
| 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); |
| } |
| |
| 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()]); |
| } |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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; |
| } |
| |
| |
| 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}`, |
| ); |
| } |
| |
| } |
| } |
|
|
| |
| 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), |
| }; |
| } |
|
|
| |
| private decodepHYs(): void { |
| const ppuX = this.readUint32(); |
| const ppuY = this.readUint32(); |
| const unitSpecifier = this.readByte(); |
| this._png.resolution = { x: ppuX, y: ppuY, unit: unitSpecifier }; |
| } |
|
|
| 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; |
| |
| 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; |
| } |
|
|