Skip to content

Commit

Permalink
fix: use chunk-based search
Browse files Browse the repository at this point in the history
  • Loading branch information
vHeemstra authored Aug 12, 2024
2 parents befa2f2 + 1f59b54 commit 32654e8
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 60 deletions.
10 changes: 7 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# is-apng   [![Latest GitHub release][release-image]][release-url] [![Latest NPM version][npm-image]][npm-url] [![Build Status][ci-image]][ci-url]

> Check if a Buffer/Uint8Array is a [Animated PNG / APNG](https://en.wikipedia.org/wiki/APNG) image
> Checks if a Buffer/Uint8Array contains a [Animated PNG / APNG](https://en.wikipedia.org/wiki/APNG) image.
> [!IMPORTANT]
> This is _only_ a quick detect method and does not do full (A)PNG file validation.<br>
> For security and file integrity sensitive operations, use a true (A)PNG validator (see also [specs](https://www.w3.org/TR/png/)).
## Install

Expand Down Expand Up @@ -40,7 +44,7 @@ isApng(new Uint8Array(buffer))
#### As old-school global script tag

Url for latest version: `https://unpkg.com/is-apng`<br>
Url for specific version: `https://unpkg.com/[email protected].3/dist/index.js`
Url for specific version: `https://unpkg.com/[email protected].4/dist/index.js`

```html
<script src="https://unpkg.com/is-apng" type="text/javascript"></script>
Expand All @@ -54,7 +58,7 @@ Url for specific version: `https://unpkg.com/[email protected]/dist/index.js`
#### As module

Url for latest version: `https://unpkg.com/is-apng/dist/index.mjs`<br>
Url for specific version: `https://unpkg.com/[email protected].3/dist/index.mjs`
Url for specific version: `https://unpkg.com/[email protected].4/dist/index.mjs`

```html
<script type="module">
Expand Down
Binary file added src/images/staticWithMetadata.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
111 changes: 97 additions & 14 deletions src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@ import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import isApng from '../dist/index.mjs'

/** Input validation tests */

test('returns false on number 0 input', (t) => {
t.false(isApng(0))
})

test('returns false on number 1 input', (t) => {
t.false(isApng(1))
})

test('returns false on empty string input', (t) => {
t.false(isApng(''))
})

test('returns false on non-empty string input', (t) => {
t.false(isApng('string'))
})

test('returns false on boolean true input', (t) => {
t.false(isApng(true))
})

test('returns false on boolean false input', (t) => {
t.false(isApng(false))
})

test('returns false on object input', (t) => {
t.false(isApng({}))
})

test('returns false on array input', (t) => {
t.false(isApng([]))
})

/** Real image tests */

const dir =
typeof __dirname !== 'undefined'
? __dirname
Expand All @@ -19,63 +55,110 @@ test('returns false on static PNG', (t) => {
t.false(check(dir + '/images/static.png'))
})

test('returns false on static PNG with `acTL` text in metadata', (t) => {
t.false(check(dir + '/images/staticWithMetadata.png'))
})

test('returns false on JPG', (t) => {
t.false(check(dir + '/images/static.jpg'))
})

test('returns true when IDAT follows acTL', (t) => {
/** Mock data tests */

test('returns true when acTL precedes any IDAT', (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,
// Chunk CRC
0x00, 0x00, 0x00, 0x00,
]),
),
)
})

test('returns false when IDAT precedes acTL', (t) => {
test('returns false when any IDAT precedes acTL', (t) => {
t.false(
isApng(
new Uint8Array([
// PNG header
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
// IDAT
// Chunk length: 0
0x00, 0x00, 0x00, 0x00,
// Chunk type: IDAT
0x49, 0x44, 0x41, 0x54,
// acTL
// Chunk CRC
0x00, 0x00, 0x00, 0x00,
// Chunk length: 0
0x00, 0x00, 0x00, 0x00,
// Chunk type: acTL
0x61, 0x63, 0x54, 0x4c,
// Chunk CRC
0x00, 0x00, 0x00, 0x00,
]),
),
)
})

test('returns false when missing IDAT', (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,
// acTL
// Chunk length: 4
0x00, 0x00, 0x00, 0x04,
// Chunk type: any
0x00, 0x00, 0x00, 0x01,
// Chunk data: acTL
0x61, 0x63, 0x54, 0x4c,
// Chunk CRC
0x00, 0x00, 0x00, 0x00,
]),
),
)
})

test('chunks should be found when preceded by a partial of themselves', (t) => {
t.true(
test('returns false on too small PNG', (t) => {
t.false(
isApng(
new Uint8Array([
// PNG header
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
// Content omitted
]),
),
)
})

test('returns false when next chunk size is too small', (t) => {
t.false(
isApng(
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: 4
0x00, 0x00, 0x00, 0x04,
// Chunk type: any
0x00, 0x00, 0x00, 0x01,
// Chunk CRC
0x00, 0x00, 0x00, 0x00,
// Chunk length: 4
0x00, 0x00, 0x00, 0x04,
// Chunk type: any
0x00, 0x00, 0x00, 0x02,
// Chunk CRC omitted
]),
),
)
Expand Down
104 changes: 61 additions & 43 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,82 @@
const encoder = new TextEncoder()
const sequences = {
animationControlChunk: encoder.encode('acTL'),
imageDataChunk: encoder.encode('IDAT'),
function convertToInt(bytes: Uint8Array) {
return bytes.reduce((value, byte) => (value << 8) + byte)
}

export default function isApng(buffer: Buffer | Uint8Array): boolean {
function isEqual(
first: Uint8Array | number[],
second: Uint8Array | number[],
length = 4,
) {
while (length > 0) {
length--
if (first[length] !== second[length]) {
return false
}
}
return true
}

/**
* @see https://www.w3.org/TR/png/#5DataRep
*/
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,
}

const chunkTypes = {
signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
animationControl: [0x61, 0x63, 0x54, 0x4c], // 'acTL'
imageData: [0x49, 0x44, 0x41, 0x54], // 'IDAT'
}

export default function isApng(buffer: Uint8Array): boolean {
const minChunkSize = headerSizes.LENGTH + headerSizes.TYPE + headerSizes.CRC

if (
!buffer ||
!(
(typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) ||
buffer instanceof Uint8Array
) ||
buffer.length < 16
!(buffer instanceof Uint8Array) ||
buffer.length < headerSizes.SIGNATURE + minChunkSize
) {
return false
}

const isPNG =
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a

if (!isPNG) {
/** Check for PNG signature */
if (!isEqual(buffer, chunkTypes.signature, headerSizes.SIGNATURE)) {
return false
}

// APNGs have an animation control chunk (acTL) preceding any IDAT(s).
// See: https://en.wikipedia.org/wiki/APNG#File_format
buffer = buffer.subarray(headerSizes.SIGNATURE)

buffer = buffer.subarray(8)
/**
* APNGs have an animation control (acTL) chunk preceding any image data (IDAT) chunks.
* @see: https://www.w3.org/TR/png/#5ChunkOrdering
*/

let firstIndex = 0
let secondIndex = 0
for (let i = 0; i < buffer.length; i++) {
if (buffer[i] !== sequences.animationControlChunk[firstIndex]) {
firstIndex = 0
}
while (buffer.length >= minChunkSize) {
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, headerSizes.TYPE)) {
return true
}

if (buffer[i] !== sequences.imageDataChunk[secondIndex]) {
secondIndex = 0
if (isEqual(chunkType, chunkTypes.imageData, headerSizes.TYPE)) {
return false
}

if (buffer[i] === sequences.imageDataChunk[secondIndex]) {
secondIndex++
if (secondIndex === sequences.imageDataChunk.length) {
return false
}
}
const nextChunkPosition =
minChunkSize + convertToInt(buffer.subarray(0, headerSizes.LENGTH))

buffer = buffer.subarray(nextChunkPosition)
}

return false
Expand Down

0 comments on commit 32654e8

Please sign in to comment.