diff --git a/src/utils/Image.ts b/src/utils/Image.ts index 63dcad9911..57ed3eefa6 100644 --- a/src/utils/Image.ts +++ b/src/utils/Image.ts @@ -14,62 +14,105 @@ * limitations under the License. */ +import { arrayHasDiff } from "./arrays"; + export function mayBeAnimated(mimeType: string): boolean { - return ["image/gif", "image/webp"].includes(mimeType); + return ["image/gif", "image/webp", "image/png", "image/apng"].includes(mimeType); } function arrayBufferRead(arr: ArrayBuffer, start: number, len: number): Uint8Array { return new Uint8Array(arr.slice(start, start + len)); } +function arrayBufferReadInt(arr: ArrayBuffer, start: number): number { + const dv = new DataView(arr, start, 4); + return dv.getUint32(0); +} + function arrayBufferReadStr(arr: ArrayBuffer, start: number, len: number): string { return String.fromCharCode.apply(null, arrayBufferRead(arr, start, len)); } export async function blobIsAnimated(mimeType: string, blob: Blob): Promise { - if (mimeType === "image/webp") { - // Only extended file format WEBP images support animation, so grab the expected data range and verify header. - // Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format - const arr = await blob.slice(0, 17).arrayBuffer(); - if ( - arrayBufferReadStr(arr, 0, 4) === "RIFF" && - arrayBufferReadStr(arr, 8, 4) === "WEBP" && - arrayBufferReadStr(arr, 12, 4) === "VP8X" - ) { - const [flags] = arrayBufferRead(arr, 16, 1); - // Flags: R R I L E X _A_ R (reversed) - const animationFlagMask = 1 << 1; - return (flags & animationFlagMask) != 0; - } - } else if (mimeType === "image/gif") { - // Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285 - // More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp - const dv = new DataView(await blob.arrayBuffer(), 10); - - const globalColorTable = dv.getUint8(0); - let globalColorTableSize = 0; - // check first bit, if 0, then we don't have a Global Color Table - if (globalColorTable & 0x80) { - // grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1) - // N is the value in the last 3 bits. - globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1); + switch (mimeType) { + case "image/webp": { + // Only extended file format WEBP images support animation, so grab the expected data range and verify header. + // Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format + const arr = await blob.slice(0, 17).arrayBuffer(); + if ( + arrayBufferReadStr(arr, 0, 4) === "RIFF" && + arrayBufferReadStr(arr, 8, 4) === "WEBP" && + arrayBufferReadStr(arr, 12, 4) === "VP8X" + ) { + const [flags] = arrayBufferRead(arr, 16, 1); + // Flags: R R I L E X _A_ R (reversed) + const animationFlagMask = 1 << 1; + return (flags & animationFlagMask) != 0; + } + break; } - // move on to the Graphics Control Extension - const offset = 3 + globalColorTableSize; + case "image/gif": { + // Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285 + // More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + const dv = new DataView(await blob.arrayBuffer(), 10); - const extensionIntroducer = dv.getUint8(offset); - const graphicsControlLabel = dv.getUint8(offset + 1); - let delayTime = 0; + const globalColorTable = dv.getUint8(0); + let globalColorTableSize = 0; + // check first bit, if 0, then we don't have a Global Color Table + if (globalColorTable & 0x80) { + // grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1) + // N is the value in the last 3 bits. + globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1); + } - // Graphics Control Extension section is where GIF animation data is stored - // First 2 bytes must be 0x21 and 0xF9 - if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) { - // skip to the 2 bytes with the delay time - delayTime = dv.getUint16(offset + 4); + // move on to the Graphics Control Extension + const offset = 3 + globalColorTableSize; + + const extensionIntroducer = dv.getUint8(offset); + const graphicsControlLabel = dv.getUint8(offset + 1); + let delayTime = 0; + + // Graphics Control Extension section is where GIF animation data is stored + // First 2 bytes must be 0x21 and 0xF9 + if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) { + // skip to the 2 bytes with the delay time + delayTime = dv.getUint16(offset + 4); + } + + return !!delayTime; } - return !!delayTime; + case "image/png": + case "image/apng": { + // Based on https://stackoverflow.com/a/68618296 + const arr = await blob.arrayBuffer(); + if (arrayHasDiff([ + 0x89, + 0x50, 0x4E, 0x47, + 0x0D, 0x0A, + 0x1A, + 0x0A, + ], Array.from(arrayBufferRead(arr, 0, 8)))) { + return false; + } + + for (let i = 8; i < blob.size;) { + const length = arrayBufferReadInt(arr, i); + i += 4; + const type = arrayBufferReadStr(arr, i, 4); + i += 4; + + switch (type) { + case "acTL": + return true; + case "IDAT": + return false; + } + i += length + 4; + } + break; + } } return false; diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts index bf7251b61f..892920d51f 100644 --- a/src/utils/blobs.ts +++ b/src/utils/blobs.ts @@ -52,6 +52,7 @@ const ALLOWED_BLOB_MIMETYPES = [ 'image/jpeg', 'image/gif', 'image/png', + 'image/apng', 'image/webp', 'video/mp4', diff --git a/test/Image-test.ts b/test/Image-test.ts index 9bd933a3a1..41a63eb0ce 100644 --- a/test/Image-test.ts +++ b/test/Image-test.ts @@ -29,7 +29,10 @@ describe("Image", () => { expect(mayBeAnimated("image/webp")).toBeTruthy(); }); it("image/png", async () => { - expect(mayBeAnimated("image/png")).toBeFalsy(); + expect(mayBeAnimated("image/png")).toBeTruthy(); + }); + it("image/apng", async () => { + expect(mayBeAnimated("image/apng")).toBeTruthy(); }); it("image/jpeg", async () => { expect(mayBeAnimated("image/jpeg")).toBeFalsy(); @@ -37,25 +40,36 @@ describe("Image", () => { }); describe("blobIsAnimated", () => { - const animatedGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]); - const animatedWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]); - const staticGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]); - const staticWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]); - it("Animated GIF", async () => { - expect(await blobIsAnimated("image/gif", animatedGif)).toBeTruthy(); + const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]); + expect(await blobIsAnimated("image/gif", img)).toBeTruthy(); }); it("Static GIF", async () => { - expect(await blobIsAnimated("image/gif", staticGif)).toBeFalsy(); + const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]); + expect(await blobIsAnimated("image/gif", img)).toBeFalsy(); }); it("Animated WEBP", async () => { - expect(await blobIsAnimated("image/webp", animatedWebp)).toBeTruthy(); + const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]); + expect(await blobIsAnimated("image/webp", img)).toBeTruthy(); }); it("Static WEBP", async () => { - expect(await blobIsAnimated("image/webp", staticWebp)).toBeFalsy(); + const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]); + expect(await blobIsAnimated("image/webp", img)).toBeFalsy(); + }); + + it("Animated PNG", async () => { + const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.apng"))]); + expect(await blobIsAnimated("image/png", img)).toBeTruthy(); + expect(await blobIsAnimated("image/apng", img)).toBeTruthy(); + }); + + it("Static PNG", async () => { + const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.png"))]); + expect(await blobIsAnimated("image/png", img)).toBeFalsy(); + expect(await blobIsAnimated("image/apng", img)).toBeFalsy(); }); }); }); diff --git a/test/images/animated-logo.apng b/test/images/animated-logo.apng new file mode 100644 index 0000000000..0252cf43bd Binary files /dev/null and b/test/images/animated-logo.apng differ diff --git a/test/images/static-logo.png b/test/images/static-logo.png new file mode 100644 index 0000000000..14b8f0ec31 Binary files /dev/null and b/test/images/static-logo.png differ