diff --git a/CHANGELOG.md b/CHANGELOG.md index ea11cdd..f3d65fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [1.2.1] - 2024-01-?? + +### Added + +- image: Added PNG event-based parser. + +### Changed + +- io: Fix ByteStream bug where skip(0) did not return the ByteStream. + ## [1.2.0] - 2024-01-15 ### Added diff --git a/image/parsers/png.js b/image/parsers/png.js new file mode 100644 index 0000000..615cc49 --- /dev/null +++ b/image/parsers/png.js @@ -0,0 +1,298 @@ +/* + * png.js + * + * An event-based parser for PNG images. + * + * Licensed under the MIT License + * + * Copyright(c) 2024 Google Inc. + */ + +import * as fs from 'node:fs'; // TODO: Remove. +import { ByteStream } from '../../io/bytestream.js'; + +// https://en.wikipedia.org/wiki/PNG#File_format +// https://www.w3.org/TR/2003/REC-PNG-20031110 + +const SIG = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + +/** @enum {string} */ +export const PngParseEventType = { + IHDR: 'image_header', + PLTE: 'palette', + IDAT: 'image_data', +}; + +/** @enum {number} */ +export const PngColorType = { + GREYSCALE: 0, + TRUE_COLOR: 2, + INDEXED_COLOR: 3, + GREYSCALE_WITH_ALPHA: 4, + TRUE_COLOR_WITH_ALPHA: 6, +}; + +/** @enum {number} */ +export const PngInterlaceMethod = { + NO_INTERLACE: 0, + ADAM7_INTERLACE: 1, +} + +/** + * @typedef PngImageHeader + * @property {number} width + * @property {number} height + * @property {number} bitDepth + * @property {PngColorType} colorType + * @property {number} compressionMethod + * @property {number} filterMethod + * @property {number} interlaceMethod + */ + +export class PngImageHeaderEvent extends Event { + /** @param {PngImageHeader} */ + constructor(header) { + super(PngParseEventType.IHDR); + /** @type {PngImageHeader} */ + this.imageHeader = header; + } +} + +/** + * @typedef PngColor + * @property {number} red + * @property {number} green + * @property {number} blue + */ + +/** + * @typedef PngPalette + * @property {PngColor[]} entries + */ + +export class PngPaletteEvent extends Event { + /** @param {PngPalette} */ + constructor(palette) { + super(PngParseEventType.PLTE); + /** @type {PngPalette} */ + this.palette = palette; + } +} + +/** + * @typedef PngImageData + * @property {Uint8Array} rawImageData + */ + +export class PngImageDataEvent extends Event { + /** @param {PngImageData} */ + constructor(data) { + super(PngParseEventType.IDAT); + /** @type {PngImageData} */ + this.data = data; + } +} + +/** + * @typedef PngChunk Internal use only. + * @property {number} length + * @property {string} chunkType + * @property {ByteStream} chunkStream Do not read more than length! + * @property {number} crc + */ + +export class PngParser extends EventTarget { + /** + * @type {ByteStream} + * @private + */ + bstream; + + /** + * @type {PngColorType} + * @private + */ + colorType; + + /** @param {ArrayBuffer} ab */ + constructor(ab) { + super(); + this.bstream = new ByteStream(ab); + this.bstream.setBigEndian(); + } + + /** + * Type-safe way to bind a listener for a PngImageHeaderEvent. + * @param {function(PngImageHeaderEvent): void} listener + * @returns {PngParser} for chaining + */ + onImageHeader(listener) { + super.addEventListener(PngParseEventType.IHDR, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a PngPaletteEvent. + * @param {function(PngPaletteEvent): void} listener + * @returns {PngParser} for chaining + */ + onPalette(listener) { + super.addEventListener(PngParseEventType.PLTE, listener); + return this; + } + + /** + * Type-safe way to bind a listener for a PngImageDataEvent. + * @param {function(PngImageDataEvent): void} listener + * @returns {PngParser} for chaining + */ + onImageData(listener) { + super.addEventListener(PngParseEventType.IDAT, listener); + return this; + } + + /** @returns {Promise} A Promise that resolves when the parsing is complete. */ + async start() { + const sigLength = SIG.byteLength; + const sig = this.bstream.readBytes(sigLength); + for (let sb = 0; sb < sigLength; ++sb) { + if (sig[sb] !== SIG[sb]) throw `Bad PNG signature: ${sig}`; + } + + /** @type {PngChunk} */ + let chunk; + do { + const length = this.bstream.readNumber(4); + chunk = { + length, + chunkType: this.bstream.readString(4), + chunkStream: this.bstream.tee(), + crc: this.bstream.skip(length).readNumber(4), + }; + + const chStream = chunk.chunkStream; + switch (chunk.chunkType) { + // https://www.w3.org/TR/2003/REC-PNG-20031110/#11IHDR + case 'IHDR': + if (this.colorType) throw `Found multiple IHDR chunks`; + /** @type {PngImageHeader} */ + const header = { + width: chStream.readNumber(4), + height: chStream.readNumber(4), + bitDepth: chStream.readNumber(1), + colorType: chStream.readNumber(1), + compressionMethod: chStream.readNumber(1), + filterMethod: chStream.readNumber(1), + interlaceMethod: chStream.readNumber(1), + }; + if (!Object.values(PngColorType).includes(header.colorType)) { + throw `Bad PNG color type: ${header.colorType}`; + } + if (header.compressionMethod !== 0) { + throw `Bad PNG compression method: ${header.compressionMethod}`; + } + if (header.filterMethod !== 0) { + throw `Bad PNG filter method: ${header.filterMethod}`; + } + if (!Object.values(PngInterlaceMethod).includes(header.interlaceMethod)) { + throw `Bad PNG interlace method: ${header.interlaceMethod}`; + } + + this.colorType = header.colorType; + + this.dispatchEvent(new PngImageHeaderEvent(header)); + break; + + // https://www.w3.org/TR/2003/REC-PNG-20031110/#11PLTE + case 'PLTE': + if (this.colorType === undefined) throw `PLTE before IHDR`; + if (this.colorType === PngColorType.GREYSCALE || + this.colorType === PngColorType.GREYSCALE_WITH_ALPHA) throw `PLTE with greyscale`; + if (length % 3 !== 0) throw `PLTE length was not divisible by 3`; + + /** @type {PngColor[]} */ + const paletteEntries = []; + for (let p = 0; p < length / 3; ++p) { + paletteEntries.push({ + red: chStream.readNumber(1), + green: chStream.readNumber(1), + blue: chStream.readNumber(1), + }); + } + + const palette = { + paletteEntries, + }; + + this.dispatchEvent(new PngPaletteEvent(palette)); + break; + + // https://www.w3.org/TR/2003/REC-PNG-20031110/#11IDAT + case 'IDAT': + /** @type {PngImageData} */ + const data = { + rawImageData: chStream.readBytes(chunk.length), + }; + this.dispatchEvent(new PngImageDataEvent(data)); + break; + + case 'IEND': + break; + + default: + console.log(`Found an unhandled chunk: ${chunk.chunkType}`); + break; + } + } while (chunk.chunkType !== 'IEND'); + } +} + +const FILES = `PngSuite.png basn0g04.png bggn4a16.png cs8n2c08.png f03n2c08.png g10n3p04.png s01i3p01.png s32i3p04.png tbbn0g04.png xd0n2c08.png +basi0g01.png basn0g08.png bgwn6a08.png cs8n3p08.png f04n0g08.png g25n0g16.png s01n3p01.png s32n3p04.png tbbn2c16.png xd3n2c08.png +basi0g02.png basn0g16.png bgyn6a16.png ct0n0g04.png f04n2c08.png g25n2c08.png s02i3p01.png s33i3p04.png tbbn3p08.png xd9n2c08.png +basi0g04.png basn2c08.png ccwn2c08.png ct1n0g04.png f99n0g04.png g25n3p04.png s02n3p01.png s33n3p04.png tbgn2c16.png xdtn0g01.png +basi0g08.png basn2c16.png ccwn3p08.png cten0g04.png g03n0g16.png oi1n0g16.png s03i3p01.png s34i3p04.png tbgn3p08.png xhdn0g08.png +basi0g16.png basn3p01.png cdfn2c08.png ctfn0g04.png g03n2c08.png oi1n2c16.png s03n3p01.png s34n3p04.png tbrn2c08.png xlfn0g04.png +basi2c08.png basn3p02.png cdhn2c08.png ctgn0g04.png g03n3p04.png oi2n0g16.png s04i3p01.png s35i3p04.png tbwn0g16.png xs1n0g01.png +basi2c16.png basn3p04.png cdsn2c08.png cthn0g04.png g04n0g16.png oi2n2c16.png s04n3p01.png s35n3p04.png tbwn3p08.png xs2n0g01.png +basi3p01.png basn3p08.png cdun2c08.png ctjn0g04.png g04n2c08.png oi4n0g16.png s05i3p02.png s36i3p04.png tbyn3p08.png xs4n0g01.png +basi3p02.png basn4a08.png ch1n3p04.png ctzn0g04.png g04n3p04.png oi4n2c16.png s05n3p02.png s36n3p04.png tm3n3p02.png xs7n0g01.png +basi3p04.png basn4a16.png ch2n3p08.png exif2c08.png g05n0g16.png oi9n0g16.png s06i3p02.png s37i3p04.png tp0n0g08.png z00n2c08.png +basi3p08.png basn6a08.png cm0n0g04.png f00n0g08.png g05n2c08.png oi9n2c16.png s06n3p02.png s37n3p04.png tp0n2c08.png z03n2c08.png +basi4a08.png basn6a16.png cm7n0g04.png f00n2c08.png g05n3p04.png pp0n2c16.png s07i3p02.png s38i3p04.png tp0n3p08.png z06n2c08.png +basi4a16.png bgai4a08.png cm9n0g04.png f01n0g08.png g07n0g16.png pp0n6a08.png s07n3p02.png s38n3p04.png tp1n3p08.png z09n2c08.png +basi6a08.png bgai4a16.png cs3n2c16.png f01n2c08.png g07n2c08.png ps1n0g08.png s08i3p02.png s39i3p04.png xc1n0g08.png +basi6a16.png bgan6a08.png cs3n3p08.png f02n0g08.png g07n3p04.png ps1n2c16.png s08n3p02.png s39n3p04.png xc9n2c08.png +basn0g01.png bgan6a16.png cs5n2c08.png f02n2c08.png g10n0g16.png ps2n0g08.png s09i3p02.png s40i3p04.png xcrn0g04.png +basn0g02.png bgbn4a08.png cs5n3p08.png f03n0g08.png g10n2c08.png ps2n2c16.png s09n3p02.png s40n3p04.png xcsn0g01.png` + .replace(/\s+/g, ' ') + .split(' ') + .map(fn => `tests/image-testfiles/${fn}`); + +async function main() { + for (const fileName of FILES) { + if (!fileName.includes('3p')) continue; + + console.log(`file: ${fileName}`); + const nodeBuf = fs.readFileSync(fileName); + const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length); + const parser = new PngParser(ab); + parser.onImageHeader(evt => { + // console.dir(evt.imageHeader); + }); + parser.onPalette(evt => { + console.dir(evt.palette); + }); + parser.onImageData(evt => { + // console.dir(evt); + }); + + try { + await parser.start(); + } catch (err) { + if (!fileName.startsWith('tests/image-testfiles/x')) throw err; + } + } +} + +main(); \ No newline at end of file diff --git a/tests/image-parsers-jpeg.spec.js b/tests/image-parsers-jpeg.spec.js index aad9a52..7bdd678 100644 --- a/tests/image-parsers-jpeg.spec.js +++ b/tests/image-parsers-jpeg.spec.js @@ -4,12 +4,8 @@ import { expect } from 'chai'; import { JpegParser } from '../image/parsers/jpeg.js'; import { ExifDataFormat, ExifTagNumber } from '../image/parsers/exif.js'; -/** - * @typedef {import('../image/parsers/jpeg.js').JpegStartOfFrame} JpegStartOfFrame - */ -/** - * @typedef {import('../image/parsers/exif.js').ExifValue} ExifValue - */ +/** @typedef {import('../image/parsers/jpeg.js').JpegStartOfFrame} JpegStartOfFrame */ +/** @typedef {import('../image/parsers/exif.js').ExifValue} ExifValue */ const FILE_LONG_DESC = 'tests/image-testfiles/long_description.jpg' diff --git a/tests/image-parsers-png.spec.js b/tests/image-parsers-png.spec.js new file mode 100644 index 0000000..b4ae474 --- /dev/null +++ b/tests/image-parsers-png.spec.js @@ -0,0 +1,61 @@ +import * as fs from 'node:fs'; +import 'mocha'; +import { expect } from 'chai'; +import { PngColorType, PngInterlaceMethod, PngParser } from '../image/parsers/png.js'; +import { fail } from 'node:assert'; + +/** @typedef {import('../image/parsers/png.js').PngImageHeader} PngImageHeader */ +/** @typedef {import('../image/parsers/png.js').PngImageData} PngImageData */ + +function getPngParser(fileName) { + const nodeBuf = fs.readFileSync(fileName); + const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length); + return new PngParser(ab); +} + +describe('bitjs.image.parsers.PngParser', () => { + describe('IHDR', () => { + it('extracts IHDR', async () => { + /** @type {PngImageHeader} */ + let header; + + await getPngParser('tests/image-testfiles/PngSuite.png') + .onImageHeader(evt => { header = evt.imageHeader }) + .start(); + + expect(header.width).equals(256); + expect(header.height).equals(256); + expect(header.bitDepth).equals(8); + expect(header.colorType).equals(PngColorType.TRUE_COLOR); + expect(header.compressionMethod).equals(0); + expect(header.filterMethod).equals(0); + expect(header.interlaceMethod).equals(PngInterlaceMethod.NO_INTERLACE); + }); + + it('throws on corrupt signature', async () => { + /** @type {PngImageHeader} */ + let header; + + try { + await getPngParser('tests/image-testfiles/xs1n0g01.png') + .onImageHeader(evt => { header = evt.imageHeader }) + .start(); + throw new Error(`PngParser did not throw an error for corrupt PNG signature`); + } catch (err) { + expect(err.startsWith('Bad PNG signature')).equals(true); + } + }); + }); + + it('extracts IDAT', async () => { + /** @type {PngImageData} */ + let data; + + await getPngParser('tests/image-testfiles/PngSuite.png') + .onImageData(evt => { data = evt.data }) + .start(); + + expect(data.rawImageData.byteLength).equals(2205); + expect(data.rawImageData[0]).equals(120); + }); +}); diff --git a/tests/image-testfiles/PngSuite.png b/tests/image-testfiles/PngSuite.png new file mode 100644 index 0000000..205460d Binary files /dev/null and b/tests/image-testfiles/PngSuite.png differ diff --git a/tests/image-testfiles/xs1n0g01.png b/tests/image-testfiles/xs1n0g01.png new file mode 100644 index 0000000..1817c51 Binary files /dev/null and b/tests/image-testfiles/xs1n0g01.png differ