Skip to content

Commit 193cafa

Browse files
committed
Starter PNG parser
1 parent 2825df0 commit 193cafa

File tree

6 files changed

+371
-6
lines changed

6 files changed

+371
-6
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.2.1] - 2024-01-??
6+
7+
### Added
8+
9+
- image: Added PNG event-based parser.
10+
11+
### Changed
12+
13+
- io: Fix ByteStream bug where skip(0) did not return the ByteStream.
14+
515
## [1.2.0] - 2024-01-15
616

717
### Added

image/parsers/png.js

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
/*
2+
* png.js
3+
*
4+
* An event-based parser for PNG images.
5+
*
6+
* Licensed under the MIT License
7+
*
8+
* Copyright(c) 2024 Google Inc.
9+
*/
10+
11+
import * as fs from 'node:fs'; // TODO: Remove.
12+
import { ByteStream } from '../../io/bytestream.js';
13+
14+
// https://en.wikipedia.org/wiki/PNG#File_format
15+
// https://www.w3.org/TR/2003/REC-PNG-20031110
16+
17+
const SIG = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
18+
19+
/** @enum {string} */
20+
export const PngParseEventType = {
21+
IHDR: 'image_header',
22+
PLTE: 'palette',
23+
IDAT: 'image_data',
24+
};
25+
26+
/** @enum {number} */
27+
export const PngColorType = {
28+
GREYSCALE: 0,
29+
TRUE_COLOR: 2,
30+
INDEXED_COLOR: 3,
31+
GREYSCALE_WITH_ALPHA: 4,
32+
TRUE_COLOR_WITH_ALPHA: 6,
33+
};
34+
35+
/** @enum {number} */
36+
export const PngInterlaceMethod = {
37+
NO_INTERLACE: 0,
38+
ADAM7_INTERLACE: 1,
39+
}
40+
41+
/**
42+
* @typedef PngImageHeader
43+
* @property {number} width
44+
* @property {number} height
45+
* @property {number} bitDepth
46+
* @property {PngColorType} colorType
47+
* @property {number} compressionMethod
48+
* @property {number} filterMethod
49+
* @property {number} interlaceMethod
50+
*/
51+
52+
export class PngImageHeaderEvent extends Event {
53+
/** @param {PngImageHeader} */
54+
constructor(header) {
55+
super(PngParseEventType.IHDR);
56+
/** @type {PngImageHeader} */
57+
this.imageHeader = header;
58+
}
59+
}
60+
61+
/**
62+
* @typedef PngColor
63+
* @property {number} red
64+
* @property {number} green
65+
* @property {number} blue
66+
*/
67+
68+
/**
69+
* @typedef PngPalette
70+
* @property {PngColor[]} entries
71+
*/
72+
73+
export class PngPaletteEvent extends Event {
74+
/** @param {PngPalette} */
75+
constructor(palette) {
76+
super(PngParseEventType.PLTE);
77+
/** @type {PngPalette} */
78+
this.palette = palette;
79+
}
80+
}
81+
82+
/**
83+
* @typedef PngImageData
84+
* @property {Uint8Array} rawImageData
85+
*/
86+
87+
export class PngImageDataEvent extends Event {
88+
/** @param {PngImageData} */
89+
constructor(data) {
90+
super(PngParseEventType.IDAT);
91+
/** @type {PngImageData} */
92+
this.data = data;
93+
}
94+
}
95+
96+
/**
97+
* @typedef PngChunk Internal use only.
98+
* @property {number} length
99+
* @property {string} chunkType
100+
* @property {ByteStream} chunkStream Do not read more than length!
101+
* @property {number} crc
102+
*/
103+
104+
export class PngParser extends EventTarget {
105+
/**
106+
* @type {ByteStream}
107+
* @private
108+
*/
109+
bstream;
110+
111+
/**
112+
* @type {PngColorType}
113+
* @private
114+
*/
115+
colorType;
116+
117+
/** @param {ArrayBuffer} ab */
118+
constructor(ab) {
119+
super();
120+
this.bstream = new ByteStream(ab);
121+
this.bstream.setBigEndian();
122+
}
123+
124+
/**
125+
* Type-safe way to bind a listener for a PngImageHeaderEvent.
126+
* @param {function(PngImageHeaderEvent): void} listener
127+
* @returns {PngParser} for chaining
128+
*/
129+
onImageHeader(listener) {
130+
super.addEventListener(PngParseEventType.IHDR, listener);
131+
return this;
132+
}
133+
134+
/**
135+
* Type-safe way to bind a listener for a PngPaletteEvent.
136+
* @param {function(PngPaletteEvent): void} listener
137+
* @returns {PngParser} for chaining
138+
*/
139+
onPalette(listener) {
140+
super.addEventListener(PngParseEventType.PLTE, listener);
141+
return this;
142+
}
143+
144+
/**
145+
* Type-safe way to bind a listener for a PngImageDataEvent.
146+
* @param {function(PngImageDataEvent): void} listener
147+
* @returns {PngParser} for chaining
148+
*/
149+
onImageData(listener) {
150+
super.addEventListener(PngParseEventType.IDAT, listener);
151+
return this;
152+
}
153+
154+
/** @returns {Promise<void>} A Promise that resolves when the parsing is complete. */
155+
async start() {
156+
const sigLength = SIG.byteLength;
157+
const sig = this.bstream.readBytes(sigLength);
158+
for (let sb = 0; sb < sigLength; ++sb) {
159+
if (sig[sb] !== SIG[sb]) throw `Bad PNG signature: ${sig}`;
160+
}
161+
162+
/** @type {PngChunk} */
163+
let chunk;
164+
do {
165+
const length = this.bstream.readNumber(4);
166+
chunk = {
167+
length,
168+
chunkType: this.bstream.readString(4),
169+
chunkStream: this.bstream.tee(),
170+
crc: this.bstream.skip(length).readNumber(4),
171+
};
172+
173+
const chStream = chunk.chunkStream;
174+
switch (chunk.chunkType) {
175+
// https://www.w3.org/TR/2003/REC-PNG-20031110/#11IHDR
176+
case 'IHDR':
177+
if (this.colorType) throw `Found multiple IHDR chunks`;
178+
/** @type {PngImageHeader} */
179+
const header = {
180+
width: chStream.readNumber(4),
181+
height: chStream.readNumber(4),
182+
bitDepth: chStream.readNumber(1),
183+
colorType: chStream.readNumber(1),
184+
compressionMethod: chStream.readNumber(1),
185+
filterMethod: chStream.readNumber(1),
186+
interlaceMethod: chStream.readNumber(1),
187+
};
188+
if (!Object.values(PngColorType).includes(header.colorType)) {
189+
throw `Bad PNG color type: ${header.colorType}`;
190+
}
191+
if (header.compressionMethod !== 0) {
192+
throw `Bad PNG compression method: ${header.compressionMethod}`;
193+
}
194+
if (header.filterMethod !== 0) {
195+
throw `Bad PNG filter method: ${header.filterMethod}`;
196+
}
197+
if (!Object.values(PngInterlaceMethod).includes(header.interlaceMethod)) {
198+
throw `Bad PNG interlace method: ${header.interlaceMethod}`;
199+
}
200+
201+
this.colorType = header.colorType;
202+
203+
this.dispatchEvent(new PngImageHeaderEvent(header));
204+
break;
205+
206+
// https://www.w3.org/TR/2003/REC-PNG-20031110/#11PLTE
207+
case 'PLTE':
208+
if (this.colorType === undefined) throw `PLTE before IHDR`;
209+
if (this.colorType === PngColorType.GREYSCALE ||
210+
this.colorType === PngColorType.GREYSCALE_WITH_ALPHA) throw `PLTE with greyscale`;
211+
if (length % 3 !== 0) throw `PLTE length was not divisible by 3`;
212+
213+
/** @type {PngColor[]} */
214+
const paletteEntries = [];
215+
for (let p = 0; p < length / 3; ++p) {
216+
paletteEntries.push({
217+
red: chStream.readNumber(1),
218+
green: chStream.readNumber(1),
219+
blue: chStream.readNumber(1),
220+
});
221+
}
222+
223+
const palette = {
224+
paletteEntries,
225+
};
226+
227+
this.dispatchEvent(new PngPaletteEvent(palette));
228+
break;
229+
230+
// https://www.w3.org/TR/2003/REC-PNG-20031110/#11IDAT
231+
case 'IDAT':
232+
/** @type {PngImageData} */
233+
const data = {
234+
rawImageData: chStream.readBytes(chunk.length),
235+
};
236+
this.dispatchEvent(new PngImageDataEvent(data));
237+
break;
238+
239+
case 'IEND':
240+
break;
241+
242+
default:
243+
console.log(`Found an unhandled chunk: ${chunk.chunkType}`);
244+
break;
245+
}
246+
} while (chunk.chunkType !== 'IEND');
247+
}
248+
}
249+
250+
const FILES = `PngSuite.png basn0g04.png bggn4a16.png cs8n2c08.png f03n2c08.png g10n3p04.png s01i3p01.png s32i3p04.png tbbn0g04.png xd0n2c08.png
251+
basi0g01.png basn0g08.png bgwn6a08.png cs8n3p08.png f04n0g08.png g25n0g16.png s01n3p01.png s32n3p04.png tbbn2c16.png xd3n2c08.png
252+
basi0g02.png basn0g16.png bgyn6a16.png ct0n0g04.png f04n2c08.png g25n2c08.png s02i3p01.png s33i3p04.png tbbn3p08.png xd9n2c08.png
253+
basi0g04.png basn2c08.png ccwn2c08.png ct1n0g04.png f99n0g04.png g25n3p04.png s02n3p01.png s33n3p04.png tbgn2c16.png xdtn0g01.png
254+
basi0g08.png basn2c16.png ccwn3p08.png cten0g04.png g03n0g16.png oi1n0g16.png s03i3p01.png s34i3p04.png tbgn3p08.png xhdn0g08.png
255+
basi0g16.png basn3p01.png cdfn2c08.png ctfn0g04.png g03n2c08.png oi1n2c16.png s03n3p01.png s34n3p04.png tbrn2c08.png xlfn0g04.png
256+
basi2c08.png basn3p02.png cdhn2c08.png ctgn0g04.png g03n3p04.png oi2n0g16.png s04i3p01.png s35i3p04.png tbwn0g16.png xs1n0g01.png
257+
basi2c16.png basn3p04.png cdsn2c08.png cthn0g04.png g04n0g16.png oi2n2c16.png s04n3p01.png s35n3p04.png tbwn3p08.png xs2n0g01.png
258+
basi3p01.png basn3p08.png cdun2c08.png ctjn0g04.png g04n2c08.png oi4n0g16.png s05i3p02.png s36i3p04.png tbyn3p08.png xs4n0g01.png
259+
basi3p02.png basn4a08.png ch1n3p04.png ctzn0g04.png g04n3p04.png oi4n2c16.png s05n3p02.png s36n3p04.png tm3n3p02.png xs7n0g01.png
260+
basi3p04.png basn4a16.png ch2n3p08.png exif2c08.png g05n0g16.png oi9n0g16.png s06i3p02.png s37i3p04.png tp0n0g08.png z00n2c08.png
261+
basi3p08.png basn6a08.png cm0n0g04.png f00n0g08.png g05n2c08.png oi9n2c16.png s06n3p02.png s37n3p04.png tp0n2c08.png z03n2c08.png
262+
basi4a08.png basn6a16.png cm7n0g04.png f00n2c08.png g05n3p04.png pp0n2c16.png s07i3p02.png s38i3p04.png tp0n3p08.png z06n2c08.png
263+
basi4a16.png bgai4a08.png cm9n0g04.png f01n0g08.png g07n0g16.png pp0n6a08.png s07n3p02.png s38n3p04.png tp1n3p08.png z09n2c08.png
264+
basi6a08.png bgai4a16.png cs3n2c16.png f01n2c08.png g07n2c08.png ps1n0g08.png s08i3p02.png s39i3p04.png xc1n0g08.png
265+
basi6a16.png bgan6a08.png cs3n3p08.png f02n0g08.png g07n3p04.png ps1n2c16.png s08n3p02.png s39n3p04.png xc9n2c08.png
266+
basn0g01.png bgan6a16.png cs5n2c08.png f02n2c08.png g10n0g16.png ps2n0g08.png s09i3p02.png s40i3p04.png xcrn0g04.png
267+
basn0g02.png bgbn4a08.png cs5n3p08.png f03n0g08.png g10n2c08.png ps2n2c16.png s09n3p02.png s40n3p04.png xcsn0g01.png`
268+
.replace(/\s+/g, ' ')
269+
.split(' ')
270+
.map(fn => `tests/image-testfiles/${fn}`);
271+
272+
async function main() {
273+
for (const fileName of FILES) {
274+
if (!fileName.includes('3p')) continue;
275+
276+
console.log(`file: ${fileName}`);
277+
const nodeBuf = fs.readFileSync(fileName);
278+
const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length);
279+
const parser = new PngParser(ab);
280+
parser.onImageHeader(evt => {
281+
// console.dir(evt.imageHeader);
282+
});
283+
parser.onPalette(evt => {
284+
console.dir(evt.palette);
285+
});
286+
parser.onImageData(evt => {
287+
// console.dir(evt);
288+
});
289+
290+
try {
291+
await parser.start();
292+
} catch (err) {
293+
if (!fileName.startsWith('tests/image-testfiles/x')) throw err;
294+
}
295+
}
296+
}
297+
298+
main();

