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 ( ) ;
0 commit comments