diff --git a/imports/_test/image.ts b/imports/_test/image.ts new file mode 100644 index 000000000..3a8ce1518 --- /dev/null +++ b/imports/_test/image.ts @@ -0,0 +1,164 @@ +import {assert} from 'chai'; + +import {type Sharp} from 'sharp'; + +import {createContextIso, destroyContextIso} from '../lib/canvas'; +import blobToImage from '../lib/blob/blobToImage'; + +type TypedArray = + | Uint8Array + | Uint8ClampedArray + | Int8Array + | Uint16Array + | Int16Array + | Uint32Array + | Int32Array + | Float32Array + | Float64Array; +type SharpInput = ArrayBuffer | TypedArray; +type S = Sharp | SharpInput; + +type I = HTMLImageElement | ArrayBuffer; + +type Image = S | I; + +const _s = async (x: S): Promise => { + const {default: sharp} = await import('sharp'); + return x instanceof sharp ? (x as Sharp) : sharp(x as SharpInput); +}; + +const _i = async (x: I): Promise => { + if (x instanceof HTMLImageElement) return x; + const blob = new Blob([x], {type: 'image/png'}); + const image = await blobToImage(blob); + return image; +}; + +const _pixels = async (image: HTMLImageElement): Promise => { + const {width, height} = image; + const context = await createContextIso({width, height}); + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, width, height).data; + destroyContextIso(context); + return data; +}; + +const _xorClient = ( + a: Uint8ClampedArray, + b: Uint8ClampedArray, +): Uint8ClampedArray => { + const n = a.length; + assert.equal(b.length, n); + const delta = new Uint8ClampedArray(n); + for (let i = 0; i < n; ++i) { + // eslint-disable-next-line no-bitwise + delta[i] = a[i]! ^ b[i]!; + } + + return delta; +}; + +const _diffClient = async (a: Image, b: Image) => { + const _a = await _i(a as I); + const _b = await _i(b as I); + const aPixels = await _pixels(_a); + const bPixels = await _pixels(_b); + return _xorClient(aPixels, bPixels); +}; + +const _diffServer = async (a: Image, b: Image) => { + const _a = await _s(a as S); + const _b = await _s(b as S); + const delta = await _xorServer(_a, _b); + return delta.raw().toBuffer(); +}; + +export const diff = Meteor.isServer ? _diffServer : _diffClient; + +export const assertEqual = async (a: Image, b: Image) => { + const delta = await diff(a, b); + assert( + !delta.some((value, index) => index % 4 !== 3 && value !== 0), + `Input images are not equal.`, + ); +}; + +const _assertSameDimensions = async (a: Sharp, b: Sharp): Promise => { + const { + width: aWidth, + height: aHeight, + channels: aChannels, + hasAlpha: aHasAlpha, + } = await a.metadata(); + const { + width: bWidth, + height: bHeight, + channels: bChannels, + hasAlpha: bHasAlpha, + } = await b.metadata(); + + assert.equal( + aWidth, + bWidth, + `Images have different widths: ${aWidth} !== ${bWidth}`, + ); + + assert.equal( + aHeight, + bHeight, + `Images have different heights: ${aHeight} !== ${bHeight}`, + ); + + assert.equal( + aChannels, + bChannels, + `Images have different number of channels: ${aChannels} !== ${bChannels}`, + ); + + assert.equal( + aHasAlpha, + bHasAlpha, + `Images have different alpha channel settings: ${aHasAlpha} !== ${bHasAlpha}`, + ); +}; + +const _xorServer = async (a: Sharp, b: Sharp): Promise => { + await _assertSameDimensions(a, b); + const _b = await b.toBuffer(); + return a.boolean(_b, 'eor'); +}; + +const _whiteRectanglePNGServer = async (options: { + width: number; + height: number; +}) => { + const {default: sharp} = await import('sharp'); + return sharp({ + create: { + ...options, + channels: 4, + background: {r: 255, g: 255, b: 255, alpha: 0}, + }, + }).png(); +}; + +const _whiteRectanglePNGClient = async ({ + width, + height, +}: { + width: number; + height: number; +}): Promise => { + const context = await createContextIso({width, height}); + context.fillStyle = '#FFFFFF'; + context.fillRect(0, 0, width, height); + const url = context.canvas.toDataURL('image/png'); + destroyContextIso(context); + const img = new Image(); + img.src = url; + return img; +}; + +export const whiteRectanglePNG = Meteor.isServer + ? _whiteRectanglePNGServer + : _whiteRectanglePNGClient; diff --git a/imports/lib/blob/blobToImage.ts b/imports/lib/blob/blobToImage.ts new file mode 100644 index 000000000..fec3c0e65 --- /dev/null +++ b/imports/lib/blob/blobToImage.ts @@ -0,0 +1,22 @@ +const blobToImage = async (blob: Blob): Promise => + new Promise((resolve, reject) => { + const url = URL.createObjectURL(blob); + const img = new Image(); + img.addEventListener('load', () => { + URL.revokeObjectURL(url); + resolve(img); + }); + + img.addEventListener('error', (error) => { + URL.revokeObjectURL(url); + reject(error); + }); + + img.addEventListener('abort', (_e) => { + reject(new Error(`Image load aborted for ${url}`)); + }); + + img.src = url; + }); + +export default blobToImage; diff --git a/imports/lib/pdf/pdfthumbmails.tests.ts b/imports/lib/pdf/pdfthumbmails.tests.ts index 9af9cd5eb..1532d5ebc 100644 --- a/imports/lib/pdf/pdfthumbmails.tests.ts +++ b/imports/lib/pdf/pdfthumbmails.tests.ts @@ -5,8 +5,14 @@ import {assert} from 'chai'; import {client, server, throws} from '../../_test/fixtures'; import {randomPDFUint8Array, randomPDFDataURI} from '../../_test/pdf'; +import { + assertEqual as assertEqualImages, + whiteRectanglePNG, +} from '../../_test/image'; -import dataURL from '../dataURL'; +import blobFromDataURL from '../blob/blobFromDataURL'; +import blobToBuffer from '../blob/blobToBuffer'; +import streamToBuffer from '../stream/streamToBuffer'; import { thumbnailDataURL, @@ -15,29 +21,39 @@ import { thumbnailBuffer, } from './pdfthumbnails'; +const width = 200; +const height = 200; + client(__filename, () => { it('thumbnailDataURL should work', async () => { - const url = await randomPDFDataURI(); - const result = await thumbnailDataURL( - url, - {minWidth: 10, minHeight: 10}, + const pdfDataURL = await randomPDFDataURI(); + const pngDataURL = await thumbnailDataURL( + pdfDataURL, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ); - const expected = dataURL( - 'image/png', - 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARzQklUCAgICHwIZIgAAAAXSURBVBhXY/wPBAxEAMZRhfhCifrBAwDBjyfjq7VgpgAAAABJRU5ErkJggg==', - ); - assert.equal(result, expected); + + assert.typeOf(pngDataURL, 'string'); + + const buffer = await blobFromDataURL(pngDataURL).then(blobToBuffer); + const expected = await whiteRectanglePNG({width, height}); + + await assertEqualImages(buffer, expected); }); it('thumbnailBlob should work', async () => { const data = randomPDFUint8Array(); - const result = await thumbnailBlob( + const blob = await thumbnailBlob( {data}, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ); - assert.instanceOf(result, Blob); + assert.instanceOf(blob, Blob); + + const buffer = await blobToBuffer(blob); + const expected = await whiteRectanglePNG({width, height}); + + await assertEqualImages(buffer, expected); }); it('thumbnailBuffer should NOT be implemented', async () => { @@ -46,7 +62,7 @@ client(__filename, () => { async () => thumbnailBuffer( {data}, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ), /not implemented/i, @@ -59,7 +75,7 @@ client(__filename, () => { async () => thumbnailStream( {data}, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ), /not implemented/i, @@ -75,7 +91,7 @@ server(__filename, () => { async () => thumbnailDataURL( url, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ), /not implemented/i, @@ -89,7 +105,7 @@ server(__filename, () => { async () => thumbnailBlob( {data}, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ), /not implemented/i, @@ -98,21 +114,31 @@ server(__filename, () => { it('thumbnailBuffer should work', async () => { const data = randomPDFUint8Array(); - const result = await thumbnailBuffer( + const buffer = await thumbnailBuffer( {data}, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ); - assert.instanceOf(result, Buffer); + assert.instanceOf(buffer, Buffer); + + const expected = await whiteRectanglePNG({width, height}); + + await assertEqualImages(buffer, expected); }); it('thumbnailStream should work', async () => { const data = randomPDFUint8Array(); - const result = await thumbnailStream( + const stream = await thumbnailStream( {data}, - {minWidth: 200, minHeight: 200}, + {minWidth: width, minHeight: height}, {type: 'image/png'}, ); - assert.instanceOf(result, Readable); + assert.instanceOf(stream, Readable); + + const buffer = await streamToBuffer(stream); + + const expected = await whiteRectanglePNG({width, height}); + + await assertEqualImages(buffer, expected); }); });