From f1096f0ab202c093ed1d3d881e3d3a51df66bd6e Mon Sep 17 00:00:00 2001 From: Jeff Schiller Date: Thu, 18 Jan 2024 22:05:09 -0800 Subject: [PATCH] PngParser: Add support for sPLT chunk --- image/parsers/png.js | 161 +++++++++++++---------------- tests/image-parsers-png.spec.js | 43 ++++++++ tests/image-testfiles/ps1n0g08.png | Bin 0 -> 1456 bytes 3 files changed, 115 insertions(+), 89 deletions(-) create mode 100644 tests/image-testfiles/ps1n0g08.png diff --git a/image/parsers/png.js b/image/parsers/png.js index 10a859a..ba1a504 100644 --- a/image/parsers/png.js +++ b/image/parsers/png.js @@ -8,7 +8,6 @@ * Copyright(c) 2024 Google Inc. */ -import * as fs from 'node:fs'; // TODO: Remove. import { ByteStream } from '../../io/bytestream.js'; import { getExifProfile } from './exif.js'; @@ -17,10 +16,6 @@ import { getExifProfile } from './exif.js'; // https://www.w3.org/TR/png-3/ // https://en.wikipedia.org/wiki/PNG#File_format -// TODO: Ancillary chunks: sPLT. - -// let DEBUG = true; -let DEBUG = false; const SIG = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); /** @enum {string} */ @@ -39,6 +34,7 @@ export const PngParseEventType = { iTXt: 'intl_text_data', pHYs: 'physical_pixel_dims', sBIT: 'significant_bits', + sPLT: 'suggested_palette', tEXt: 'textual_data', tIME: 'last_mod_time', tRNS: 'transparency', @@ -314,6 +310,31 @@ export class PngHistogramEvent extends Event { } } +/** + * @typedef PngSuggestedPaletteEntry + * @property {number} red + * @property {number} green + * @property {number} blue + * @property {number} alpha + * @property {number} frequency + */ + +/** + * @typedef PngSuggestedPalette + * @property {string} paletteName + * @property {number} sampleDepth Either 8 or 16. + * @property {PngSuggestedPaletteEntry[]} entries + */ + +export class PngSuggestedPaletteEvent extends Event { + /** @param {PngSuggestedPalette} suggestedPalette */ + constructor(suggestedPalette) { + super(PngParseEventType.sPLT); + /** @type {PngSuggestedPalette} */ + this.suggestedPalette = suggestedPalette; + } +} + /** * @typedef PngChunk Internal use only. * @property {number} length @@ -478,6 +499,16 @@ export class PngParser extends EventTarget { return this; } + /** + * Type-safe way to bind a listener for a PngSuggestedPaletteEvent. + * @param {function(PngSuggestedPaletteEvent): void} listener + * @returns {PngParser} for chaining + */ + onSuggestedPalette(listener) { + super.addEventListener(PngParseEventType.sPLT, listener); + return this; + } + /** * Type-safe way to bind a listener for a PngTextualDataEvent. * @param {function(PngTextualDataEvent): void} listener @@ -784,6 +815,42 @@ export class PngParser extends EventTarget { this.dispatchEvent(new PngHistogramEvent(hist)); break; + // https://www.w3.org/TR/png-3/#11sPLT + case 'sPLT': + const spByteArr = chStream.peekBytes(length); + const spNameNullIndex = spByteArr.indexOf(0); + + /** @type {PngSuggestedPalette} */ + const sPalette = { + paletteName: chStream.readString(spNameNullIndex), + sampleDepth: chStream.skip(1).readNumber(1), + entries: [], + }; + + const sampleDepth = sPalette.sampleDepth; + if (![8, 16].includes(sampleDepth)) throw `Invalid sPLT sample depth: ${sampleDepth}`; + + const remainingByteLength = length - spNameNullIndex - 1 - 1; + const compByteLength = sPalette.sampleDepth === 8 ? 1 : 2; + const entryByteLength = 4 * compByteLength + 2; + if (remainingByteLength % entryByteLength !== 0) { + throw `Invalid # of bytes left in sPLT: ${remainingByteLength}`; + } + + const numEntries = remainingByteLength / entryByteLength; + for (let e = 0; e < numEntries; ++e) { + sPalette.entries.push({ + red: chStream.readNumber(compByteLength), + green: chStream.readNumber(compByteLength), + blue: chStream.readNumber(compByteLength), + alpha: chStream.readNumber(compByteLength), + frequency: chStream.readNumber(2), + }); + } + + this.dispatchEvent(new PngSuggestedPaletteEvent(sPalette)); + break; + // https://www.w3.org/TR/png-3/#11IDAT case 'IDAT': /** @type {PngImageData} */ @@ -803,87 +870,3 @@ export class PngParser extends EventTarget { } 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) { - 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.onGamma(evt => { - // console.dir(evt.imageGamma); - }); - parser.onSignificantBits(evt => { - // console.dir(evt.sigBits); - }); - parser.onChromaticities(evt => { - // console.dir(evt.chromaticities); - }); - parser.onPalette(evt => { - // console.dir(evt.palette); - }); - parser.onTransparency(evt => { - // console.dir(evt.transparency); - }); - parser.onImageData(evt => { - // console.dir(evt); - }); - parser.onTextualData(evt => { - // console.dir(evt.textualData); - }); - parser.onCompressedTextualData(evt => { - // console.dir(evt.compressedTextualData); - }); - parser.onIntlTextualData(evt => { - // console.dir(evt.intlTextualdata); - }); - parser.onBackgroundColor(evt => { - // console.dir(evt.backgroundColor); - }); - parser.onLastModTime(evt => { - // console.dir(evt.lastModTime); - }); - parser.onPhysicalPixelDimensions(evt => { - // console.dir(evt.physicalPixelDimensions); - }); - parser.onExifProfile(evt => { - // console.dir(evt.exifProfile); - }); - parser.onHistogram(evt => { - // console.dir(evt.histogram); - }); - - try { - await parser.start(); - } catch (err) { - if (!fileName.startsWith('tests/image-testfiles/x')) throw err; - } - } -} - -// main(); diff --git a/tests/image-parsers-png.spec.js b/tests/image-parsers-png.spec.js index 815cacf..da8c630 100644 --- a/tests/image-parsers-png.spec.js +++ b/tests/image-parsers-png.spec.js @@ -9,6 +9,7 @@ import { ExifDataFormat, ExifTagNumber } from '../image/parsers/exif.js'; /** @typedef {import('../image/parsers/png.js').PngBackgroundColor} PngBackgroundColor */ /** @typedef {import('../image/parsers/png.js').PngChromaticies} PngChromaticies */ /** @typedef {import('../image/parsers/png.js').PngCompressedTextualData} PngCompressedTextualData */ +/** @typedef {import('../image/parsers/png.js').PngHistogram} PngHistogram */ /** @typedef {import('../image/parsers/png.js').PngImageData} PngImageData */ /** @typedef {import('../image/parsers/png.js').PngImageGamma} PngImageGamma */ /** @typedef {import('../image/parsers/png.js').PngImageHeader} PngImageHeader */ @@ -17,6 +18,7 @@ import { ExifDataFormat, ExifTagNumber } from '../image/parsers/exif.js'; /** @typedef {import('../image/parsers/png.js').PngPalette} PngPalette */ /** @typedef {import('../image/parsers/png.js').PngPhysicalPixelDimensions} PngPhysicalPixelDimensions */ /** @typedef {import('../image/parsers/png.js').PngSignificantBits} PngSignificantBits */ +/** @typedef {import('../image/parsers/png.js').PngSuggestedPalette} PngSuggestedPalette */ /** @typedef {import('../image/parsers/png.js').PngTextualData} PngTextualData */ /** @typedef {import('../image/parsers/png.js').PngTransparency} PngTransparency */ @@ -275,4 +277,45 @@ describe('bitjs.image.parsers.PngParser', () => { expect(descVal.dataFormat).equals(ExifDataFormat.ASCII_STRING); expect(descVal.stringValue).equals('2017 Willem van Schaik'); }); + + it('extracts hIST', async () => { + /** @type {PngPalette} */ + let palette; + /** @type {PngHistogram} */ + let hist; + await getPngParser('tests/image-testfiles/ch1n3p04.png') + .onHistogram(evt => { hist = evt.histogram }) + .onPalette(evt => { palette = evt.palette }) + .start(); + + expect(hist.frequencies.length).equals(palette.entries.length); + expect(hist.frequencies[0]).equals(64); + expect(hist.frequencies[1]).equals(112); + }); + + it('extracts sPLT', async () => { + /** @type {PngSuggestedPalette} */ + let sPalette; + await getPngParser('tests/image-testfiles/ps1n0g08.png') + .onSuggestedPalette(evt => { sPalette = evt.suggestedPalette }) + .start(); + + expect(sPalette.entries.length).equals(216); + expect(sPalette.paletteName).equals('six-cube'); + expect(sPalette.sampleDepth).equals(8); + + const entry0 = sPalette.entries[0]; + expect(entry0.red).equals(0); + expect(entry0.green).equals(0); + expect(entry0.blue).equals(0); + expect(entry0.alpha).equals(255); + expect(entry0.frequency).equals(0); + + const entry1 = sPalette.entries[1]; + expect(entry1.red).equals(0); + expect(entry1.green).equals(0); + expect(entry1.blue).equals(51); + expect(entry1.alpha).equals(255); + expect(entry1.frequency).equals(0); + }); }); diff --git a/tests/image-testfiles/ps1n0g08.png b/tests/image-testfiles/ps1n0g08.png new file mode 100644 index 0000000000000000000000000000000000000000..99625fa4ba1c6964446797075c47ee05dcae22f5 GIT binary patch literal 1456 zcmXxkK}(cj5Ww*#aAOfA1P>uWA_;XUl%aEMY}7)@A`%EeORi%bMFPEcC<37_1p^(# z58$OQAu`alqXdS>Zh3N=`9rY#yvw`)eV+Lp_Wd|qc|1KiH`!X79z5x<=5@Z_-CWx9 znZ?_Anb_(*>$NNI50=lrzHF^Cw=;aYw)1B9?#A0!o2|;IT3D6USe-R$6&j%t8le#y zp-~#8Q5vOD8l^EBqcIwzF&d+B8mDm@r*Rsm37Vh@nxF}qs77!JE@{yjc z!KJtqm*P@fc~mxr%WxSk!)3T~kZN!lF2iNG3|Ed>4KBlFxD1!!N<=ld442_DT!t$@ zR)foM87{+RxH7B;m*Fy8hRbl}HrY5X$K|*jm*dJqs=?*B9GByATscBDxEz<`a$Jrp z2doB{<8oY%%W>uS)!=emj>~a5u3W0Y<+vP|<8oY?Wb^-3|G~f&xB^#hQ1@{KuD}(z z0#|Nd4X(fyxB^$;%EPO{6}SRd;0jzhS~a)=SKtaFjV?4UmX1mAVr#hYeYu!5+e{Vh*?JeDyT{@Zhyfr@Ddogvjv;XPO$z}Yw k`0#ss`FehH`ek!G(S~Q2+xLI{T(5`g;L%F|=;7$yKLBk?PXGV_ literal 0 HcmV?d00001