Skip to content

Commit f1096f0

Browse files
committed
PngParser: Add support for sPLT chunk
1 parent 7b1906a commit f1096f0

File tree

3 files changed

+115
-89
lines changed

3 files changed

+115
-89
lines changed

image/parsers/png.js

Lines changed: 72 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
* Copyright(c) 2024 Google Inc.
99
*/
1010

11-
import * as fs from 'node:fs'; // TODO: Remove.
1211
import { ByteStream } from '../../io/bytestream.js';
1312
import { getExifProfile } from './exif.js';
1413

@@ -17,10 +16,6 @@ import { getExifProfile } from './exif.js';
1716
// https://www.w3.org/TR/png-3/
1817
// https://en.wikipedia.org/wiki/PNG#File_format
1918

20-
// TODO: Ancillary chunks: sPLT.
21-
22-
// let DEBUG = true;
23-
let DEBUG = false;
2419
const SIG = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
2520

2621
/** @enum {string} */
@@ -39,6 +34,7 @@ export const PngParseEventType = {
3934
iTXt: 'intl_text_data',
4035
pHYs: 'physical_pixel_dims',
4136
sBIT: 'significant_bits',
37+
sPLT: 'suggested_palette',
4238
tEXt: 'textual_data',
4339
tIME: 'last_mod_time',
4440
tRNS: 'transparency',
@@ -314,6 +310,31 @@ export class PngHistogramEvent extends Event {
314310
}
315311
}
316312

