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