first commit

This commit is contained in:
2026-06-24 09:48:54 +02:00
commit 41e62ddcad
33739 changed files with 4266226 additions and 0 deletions
+594
View File
@@ -0,0 +1,594 @@
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;
// 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 };
}
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;
}
+327
View File
@@ -0,0 +1,327 @@
import { IOBuffer } from 'iobuffer';
import { deflate } from 'pako';
import { writeCrc } from './helpers/crc';
import { writeSignature } from './helpers/signature';
import { encodetEXt } from './helpers/text';
import {
InterlaceMethod,
ColorType,
CompressionMethod,
FilterMethod,
} from './internalTypes';
import type {
DeflateFunctionOptions,
PngEncoderOptions,
ImageData,
PngDataArray,
BitDepth,
IndexedColors,
} from './types';
const defaultZlibOptions: DeflateFunctionOptions = {
level: 3,
};
interface PngToEncode {
width: number;
height: number;
data: PngDataArray;
depth: BitDepth;
channels: number;
text?: ImageData['text'];
palette?: IndexedColors;
}
export default class PngEncoder extends IOBuffer {
private readonly _png: PngToEncode;
private readonly _zlibOptions: DeflateFunctionOptions;
private _colorType: ColorType;
private readonly _interlaceMethod: InterlaceMethod;
public constructor(data: ImageData, options: PngEncoderOptions = {}) {
super();
this._colorType = ColorType.UNKNOWN;
this._zlibOptions = { ...defaultZlibOptions, ...options.zlib };
this._png = this._checkData(data);
this._interlaceMethod =
(options.interlace === 'Adam7'
? InterlaceMethod.ADAM7
: InterlaceMethod.NO_INTERLACE) ?? InterlaceMethod.NO_INTERLACE;
this.setBigEndian();
}
public encode(): Uint8Array {
writeSignature(this);
this.encodeIHDR();
if (this._png.palette) {
this.encodePLTE();
if (this._png.palette[0].length === 4) {
this.encodeTRNS();
}
}
this.encodeData();
if (this._png.text) {
for (const [keyword, text] of Object.entries(this._png.text)) {
encodetEXt(this, keyword, text);
}
}
this.encodeIEND();
return this.toArray();
}
// https://www.w3.org/TR/PNG/#11IHDR
private encodeIHDR(): void {
this.writeUint32(13);
this.writeChars('IHDR');
this.writeUint32(this._png.width);
this.writeUint32(this._png.height);
this.writeByte(this._png.depth);
this.writeByte(this._colorType);
this.writeByte(CompressionMethod.DEFLATE);
this.writeByte(FilterMethod.ADAPTIVE);
this.writeByte(this._interlaceMethod);
writeCrc(this, 17);
}
// https://www.w3.org/TR/PNG/#11IEND
private encodeIEND(): void {
this.writeUint32(0);
this.writeChars('IEND');
writeCrc(this, 4);
}
private encodePLTE() {
const paletteLength = (this._png.palette?.length as number) * 3;
this.writeUint32(paletteLength);
this.writeChars('PLTE');
for (const color of this._png.palette as IndexedColors) {
this.writeByte(color[0]);
this.writeByte(color[1]);
this.writeByte(color[2]);
}
writeCrc(this, 4 + paletteLength);
}
private encodeTRNS() {
const alpha = (this._png.palette as IndexedColors).filter((color) => {
return color.at(-1) !== 255;
});
this.writeUint32(alpha.length);
this.writeChars('tRNS');
for (const el of alpha) {
this.writeByte(el.at(-1) as number);
}
writeCrc(this, 4 + alpha.length);
}
// https://www.w3.org/TR/PNG/#11IDAT
private encodeIDAT(data: PngDataArray): void {
this.writeUint32(data.length);
this.writeChars('IDAT');
this.writeBytes(data);
writeCrc(this, data.length + 4);
}
private encodeData(): void {
const { width, height, channels, depth, data } = this._png;
const slotsPerLine =
depth <= 8
? Math.ceil((width * depth) / 8) * channels
: Math.ceil((((width * depth) / 8) * channels) / 2);
const newData = new IOBuffer().setBigEndian();
let offset = 0;
if (this._interlaceMethod === InterlaceMethod.NO_INTERLACE) {
for (let i = 0; i < height; i++) {
newData.writeByte(0); // no filter
if (depth === 16) {
offset = writeDataUint16(data, newData, slotsPerLine, offset);
} else {
offset = writeDataBytes(data, newData, slotsPerLine, offset);
}
}
} else if (this._interlaceMethod === InterlaceMethod.ADAM7) {
// Adam7 interlacing
offset = writeDataInterlaced(this._png, data, newData, offset);
}
const buffer = newData.toArray();
const compressed = deflate(buffer, this._zlibOptions);
this.encodeIDAT(compressed);
}
private _checkData(data: ImageData): PngToEncode {
const { colorType, channels, depth } = getColorType(data, data.palette);
const png: PngToEncode = {
width: checkInteger(data.width, 'width'),
height: checkInteger(data.height, 'height'),
channels,
data: data.data,
depth,
text: data.text,
palette: data.palette,
};
this._colorType = colorType;
const expectedSize =
depth < 8
? Math.ceil((png.width * depth) / 8) * png.height * channels
: png.width * png.height * channels;
if (png.data.length !== expectedSize) {
throw new RangeError(
`wrong data size. Found ${png.data.length}, expected ${expectedSize}`,
);
}
return png;
}
}
function checkInteger(value: number, name: string): number {
if (Number.isInteger(value) && value > 0) {
return value;
}
throw new TypeError(`${name} must be a positive integer`);
}
interface GetColorTypeReturn {
channels: number;
depth: BitDepth;
colorType: ColorType;
}
function getColorType(
data: ImageData,
palette?: IndexedColors,
): GetColorTypeReturn {
const { channels = 4, depth = 8 } = data;
if (channels !== 4 && channels !== 3 && channels !== 2 && channels !== 1) {
throw new RangeError(`unsupported number of channels: ${channels}`);
}
const returnValue: GetColorTypeReturn = {
channels,
depth,
colorType: ColorType.UNKNOWN,
};
switch (channels) {
case 4:
returnValue.colorType = ColorType.TRUECOLOUR_ALPHA;
break;
case 3:
returnValue.colorType = ColorType.TRUECOLOUR;
break;
case 1:
if (palette) {
returnValue.colorType = ColorType.INDEXED_COLOUR;
} else {
returnValue.colorType = ColorType.GREYSCALE;
}
break;
case 2:
returnValue.colorType = ColorType.GREYSCALE_ALPHA;
break;
default:
throw new Error('unsupported number of channels');
}
return returnValue;
}
function writeDataBytes(
data: PngDataArray,
newData: IOBuffer,
slotsPerLine: number,
offset: number,
): number {
for (let j = 0; j < slotsPerLine; j++) {
newData.writeByte(data[offset++]);
}
return offset;
}
function writeDataInterlaced(
imageData: PngToEncode,
data: PngDataArray,
newData: IOBuffer,
offset: number,
) {
const passes = [
{ x: 0, y: 0, xStep: 8, yStep: 8 },
{ x: 4, y: 0, xStep: 8, yStep: 8 },
{ x: 0, y: 4, xStep: 4, yStep: 8 },
{ x: 2, y: 0, xStep: 4, yStep: 4 },
{ x: 0, y: 2, xStep: 2, yStep: 4 },
{ x: 1, y: 0, xStep: 2, yStep: 2 },
{ x: 0, y: 1, xStep: 1, yStep: 2 },
];
const { width, height, channels, depth } = imageData;
let pixelSize = 0;
if (depth === 16) {
pixelSize = (channels * depth) / 8 / 2;
} else {
pixelSize = (channels * depth) / 8;
}
// Process each pass
for (let passIndex = 0; passIndex < 7; passIndex++) {
const pass = passes[passIndex];
const passWidth = Math.floor(
(width - pass.x + pass.xStep - 1) / pass.xStep,
);
const passHeight = Math.floor(
(height - pass.y + pass.yStep - 1) / pass.yStep,
);
if (passWidth <= 0 || passHeight <= 0) continue;
const passLineBytes = passWidth * pixelSize;
// For each scanline in this pass
for (let y = 0; y < passHeight; y++) {
const imageY = pass.y + y * pass.yStep;
// Extract raw scanline data
const rawScanline =
depth <= 8
? new Uint8Array(passLineBytes)
: new Uint16Array(passLineBytes);
let rawOffset = 0;
for (let x = 0; x < passWidth; x++) {
const imageX = pass.x + x * pass.xStep;
if (imageX < width && imageY < height) {
const srcPos = (imageY * width + imageX) * pixelSize;
for (let i = 0; i < pixelSize; i++) {
rawScanline[rawOffset++] = data[srcPos + i];
}
}
}
newData.writeByte(0); // no filter
if (depth === 8) {
newData.writeBytes(rawScanline);
} else if (depth === 16) {
for (const value of rawScanline) {
newData.writeByte((value >> 8) & 0xff); // High byte
newData.writeByte(value & 0xff);
}
}
}
}
return offset;
}
function writeDataUint16(
data: PngDataArray,
newData: IOBuffer,
slotsPerLine: number,
offset: number,
): number {
for (let j = 0; j < slotsPerLine; j++) {
newData.writeUint16(data[offset++]);
}
return offset;
}
+78
View File
@@ -0,0 +1,78 @@
import type { DecodedPng, IndexedColorBitDepth } from './types';
/**
* Converts indexed data into RGB/RGBA format
* @param decodedImage - Image to decode data from.
* @returns Uint8Array with RGB data.
*/
export function convertIndexedToRgb(decodedImage: DecodedPng) {
const palette = decodedImage.palette;
const depth = decodedImage.depth as IndexedColorBitDepth;
if (!palette) {
throw new Error('Color palette is undefined.');
}
checkDataSize(decodedImage);
const indexSize = decodedImage.width * decodedImage.height;
const resSize = indexSize * palette[0].length;
const res = new Uint8Array(resSize);
let indexPos = 0;
let offset = 0;
const indexes = new Uint8Array(indexSize);
let bit = 0xff;
switch (depth) {
case 1:
bit = 0x80;
break;
case 2:
bit = 0xc0;
break;
case 4:
bit = 0xf0;
break;
case 8:
bit = 0xff;
break;
default:
throw new Error('Incorrect depth value');
}
for (const byte of decodedImage.data) {
let bit2 = bit;
let shift = 8;
while (bit2) {
shift -= depth;
indexes[indexPos++] = (byte & bit2) >> shift;
bit2 = bit2 >> depth;
if (indexPos % decodedImage.width === 0) {
break;
}
}
}
if (decodedImage.palette) {
for (const index of indexes) {
const color = decodedImage.palette.at(index);
if (!color) {
throw new Error('Incorrect index of palette color');
}
res.set(color, offset);
offset += color.length;
}
}
return res;
}
function checkDataSize(image: DecodedPng): void {
const expectedSize =
image.depth < 8
? Math.ceil((image.width * image.depth) / 8) *
image.height *
image.channels
: image.width * image.height * image.channels;
if (image.data.length !== expectedSize) {
throw new RangeError(
`wrong data size. Found ${image.data.length}, expected ${expectedSize}`,
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import {
unfilterAverage,
unfilterNone,
unfilterPaeth,
unfilterSub,
unfilterUp,
} from './unfilter';
/**
* Apllies filter on scanline based on the filter type.
* @param filterType - The filter type to apply.
* @param currentLine - The current line of pixel data.
* @param newLine - The new line of pixel data.
* @param prevLine - The previous line of pixel data.
* @param passLineBytes - The number of bytes in the pass line.
* @param bytesPerPixel - The number of bytes per pixel.
*/
export function applyUnfilter(
filterType: number,
currentLine: Uint8Array,
newLine: Uint8Array,
prevLine: Uint8Array,
passLineBytes: number,
bytesPerPixel: number,
) {
switch (filterType) {
case 0:
unfilterNone(currentLine, newLine, passLineBytes);
break;
case 1:
unfilterSub(currentLine, newLine, passLineBytes, bytesPerPixel);
break;
case 2:
unfilterUp(currentLine, newLine, prevLine, passLineBytes);
break;
case 3:
unfilterAverage(
currentLine,
newLine,
prevLine,
passLineBytes,
bytesPerPixel,
);
break;
case 4:
unfilterPaeth(
currentLine,
newLine,
prevLine,
passLineBytes,
bytesPerPixel,
);
break;
default:
throw new Error(`Unsupported filter: ${filterType}`);
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { IOBuffer } from 'iobuffer';
const crcTable: number[] = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
if (c & 1) {
c = 0xedb88320 ^ (c >>> 1);
} else {
c = c >>> 1;
}
}
crcTable[n] = c;
}
const initialCrc = 0xffffffff;
function updateCrc(
currentCrc: number,
data: Uint8Array,
length: number,
): number {
let c = currentCrc;
for (let n = 0; n < length; n++) {
c = crcTable[(c ^ data[n]) & 0xff] ^ (c >>> 8);
}
return c;
}
function crc(data: Uint8Array, length: number): number {
return (updateCrc(initialCrc, data, length) ^ initialCrc) >>> 0;
}
export function checkCrc(
buffer: IOBuffer,
crcLength: number,
chunkName: string,
) {
const expectedCrc = buffer.readUint32();
const actualCrc = crc(
new Uint8Array(
buffer.buffer,
buffer.byteOffset + buffer.offset - crcLength - 4,
crcLength,
),
crcLength,
); // "- 4" because we already advanced by reading the CRC
if (actualCrc !== expectedCrc) {
throw new Error(
`CRC mismatch for chunk ${chunkName}. Expected ${expectedCrc}, found ${actualCrc}`,
);
}
}
export function writeCrc(buffer: IOBuffer, length: number) {
buffer.writeUint32(
crc(
new Uint8Array(
buffer.buffer,
buffer.byteOffset + buffer.offset - length,
length,
),
length,
),
);
}
+93
View File
@@ -0,0 +1,93 @@
import { applyUnfilter } from './applyUnfilter';
import type { DecodeInterlaceNullParams } from './decodeInterlaceNull';
const uint16 = new Uint16Array([0x00ff]);
const uint8 = new Uint8Array(uint16.buffer);
const osIsLittleEndian = uint8[0] === 0xff;
/**
* Decodes the Adam7 interlaced PNG data.
*
* @param params - DecodeInterlaceNullParams
* @returns - array of pixel data.
*/
export function decodeInterlaceAdam7(params: DecodeInterlaceNullParams) {
const { data, width, height, channels, depth } = params;
// Adam7 interlacing pattern
const passes = [
{ x: 0, y: 0, xStep: 8, yStep: 8 }, // Pass 1
{ x: 4, y: 0, xStep: 8, yStep: 8 }, // Pass 2
{ x: 0, y: 4, xStep: 4, yStep: 8 }, // Pass 3
{ x: 2, y: 0, xStep: 4, yStep: 4 }, // Pass 4
{ x: 0, y: 2, xStep: 2, yStep: 4 }, // Pass 5
{ x: 1, y: 0, xStep: 2, yStep: 2 }, // Pass 6
{ x: 0, y: 1, xStep: 1, yStep: 2 }, // Pass 7
];
const bytesPerPixel = Math.ceil(depth / 8) * channels;
const resultData = new Uint8Array(height * width * bytesPerPixel);
let offset = 0;
// Process each pass
for (let passIndex = 0; passIndex < 7; passIndex++) {
const pass = passes[passIndex];
// Calculate pass dimensions
const passWidth = Math.ceil((width - pass.x) / pass.xStep);
const passHeight = Math.ceil((height - pass.y) / pass.yStep);
if (passWidth <= 0 || passHeight <= 0) continue;
const passLineBytes = passWidth * bytesPerPixel;
const prevLine = new Uint8Array(passLineBytes);
// Process each scanline in this pass
for (let y = 0; y < passHeight; y++) {
// First byte is the filter type
const filterType = data[offset++];
const currentLine = data.subarray(offset, offset + passLineBytes);
offset += passLineBytes;
// Create a new line for the unfiltered data
const newLine = new Uint8Array(passLineBytes);
// Apply the appropriate unfilter
applyUnfilter(
filterType,
currentLine,
newLine,
prevLine,
passLineBytes,
bytesPerPixel,
);
prevLine.set(newLine);
for (let x = 0; x < passWidth; x++) {
const outputX = pass.x + x * pass.xStep;
const outputY = pass.y + y * pass.yStep;
if (outputX >= width || outputY >= height) continue;
for (let i = 0; i < bytesPerPixel; i++) {
resultData[(outputY * width + outputX) * bytesPerPixel + i] =
newLine[x * bytesPerPixel + i];
}
}
}
}
if (depth === 16) {
const uint16Data = new Uint16Array(resultData.buffer);
if (osIsLittleEndian) {
for (let k = 0; k < uint16Data.length; k++) {
// PNG is always big endian. Swap the bytes.
uint16Data[k] = swap16(uint16Data[k]);
}
}
return uint16Data;
} else {
return resultData;
}
}
function swap16(val: number): number {
return ((val & 0xff) << 8) | ((val >> 8) & 0xff);
}
+94
View File
@@ -0,0 +1,94 @@
import type { PngDataArray } from '../types';
import {
unfilterAverage,
unfilterNone,
unfilterPaeth,
unfilterSub,
unfilterUp,
} from './unfilter';
const uint16 = new Uint16Array([0x00ff]);
const uint8 = new Uint8Array(uint16.buffer);
const osIsLittleEndian = uint8[0] === 0xff;
const empty = new Uint8Array(0);
export interface DecodeInterlaceNullParams {
data: Uint8Array;
width: number;
height: number;
channels: number;
depth: number;
}
export function decodeInterlaceNull(
params: DecodeInterlaceNullParams,
): PngDataArray {
const { data, width, height, channels, depth } = params;
const bytesPerPixel = Math.ceil(depth / 8) * channels;
const bytesPerLine = Math.ceil((depth / 8) * channels * width);
const newData = new Uint8Array(height * bytesPerLine);
let prevLine = empty;
let offset = 0;
let currentLine;
let newLine;
for (let i = 0; i < height; i++) {
currentLine = data.subarray(offset + 1, offset + 1 + bytesPerLine);
newLine = newData.subarray(i * bytesPerLine, (i + 1) * bytesPerLine);
switch (data[offset]) {
case 0:
unfilterNone(currentLine, newLine, bytesPerLine);
break;
case 1:
unfilterSub(currentLine, newLine, bytesPerLine, bytesPerPixel);
break;
case 2:
unfilterUp(currentLine, newLine, prevLine, bytesPerLine);
break;
case 3:
unfilterAverage(
currentLine,
newLine,
prevLine,
bytesPerLine,
bytesPerPixel,
);
break;
case 4:
unfilterPaeth(
currentLine,
newLine,
prevLine,
bytesPerLine,
bytesPerPixel,
);
break;
default:
throw new Error(`Unsupported filter: ${data[offset]}`);
}
prevLine = newLine;
offset += bytesPerLine + 1;
}
if (depth === 16) {
const uint16Data = new Uint16Array(newData.buffer);
if (osIsLittleEndian) {
for (let k = 0; k < uint16Data.length; k++) {
// PNG is always big endian. Swap the bytes.
uint16Data[k] = swap16(uint16Data[k]);
}
}
return uint16Data;
} else {
return newData;
}
}
function swap16(val: number): number {
return ((val & 0xff) << 8) | ((val >> 8) & 0xff);
}
+27
View File
@@ -0,0 +1,27 @@
import type { IOBuffer } from 'iobuffer';
// https://www.w3.org/TR/PNG/#5PNG-file-signature
const pngSignature = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
export function writeSignature(buffer: IOBuffer) {
buffer.writeBytes(pngSignature);
}
export function checkSignature(buffer: IOBuffer) {
if (!hasPngSignature(buffer.readBytes(pngSignature.length))) {
throw new Error('wrong PNG signature');
}
}
export function hasPngSignature(array: ArrayLike<number>) {
if (array.length < pngSignature.length) {
return false;
}
for (let i = 0; i < pngSignature.length; i++) {
if (array[i] !== pngSignature[i]) {
return false;
}
}
return true;
}
+71
View File
@@ -0,0 +1,71 @@
import type { IOBuffer } from 'iobuffer';
import { writeCrc } from './crc';
// https://www.w3.org/TR/png/#11tEXt
export const textChunkName = 'tEXt';
const NULL = 0;
const latin1Decoder = new TextDecoder('latin1');
function validateKeyword(keyword: string) {
validateLatin1(keyword);
if (keyword.length === 0 || keyword.length > 79) {
throw new Error('keyword length must be between 1 and 79');
}
}
// eslint-disable-next-line no-control-regex
const latin1Regex = /^[\u0000-\u00FF]*$/;
function validateLatin1(text: string) {
if (!latin1Regex.test(text)) {
throw new Error('invalid latin1 text');
}
}
export function decodetEXt(
text: Record<string, string>,
buffer: IOBuffer,
length: number,
) {
const keyword = readKeyword(buffer);
text[keyword] = readLatin1(buffer, length - keyword.length - 1);
}
export function encodetEXt(buffer: IOBuffer, keyword: string, text: string) {
validateKeyword(keyword);
validateLatin1(text);
const length = keyword.length + 1 /* NULL */ + text.length;
buffer.writeUint32(length);
buffer.writeChars(textChunkName);
buffer.writeChars(keyword);
buffer.writeByte(NULL);
buffer.writeChars(text);
writeCrc(buffer, length + 4);
}
// https://www.w3.org/TR/png/#11keywords
export function readKeyword(buffer: IOBuffer): string {
buffer.mark();
while (buffer.readByte() !== NULL) {
/* advance */
}
const end = buffer.offset;
buffer.reset();
const keyword = latin1Decoder.decode(
buffer.readBytes(end - buffer.offset - 1),
);
// NULL
buffer.skip(1);
validateKeyword(keyword);
return keyword;
}
export function readLatin1(buffer: IOBuffer, length: number): string {
return latin1Decoder.decode(buffer.readBytes(length));
}
+115
View File
@@ -0,0 +1,115 @@
import type { PngDataArray } from '../types';
export function unfilterNone(
currentLine: PngDataArray,
newLine: PngDataArray,
bytesPerLine: number,
): void {
for (let i = 0; i < bytesPerLine; i++) {
newLine[i] = currentLine[i];
}
}
export function unfilterSub(
currentLine: PngDataArray,
newLine: PngDataArray,
bytesPerLine: number,
bytesPerPixel: number,
): void {
let i = 0;
for (; i < bytesPerPixel; i++) {
// just copy first bytes
newLine[i] = currentLine[i];
}
for (; i < bytesPerLine; i++) {
newLine[i] = (currentLine[i] + newLine[i - bytesPerPixel]) & 0xff;
}
}
export function unfilterUp(
currentLine: PngDataArray,
newLine: PngDataArray,
prevLine: PngDataArray,
bytesPerLine: number,
): void {
let i = 0;
if (prevLine.length === 0) {
// just copy bytes for first line
for (; i < bytesPerLine; i++) {
newLine[i] = currentLine[i];
}
} else {
for (; i < bytesPerLine; i++) {
newLine[i] = (currentLine[i] + prevLine[i]) & 0xff;
}
}
}
export function unfilterAverage(
currentLine: PngDataArray,
newLine: PngDataArray,
prevLine: PngDataArray,
bytesPerLine: number,
bytesPerPixel: number,
): void {
let i = 0;
if (prevLine.length === 0) {
for (; i < bytesPerPixel; i++) {
newLine[i] = currentLine[i];
}
for (; i < bytesPerLine; i++) {
newLine[i] = (currentLine[i] + (newLine[i - bytesPerPixel] >> 1)) & 0xff;
}
} else {
for (; i < bytesPerPixel; i++) {
newLine[i] = (currentLine[i] + (prevLine[i] >> 1)) & 0xff;
}
for (; i < bytesPerLine; i++) {
newLine[i] =
(currentLine[i] + ((newLine[i - bytesPerPixel] + prevLine[i]) >> 1)) &
0xff;
}
}
}
export function unfilterPaeth(
currentLine: PngDataArray,
newLine: PngDataArray,
prevLine: PngDataArray,
bytesPerLine: number,
bytesPerPixel: number,
): void {
let i = 0;
if (prevLine.length === 0) {
for (; i < bytesPerPixel; i++) {
newLine[i] = currentLine[i];
}
for (; i < bytesPerLine; i++) {
newLine[i] = (currentLine[i] + newLine[i - bytesPerPixel]) & 0xff;
}
} else {
for (; i < bytesPerPixel; i++) {
newLine[i] = (currentLine[i] + prevLine[i]) & 0xff;
}
for (; i < bytesPerLine; i++) {
newLine[i] =
(currentLine[i] +
paethPredictor(
newLine[i - bytesPerPixel],
prevLine[i],
prevLine[i - bytesPerPixel],
)) &
0xff;
}
}
}
function paethPredictor(a: number, b: number, c: number): number {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
else if (pb <= pc) return b;
else return c;
}
+38
View File
@@ -0,0 +1,38 @@
import PngDecoder from './PngDecoder';
import PngEncoder from './PngEncoder';
import type {
DecoderInputType,
PngDecoderOptions,
DecodedPng,
DecodedApng,
ImageData,
PngEncoderOptions,
} from './types';
export { hasPngSignature } from './helpers/signature';
export * from './types';
function decodePng(
data: DecoderInputType,
options?: PngDecoderOptions,
): DecodedPng {
const decoder = new PngDecoder(data, options);
return decoder.decode();
}
function encodePng(png: ImageData, options?: PngEncoderOptions): Uint8Array {
const encoder = new PngEncoder(png, options);
return encoder.encode();
}
function decodeApng(
data: DecoderInputType,
options?: PngDecoderOptions,
): DecodedApng {
const decoder = new PngDecoder(data, options);
return decoder.decodeApng();
}
export { decodePng as decode, encodePng as encode, decodeApng };
export { convertIndexedToRgb } from './convertIndexedToRgb';
+46
View File
@@ -0,0 +1,46 @@
export const ColorType = {
UNKNOWN: -1,
GREYSCALE: 0,
TRUECOLOUR: 2,
INDEXED_COLOUR: 3,
GREYSCALE_ALPHA: 4,
TRUECOLOUR_ALPHA: 6,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type ColorType = (typeof ColorType)[keyof typeof ColorType];
export const CompressionMethod = {
UNKNOWN: -1,
DEFLATE: 0,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type CompressionMethod =
(typeof CompressionMethod)[keyof typeof CompressionMethod];
export const FilterMethod = {
UNKNOWN: -1,
ADAPTIVE: 0,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type FilterMethod = (typeof FilterMethod)[keyof typeof FilterMethod];
export const InterlaceMethod = {
UNKNOWN: -1,
NO_INTERLACE: 0,
ADAM7: 1,
} as const;
export const DisposeOpType = {
NONE: 0,
BACKGROUND: 1,
PREVIOUS: 2,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type DisposeOpType = (typeof DisposeOpType)[keyof typeof DisposeOpType];
export const BlendOpType = {
SOURCE: 0,
OVER: 1,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type InterlaceMethod =
(typeof InterlaceMethod)[keyof typeof InterlaceMethod];
+113
View File
@@ -0,0 +1,113 @@
import type { IOBuffer } from 'iobuffer';
import type { DeflateFunctionOptions } from 'pako';
export type { DeflateFunctionOptions } from 'pako';
export type PngDataArray = Uint8Array | Uint8ClampedArray | Uint16Array;
export type DecoderInputType = IOBuffer | ArrayBufferLike | ArrayBufferView;
export type BitDepth = 1 | 2 | 4 | 8 | 16;
export type IndexedColorBitDepth = 1 | 2 | 4 | 8;
export interface PngResolution {
/**
* Pixels per unit, X axis
*/
x: number;
/**
* Pixels per unit, Y axis
*/
y: number;
/**
* Unit specifier
*/
unit: ResolutionUnitSpecifier;
}
export enum ResolutionUnitSpecifier {
/**
* Unit is unknown
*/
UNKNOWN = 0,
/**
* Unit is the metre
*/
METRE = 1,
}
export interface ImageData {
width: number;
height: number;
data: PngDataArray;
depth?: BitDepth;
channels?: number;
text?: Record<string, string>;
palette?: IndexedColors;
transparency?: Uint16Array;
}
export interface DecodedPng {
width: number;
height: number;
data: PngDataArray;
depth: BitDepth;
channels: number;
text: Record<string, string>;
resolution?: PngResolution;
palette?: IndexedColors;
transparency?: Uint16Array;
iccEmbeddedProfile?: IccEmbeddedProfile;
}
export interface DecodedApng {
width: number;
height: number;
depth: BitDepth;
channels: number;
numberOfFrames: number;
numberOfPlays: number;
text: Record<string, string>;
resolution?: PngResolution;
palette?: IndexedColors;
transparency?: Uint16Array;
iccEmbeddedProfile?: IccEmbeddedProfile;
frames: DecodedApngFrame[];
}
export interface ApngFrame {
sequenceNumber: number;
width: number;
height: number;
xOffset: number;
yOffset: number;
delayNumber: number;
delayDenominator: number;
disposeOp: number;
blendOp: number;
data: PngDataArray;
}
export interface DecodedApngFrame {
sequenceNumber: number;
delayNumber: number;
delayDenominator: number;
data: PngDataArray;
}
export interface PngDecoderOptions {
checkCrc?: boolean;
}
export interface PngEncoderOptions {
interlace?: 'null' | 'Adam7';
zlib?: DeflateFunctionOptions;
}
export type IndexedColors = number[][];
export interface IccEmbeddedProfile {
name: string;
profile: Uint8Array;
}