313+
/**
314+
* @typedef PngSuggestedPaletteEntry
315+
* @property {number} red
316+
* @property {number} green
317+
* @property {number} blue
318+
* @property {number} alpha
319+
* @property {number} frequency
320+
*/
321+
322+
/**
323+
* @typedef PngSuggestedPalette
324+
* @property {string} paletteName
325+
* @property {number} sampleDepth Either 8 or 16.
326+
* @property {PngSuggestedPaletteEntry[]} entries
327+
*/
328+
329+
export class PngSuggestedPaletteEvent extends Event {
330+
/** @param {PngSuggestedPalette} suggestedPalette */
331+
constructor(suggestedPalette) {
332+
super(PngParseEventType.sPLT);
333+
/** @type {PngSuggestedPalette} */
334+
this.suggestedPalette = suggestedPalette;
335+
}
336+
}
337+
317338
/**
318339
* @typedef PngChunk Internal use only.
319340
* @property {number} length
@@ -478,6 +499,16 @@ export class PngParser extends EventTarget {
478499
return this;
479500
}
480501

502+
/**
503+
* Type-safe way to bind a listener for a PngSuggestedPaletteEvent.
504+
* @param {function(PngSuggestedPaletteEvent): void} listener
505+
* @returns {PngParser} for chaining
506+
*/
507+
onSuggestedPalette(listener) {
508+
super.addEventListener(PngParseEventType.sPLT, listener);
509+
return this;
510+
}
511+
481512
/**
482513
* Type-safe way to bind a listener for a PngTextualDataEvent.
483514
* @param {function(PngTextualDataEvent): void} listener
@@ -784,6 +815,42 @@ export class PngParser extends EventTarget {
784815
this.dispatchEvent(new PngHistogramEvent(hist));
785816
break;
786817

818+
// https://www.w3.org/TR/png-3/#11sPLT
819+
case 'sPLT':
820+
const spByteArr = chStream.peekBytes(length);
821+
const spNameNullIndex = spByteArr.indexOf(0);
822+
823+
/** @type {PngSuggestedPalette} */
824+
const sPalette = {
825+
paletteName: chStream.readString(spNameNullIndex),
826+
sampleDepth: chStream.skip(1).readNumber(1),
827+
entries: [],
828+
};
829+
830+
const sampleDepth = sPalette.sampleDepth;
831+
if (![8, 16].includes(sampleDepth)) throw `Invalid sPLT sample depth: ${sampleDepth}`;
832+
833+
const remainingByteLength = length - spNameNullIndex - 1 - 1;
834+
const compByteLength = sPalette.sampleDepth === 8 ? 1 : 2;
835+
const entryByteLength = 4 * compByteLength + 2;
836+
if (remainingByteLength % entryByteLength !== 0) {
837+
throw `Invalid # of bytes left in sPLT: ${remainingByteLength}`;
838+
}
839+
840+
const numEntries = remainingByteLength / entryByteLength;
841+
for (let e = 0; e < numEntries; ++e) {
842+
sPalette.entries.push({
843+
red: chStream.readNumber(compByteLength),
844+
green: chStream.readNumber(compByteLength),
845+
blue: chStream.readNumber(compByteLength),
846+
alpha: chStream.readNumber(compByteLength),
847+
frequency: chStream.readNumber(2),
848+
});
849+
}
850+
851+
this.dispatchEvent(new PngSuggestedPaletteEvent(sPalette));
852+
break;
853+
787854
// https://www.w3.org/TR/png-3/#11IDAT
788855
case 'IDAT':
789856
/** @type {PngImageData} */
@@ -803,87 +870,3 @@ export class PngParser extends EventTarget {
803870
} while (chunk.chunkType !== 'IEND');
804871
}
805872
}
806-
807-
const FILES = `PngSuite.png basn0g04.png bggn4a16.png cs8n2c08.png f03n2c08.png g10n3p04.png s01i3p01.png s32i3p04.png tbbn0g04.png xd0n2c08.png
808-
basi0g01.png basn0g08.png bgwn6a08.png cs8n3p08.png f04n0g08.png g25n0g16.png s01n3p01.png s32n3p04.png tbbn2c16.png xd3n2c08.png
809-
basi0g02.png basn0g16.png bgyn6a16.png ct0n0g04.png f04n2c08.png g25n2c08.png s02i3p01.png s33i3p04.png tbbn3p08.png xd9n2c08.png
810-
basi0g04.png basn2c08.png ccwn2c08.png ct1n0g04.png f99n0g04.png g25n3p04.png s02n3p01.png s33n3p04.png tbgn2c16.png xdtn0g01.png
811-
basi0g08.png basn2c16.png ccwn3p08.png cten0g04.png g03n0g16.png oi1n0g16.png s03i3p01.png s34i3p04.png tbgn3p08.png xhdn0g08.png
812-
basi0g16.png basn3p01.png cdfn2c08.png ctfn0g04.png g03n2c08.png oi1n2c16.png s03n3p01.png s34n3p04.png tbrn2c08.png xlfn0g04.png
813-
basi2c08.png basn3p02.png cdhn2c08.png ctgn0g04.png g03n3p04.png oi2n0g16.png s04i3p01.png s35i3p04.png tbwn0g16.png xs1n0g01.png
814-
basi2c16.png basn3p04.png cdsn2c08.png cthn0g04.png g04n0g16.png oi2n2c16.png s04n3p01.png s35n3p04.png tbwn3p08.png xs2n0g01.png
815-
basi3p01.png basn3p08.png cdun2c08.png ctjn0g04.png g04n2c08.png oi4n0g16.png s05i3p02.png s36i3p04.png tbyn3p08.png xs4n0g01.png
816-
basi3p02.png basn4a08.png ch1n3p04.png ctzn0g04.png g04n3p04.png oi4n2c16.png s05n3p02.png s36n3p04.png tm3n3p02.png xs7n0g01.png
817-
basi3p04.png basn4a16.png ch2n3p08.png exif2c08.png g05n0g16.png oi9n0g16.png s06i3p02.png s37i3p04.png tp0n0g08.png z00n2c08.png
818-
basi3p08.png basn6a08.png cm0n0g04.png f00n0g08.png g05n2c08.png oi9n2c16.png s06n3p02.png s37n3p04.png tp0n2c08.png z03n2c08.png
819-
basi4a08.png basn6a16.png cm7n0g04.png f00n2c08.png g05n3p04.png pp0n2c16.png s07i3p02.png s38i3p04.png tp0n3p08.png z06n2c08.png
820-
basi4a16.png bgai4a08.png cm9n0g04.png f01n0g08.png g07n0g16.png pp0n6a08.png s07n3p02.png s38n3p04.png tp1n3p08.png z09n2c08.png
821-
basi6a08.png bgai4a16.png cs3n2c16.png f01n2c08.png g07n2c08.png ps1n0g08.png s08i3p02.png s39i3p04.png xc1n0g08.png
822-
basi6a16.png bgan6a08.png cs3n3p08.png f02n0g08.png g07n3p04.png ps1n2c16.png s08n3p02.png s39n3p04.png xc9n2c08.png
823-
basn0g01.png bgan6a16.png cs5n2c08.png f02n2c08.png g10n0g16.png ps2n0g08.png s09i3p02.png s40i3p04.png xcrn0g04.png
824-
basn0g02.png bgbn4a08.png cs5n3p08.png f03n0g08.png g10n2c08.png ps2n2c16.png s09n3p02.png s40n3p04.png xcsn0g01.png`
825-
.replace(/\s+/g, ' ')
826-
.split(' ')
827-
.map(fn => `tests/image-testfiles/${fn}`);
828-
829-
async function main() {
830-
for (const fileName of FILES) {
831-
console.log(`file: ${fileName}`);
832-
const nodeBuf = fs.readFileSync(fileName);
833-
const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length);
834-
const parser = new PngParser(ab);
835-
parser.onImageHeader(evt => {
836-
// console.dir(evt.imageHeader);
837-
});
838-
parser.onGamma(evt => {
839-
// console.dir(evt.imageGamma);
840-
});
841-
parser.onSignificantBits(evt => {
842-
// console.dir(evt.sigBits);
843-
});
844-
parser.onChromaticities(evt => {
845-
// console.dir(evt.chromaticities);
846-
});
847-
parser.onPalette(evt => {
848-
// console.dir(evt.palette);
849-
});
850-
parser.onTransparency(evt => {
851-
// console.dir(evt.transparency);
852-
});
853-
parser.onImageData(evt => {
854-
// console.dir(evt);
855-
});
856-
parser.onTextualData(evt => {
857-
// console.dir(evt.textualData);
858-
});
859-
parser.onCompressedTextualData(evt => {
860-
// console.dir(evt.compressedTextualData);
861-
});
862-
parser.onIntlTextualData(evt => {
863-
// console.dir(evt.intlTextualdata);
864-
});
865-
parser.onBackgroundColor(evt => {
866-
// console.dir(evt.backgroundColor);
867-
});
868-
parser.onLastModTime(evt => {
869-
// console.dir(evt.lastModTime);
870-
});
871-
parser.onPhysicalPixelDimensions(evt => {
872-
// console.dir(evt.physicalPixelDimensions);
873-
});
874-
parser.onExifProfile(evt => {
875-
// console.dir(evt.exifProfile);
876-
});
877-
parser.onHistogram(evt => {
878-
// console.dir(evt.histogram);
879-
});
880-
881-
try {
882-
await parser.start();
883-
} catch (err) {
884-
if (!fileName.startsWith('tests/image-testfiles/x')) throw err;
885-
}
886-
}
887-
}
888-
889-
// main();

