diff --git a/src/images/staticWithMetadata.png b/src/images/staticWithMetadata.png new file mode 100644 index 0000000..9b51945 Binary files /dev/null and b/src/images/staticWithMetadata.png differ diff --git a/src/index.test.js b/src/index.test.js index 21522db..6c1bdea 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -23,43 +23,63 @@ test('returns false on JPG', (t) => { t.false(check(dir + '/images/static.jpg')) }) +test('returns false on static PNG with `acTL` text in metadata', (t) => { + t.false(check(dir + '/images/staticWithMetadata.png')) +}) + test('returns true when IDAT follows acTL', (t) => { t.true( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // acTL + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL 0x61, 0x63, 0x54, 0x4c, - // IDAT + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT 0x49, 0x44, 0x41, 0x54, ]), ), ) }) -test('returns false when IDAT precedes acTL', (t) => { +test('returns false when acTL is not a chunk type', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // IDAT - 0x49, 0x44, 0x41, 0x54, - // acTL + // Chunk length: 4 + 0x00, 0x00, 0x00, 0x04, + // Chunk type: any + 0x00, 0x00, 0x00, 0x01, + // Chunk data: acTL 0x61, 0x63, 0x54, 0x4c, ]), ), ) }) -test('returns false when missing IDAT', (t) => { +test('returns false when IDAT precedes acTL', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // acTL + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT + 0x49, 0x44, 0x41, 0x54, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL 0x61, 0x63, 0x54, 0x4c, ]), ), @@ -72,10 +92,10 @@ test('chunks should be found when preceded by a partial of themselves', (t) => { new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // a acTL - 0x61, 0x61, 0x63, 0x54, 0x4c, - // I IDAT - 0x49, 0x49, 0x44, 0x41, 0x54, + // Chunk length: a + 0x00, 0x00, 0x00, 0x61, + // Chunk type: acTL + 0x61, 0x63, 0x54, 0x4c, ]), ), ) diff --git a/src/index.ts b/src/index.ts index 1cf86b3..6c58e4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,34 @@ +function convertToInt(bytes: Uint8Array) { + return bytes.reduce((value, byte) => (value << 8) + byte) +} + +function isEqual(first, second) { + return ( + first[0] === second[0] && + first[1] === second[1] && + first[2] === second[2] && + first[3] === second[3] + ) +} + const encoder = new TextEncoder() -const sequences = { - animationControlChunk: encoder.encode('acTL'), - imageDataChunk: encoder.encode('IDAT'), +const chunkTypes = { + animationControl: encoder.encode('acTL'), + imageData: encoder.encode('IDAT'), +} + +/** + * @see http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html + */ +const headerSizes = { + /** Number of bytes reserved for PNG signature */ + SIGNATURE: 8, + /** Number of bytes reserved for chunk type */ + LENGTH: 4, + /** Number of bytes reserved for chunk type */ + TYPE: 4, + /** Number of bytes reserved for chunk CRC */ + CRC: 4, } export default function isApng(buffer: Buffer | Uint8Array): boolean { @@ -33,32 +60,28 @@ export default function isApng(buffer: Buffer | Uint8Array): boolean { // APNGs have an animation control chunk (acTL) preceding any IDAT(s). // See: https://en.wikipedia.org/wiki/APNG#File_format - buffer = buffer.subarray(8) + buffer = buffer.subarray(headerSizes.SIGNATURE) - let firstIndex = 0 - let secondIndex = 0 - for (let i = 0; i < buffer.length; i++) { - if (buffer[i] !== sequences.animationControlChunk[firstIndex]) { - firstIndex = 0 - } + const minBufferSize = headerSizes.LENGTH + headerSizes.TYPE + while (buffer.length >= minBufferSize) { + const chunkLength = convertToInt(buffer.subarray(0, headerSizes.LENGTH)) + const chunkType = buffer.subarray( + headerSizes.LENGTH, + headerSizes.LENGTH + headerSizes.TYPE, + ) - if (buffer[i] === sequences.animationControlChunk[firstIndex]) { - firstIndex++ - if (firstIndex === sequences.animationControlChunk.length) { - return true - } + if (isEqual(chunkType, chunkTypes.animationControl)) { + return true } - if (buffer[i] !== sequences.imageDataChunk[secondIndex]) { - secondIndex = 0 + if (isEqual(chunkType, chunkTypes.imageData)) { + return false } - if (buffer[i] === sequences.imageDataChunk[secondIndex]) { - secondIndex++ - if (secondIndex === sequences.imageDataChunk.length) { - return false - } - } + const nextChunkPosition = + headerSizes.LENGTH + headerSizes.TYPE + chunkLength + headerSizes.CRC + + buffer = buffer.subarray(nextChunkPosition) } return false