tests/image-parsers-jpeg.spec.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,8 @@ import { expect } from 'chai';
44
import { JpegParser } from '../image/parsers/jpeg.js';
55
import { ExifDataFormat, ExifTagNumber } from '../image/parsers/exif.js';
66

7-
/**
8-
* @typedef {import('../image/parsers/jpeg.js').JpegStartOfFrame} JpegStartOfFrame
9-
*/
10-
/**
11-
* @typedef {import('../image/parsers/exif.js').ExifValue} ExifValue
12-
*/
7+
/** @typedef {import('../image/parsers/jpeg.js').JpegStartOfFrame} JpegStartOfFrame */
8+
/** @typedef {import('../image/parsers/exif.js').ExifValue} ExifValue */
139

1410
const FILE_LONG_DESC = 'tests/image-testfiles/long_description.jpg'
1511

tests/image-parsers-png.spec.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as fs from 'node:fs';
2+
import 'mocha';
3+
import { expect } from 'chai';
4+
import { PngColorType, PngInterlaceMethod, PngParser } from '../image/parsers/png.js';
5+
import { fail } from 'node:assert';
6+
7+
/** @typedef {import('../image/parsers/png.js').PngImageHeader} PngImageHeader */
8+
/** @typedef {import('../image/parsers/png.js').PngImageData} PngImageData */
9+
10+
function getPngParser(fileName) {
11+
const nodeBuf = fs.readFileSync(fileName);
12+
const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length);
13+
return new PngParser(ab);
14+
}
15+
16+
describe('bitjs.image.parsers.PngParser', () => {
17+
describe('IHDR', () => {
18+
it('extracts IHDR', async () => {
19+
/** @type {PngImageHeader} */
20+
let header;
21+
22+
await getPngParser('tests/image-testfiles/PngSuite.png')
23+
.onImageHeader(evt => { header = evt.imageHeader })
24+
.start();
25+
26+
expect(header.width).equals(256);
27+
expect(header.height).equals(256);
28+
expect(header.bitDepth).equals(8);
29+
expect(header.colorType).equals(PngColorType.TRUE_COLOR);
30+
expect(header.compressionMethod).equals(0);
31+
expect(header.filterMethod).equals(0);
32+
expect(header.interlaceMethod).equals(PngInterlaceMethod.NO_INTERLACE);
33+
});
34+
35+
it('throws on corrupt signature', async () => {
36+
/** @type {PngImageHeader} */
37+
let header;
38+
39+
try {
40+
await getPngParser('tests/image-testfiles/xs1n0g01.png')
41+
.onImageHeader(evt => { header = evt.imageHeader })
42+
.start();
43+
throw new Error(`PngParser did not throw an error for corrupt PNG signature`);
44+
} catch (err) {
45+
expect(err.startsWith('Bad PNG signature')).equals(true);
46+
}
47+
});
48+
});
49+
50+
it('extracts IDAT', async () => {
51+
/** @type {PngImageData} */
52+
let data;
53+
54+
await getPngParser('tests/image-testfiles/PngSuite.png')
55+
.onImageData(evt => { data = evt.data })
56+
.start();
57+
58+
expect(data.rawImageData.byteLength).equals(2205);
59+
expect(data.rawImageData[0]).equals(120);
60+
});
61+
});

tests/image-testfiles/PngSuite.png

2.21 KB
Loading

tests/image-testfiles/xs1n0g01.png

164 Bytes
Loading

0 commit comments

Comments
 (0)