tests/image-parsers-png.spec.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ExifDataFormat, ExifTagNumber } from '../image/parsers/exif.js';
99
/** @typedef {import('../image/parsers/png.js').PngBackgroundColor} PngBackgroundColor */
1010
/** @typedef {import('../image/parsers/png.js').PngChromaticies} PngChromaticies */
1111
/** @typedef {import('../image/parsers/png.js').PngCompressedTextualData} PngCompressedTextualData */
12+
/** @typedef {import('../image/parsers/png.js').PngHistogram} PngHistogram */
1213
/** @typedef {import('../image/parsers/png.js').PngImageData} PngImageData */
1314
/** @typedef {import('../image/parsers/png.js').PngImageGamma} PngImageGamma */
1415
/** @typedef {import('../image/parsers/png.js').PngImageHeader} PngImageHeader */
@@ -17,6 +18,7 @@ import { ExifDataFormat, ExifTagNumber } from '../image/parsers/exif.js';
1718
/** @typedef {import('../image/parsers/png.js').PngPalette} PngPalette */
1819
/** @typedef {import('../image/parsers/png.js').PngPhysicalPixelDimensions} PngPhysicalPixelDimensions */
1920
/** @typedef {import('../image/parsers/png.js').PngSignificantBits} PngSignificantBits */
21+
/** @typedef {import('../image/parsers/png.js').PngSuggestedPalette} PngSuggestedPalette */
2022
/** @typedef {import('../image/parsers/png.js').PngTextualData} PngTextualData */
2123
/** @typedef {import('../image/parsers/png.js').PngTransparency} PngTransparency */
2224

@@ -275,4 +277,45 @@ describe('bitjs.image.parsers.PngParser', () => {
275277
expect(descVal.dataFormat).equals(ExifDataFormat.ASCII_STRING);
276278
expect(descVal.stringValue).equals('2017 Willem van Schaik');
277279
});
280+
281+
it('extracts hIST', async () => {
282+
/** @type {PngPalette} */
283+
let palette;
284+
/** @type {PngHistogram} */
285+
let hist;
286+
await getPngParser('tests/image-testfiles/ch1n3p04.png')
287+
.onHistogram(evt => { hist = evt.histogram })
288+
.onPalette(evt => { palette = evt.palette })
289+
.start();
290+
291+
expect(hist.frequencies.length).equals(palette.entries.length);
292+
expect(hist.frequencies[0]).equals(64);
293+
expect(hist.frequencies[1]).equals(112);
294+
});
295+
296+
it('extracts sPLT', async () => {
297+
/** @type {PngSuggestedPalette} */
298+
let sPalette;
299+
await getPngParser('tests/image-testfiles/ps1n0g08.png')
300+
.onSuggestedPalette(evt => { sPalette = evt.suggestedPalette })
301+
.start();
302+
303+
expect(sPalette.entries.length).equals(216);
304+
expect(sPalette.paletteName).equals('six-cube');
305+
expect(sPalette.sampleDepth).equals(8);
306+
307+
const entry0 = sPalette.entries[0];
308+
expect(entry0.red).equals(0);
309+
expect(entry0.green).equals(0);
310+
expect(entry0.blue).equals(0);
311+
expect(entry0.alpha).equals(255);
312+
expect(entry0.frequency).equals(0);
313+
314+
const entry1 = sPalette.entries[1];
315+
expect(entry1.red).equals(0);
316+
expect(entry1.green).equals(0);
317+
expect(entry1.blue).equals(51);
318+
expect(entry1.alpha).equals(255);
319+
expect(entry1.frequency).equals(0);
320+
});
278321
});

tests/image-testfiles/ps1n0g08.png

1.42 KB
Loading

0 commit comments

Comments
 (0)