Support APNGs in Sticker Creator

This commit is contained in:
Evan Hahn 2020-09-28 13:40:26 -05:00 committed by Josh Perez
parent 6b3d5c19b3
commit bdd71e4898
20 changed files with 542 additions and 62 deletions

View file

@ -0,0 +1,78 @@
const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
const ACTL_CHUNK_BYTES = new TextEncoder().encode('acTL');
const IDAT_CHUNK_BYTES = new TextEncoder().encode('IDAT');
const MAX_BYTES_TO_READ = 1024 * 1024;
interface AnimatedPngData {
numPlays: number;
}
/**
* This is a naïve implementation. It only performs two checks:
*
* 1. Do the bytes start with the [PNG signature][0]?
* 2. If so, does it contain the [`acTL` chunk][1] before the [`IDAT` chunk][2], in the
* first megabyte?
*
* Though we _could_ only check for the precense of the `acTL` chunk anywhere, we make
* sure it's before the `IDAT` chunk and within the first megabyte. This adds a small
* amount of validity checking and helps us avoid problems with large PNGs.
*
* It doesn't make sure the PNG is valid. It doesn't verify [the CRC code][3] of each PNG
* chunk; it doesn't verify any of the chunk's data; it doesn't verify that the chunks are
* in the right order; etc.
*
* [0]: https://www.w3.org/TR/PNG/#5PNG-file-signature
* [1]: https://wiki.mozilla.org/APNG_Specification#.60acTL.60:_The_Animation_Control_Chunk
* [2]: https://www.w3.org/TR/PNG/#11IDAT
* [3]: https://www.w3.org/TR/PNG/#5Chunk-layout
*/
export function getAnimatedPngDataIfExists(
bytes: Uint8Array
): null | AnimatedPngData {
if (!hasPngSignature(bytes)) {
return null;
}
let numPlays: void | number;
const dataView = new DataView(bytes.buffer);
let i = PNG_SIGNATURE.length;
while (i < bytes.byteLength && i <= MAX_BYTES_TO_READ) {
const chunkTypeBytes = bytes.slice(i + 4, i + 8);
if (areBytesEqual(chunkTypeBytes, ACTL_CHUNK_BYTES)) {
// 4 bytes for the length; 4 bytes for the type; 4 bytes for the number of frames.
numPlays = dataView.getUint32(i + 12);
if (numPlays === 0) {
numPlays = Infinity;
}
return { numPlays };
}
if (areBytesEqual(chunkTypeBytes, IDAT_CHUNK_BYTES)) {
return null;
}
// Jump over the length (4 bytes), the type (4 bytes), the data, and the CRC checksum
// (4 bytes).
i += 12 + dataView.getUint32(i);
}
return null;
}
function hasPngSignature(bytes: Uint8Array): boolean {
return areBytesEqual(bytes.slice(0, 8), PNG_SIGNATURE);
}
function areBytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) {
return false;
}
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,127 @@
import {
IMAGE_BMP,
IMAGE_GIF,
IMAGE_ICO,
IMAGE_JPEG,
IMAGE_PNG,
IMAGE_WEBP,
MIMEType,
} from '../types/MIME';
/**
* This follows the [MIME Sniffing Standard for images][0].
*
* [0]: https://mimesniff.spec.whatwg.org/#matching-an-image-type-pattern
*/
export function sniffImageMimeType(
bytes: ArrayBuffer | Uint8Array
): undefined | MIMEType {
const asTypedArray = new Uint8Array(bytes);
for (let i = 0; i < TYPES.length; i += 1) {
const type = TYPES[i];
if (matchesType(asTypedArray, type)) {
return type.mimeType;
}
}
return undefined;
}
interface Type {
mimeType: MIMEType;
bytePattern: Uint8Array;
patternMask?: Uint8Array;
}
const TYPES: Array<Type> = [
{
mimeType: IMAGE_ICO,
bytePattern: new Uint8Array([0x00, 0x00, 0x01, 0x00]),
},
{
mimeType: IMAGE_ICO,
bytePattern: new Uint8Array([0x00, 0x00, 0x02, 0x00]),
},
{
mimeType: IMAGE_BMP,
bytePattern: new Uint8Array([0x42, 0x4d]),
},
{
mimeType: IMAGE_GIF,
bytePattern: new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]),
},
{
mimeType: IMAGE_GIF,
bytePattern: new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]),
},
{
mimeType: IMAGE_WEBP,
bytePattern: new Uint8Array([
0x52,
0x49,
0x46,
0x46,
0x00,
0x00,
0x00,
0x00,
0x57,
0x45,
0x42,
0x50,
0x56,
0x50,
]),
patternMask: new Uint8Array([
0xff,
0xff,
0xff,
0xff,
0x00,
0x00,
0x00,
0x00,
0xff,
0xff,
0xff,
0xff,
0xff,
0xff,
]),
},
{
mimeType: IMAGE_PNG,
bytePattern: new Uint8Array([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
]),
},
{
mimeType: IMAGE_JPEG,
bytePattern: new Uint8Array([0xff, 0xd8, 0xff]),
},
];
// This follows the [pattern matching algorithm in the spec][1].
// [1]: https://mimesniff.spec.whatwg.org/#pattern-matching-algorithm
function matchesType(input: Uint8Array, type: Type): boolean {
if (input.byteLength < type.bytePattern.byteLength) {
return false;
}
for (let p = 0; p < type.bytePattern.length; p += 1) {
const mask = type.patternMask ? type.patternMask[p] : 0xff;
// We need to use a bitwise operator here, per the spec.
// eslint-disable-next-line no-bitwise
const maskedData = input[p] & mask;
if (maskedData !== type.bytePattern[p]) {
return false;
}
}
return true;
}