'use strict'; // TODO: unwrap promises const readJpegChunks = () => { const stack = []; const promiseReadJpegChunks = (blob) => new Promise((resolve, reject) => { let pos = 2; const readToView = (blob, cb) => { const reader = new FileReader(); reader.addEventListener('load', () => { cb(new DataView(reader.result)); }); reader.addEventListener('error', (e) => { reject(`Reader error: ${e}`); }); reader.readAsArrayBuffer(blob); }; const readNext = () => readToView(blob.slice(pos, pos + 128), (view) => { let i, j, ref; for (i = j = 0, ref = view.byteLength; ref >= 0 ? j < ref : j > ref; i = ref >= 0 ? ++j : --j) { if (view.getUint8(i) === 0xff) { pos += i; break; } } readNextChunk(); }); const readNextChunk = () => { const startPos = pos; return readToView(blob.slice(pos, (pos += 4)), (view) => { if (view.byteLength !== 4 || view.getUint8(0) !== 0xff) { reject('Corrupted'); return; } const marker = view.getUint8(1); if (marker === 0xda) { resolve(true); return; } const length = view.getUint16(2) - 2; return readToView(blob.slice(pos, (pos += length)), (view) => { if (view.byteLength !== length) { reject('Corrupted'); return; } stack.push({ startPos, length, marker, view }); readNext(); }); }); }; if (!(FileReader && DataView)) { reject('Not Support'); } readToView(blob.slice(0, 2), (view) => { if (view.getUint16(0) !== 0xffd8) { reject('Not jpeg'); } readNext(); }); }); return { stack, promiseReadJpegChunks }; }; const getIccProfile = async (blob) => { const iccProfile = []; const { promiseReadJpegChunks, stack } = readJpegChunks(); await promiseReadJpegChunks(blob); stack.forEach(({ marker, view }) => { if (marker === 0xe2) { if ( // check for "ICC_PROFILE\0" view.getUint32(0) === 0x4943435f && view.getUint32(4) === 0x50524f46 && view.getUint32(8) === 0x494c4500) { iccProfile.push(view); } } }); return iccProfile; }; const replaceJpegChunk = async (blob, marker, chunks) => { { const oldChunkPos = []; const oldChunkLength = []; const { promiseReadJpegChunks, stack } = readJpegChunks(); await promiseReadJpegChunks(blob); stack.forEach((chunk) => { if (chunk.marker === marker) { oldChunkPos.push(chunk.startPos); return oldChunkLength.push(chunk.length); } }); const newChunks = [blob.slice(0, 2)]; for (const chunk of chunks) { const intro = new DataView(new ArrayBuffer(4)); intro.setUint16(0, 0xff00 + marker); intro.setUint16(2, chunk.byteLength + 2); newChunks.push(intro.buffer); newChunks.push(chunk); } let pos = 2; for (let i = 0; i < oldChunkPos.length; i++) { if (oldChunkPos[i] > pos) { newChunks.push(blob.slice(pos, oldChunkPos[i])); } pos = oldChunkPos[i] + oldChunkLength[i] + 4; } newChunks.push(blob.slice(pos, blob.size)); return new Blob(newChunks, { type: blob.type }); } }; const MARKER = 0xe2; const replaceIccProfile = (blob, iccProfiles) => { return replaceJpegChunk(blob, MARKER, iccProfiles.map((chunk) => chunk.buffer)); }; const stripIccProfile = async (blob) => { try { return await replaceIccProfile(blob, []); } catch (e) { throw new Error(`Failed to strip ICC profile: ${e}`); } }; const canvasToBlob = (canvas, type, quality) => { return new Promise((resolve, reject) => { const callback = (blob) => { if (!blob) { reject('Failed to convert canvas to blob'); return; } resolve(blob); }; canvas.toBlob(callback, type, quality); canvas.width = canvas.height = 1; }); }; const createCanvas = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); return { canvas, ctx }; }; const hasTransparency = (img) => { const canvasSize = 50; // Create a canvas element and get 2D rendering context const { ctx, canvas } = createCanvas(); canvas.width = canvas.height = canvasSize; // Draw the image onto the canvas ctx.drawImage(img, 0, 0, canvasSize, canvasSize); // Get the image data const imageData = ctx.getImageData(0, 0, canvasSize, canvasSize).data; // Reset the canvas dimensions canvas.width = canvas.height = 1; // Check for transparency in the alpha channel for (let i = 3; i < imageData.length; i += 4) { if (imageData[i] < 254) { return true; } } // No transparency found return false; }; const getExif = async (blob) => { let exif = null; const { promiseReadJpegChunks, stack } = readJpegChunks(); await promiseReadJpegChunks(blob); stack.forEach(({ marker, view }) => { if (!exif && marker === 0xe1) { if (view.byteLength >= 14) { if ( // check for "Exif\0" view.getUint32(0) === 0x45786966 && view.getUint16(4) === 0) { exif = view; return; } } } }); return exif; }; // 2x1 pixel image 90CW rotated with orientation header const base64ImageSrc = 'data:image/jpg;base64,' + '/9j/4AAQSkZJRgABAQEASABIAAD/4QA6RXhpZgAATU0AKgAAAAgAAwESAAMAAAABAAYAAAEo' + 'AAMAAAABAAIAAAITAAMAAAABAAEAAAAAAAD/2wBDAP//////////////////////////////' + '////////////////////////////////////////////////////////wAALCAABAAIBASIA' + '/8QAJgABAAAAAAAAAAAAAAAAAAAAAxABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAAPwBH/9k='; let isApplied = undefined; const isBrowserApplyExifOrientation = () => { return new Promise((resolve) => { if (isApplied !== undefined) { resolve(isApplied); } else { const image = new Image(); image.addEventListener('load', () => { isApplied = image.naturalWidth < image.naturalHeight; image.src = '//:0'; resolve(isApplied); }); image.src = base64ImageSrc; } }); }; const findExifOrientation = (exif, exifCallback) => { let j, little, offset, ref; if (!exif || exif.byteLength < 14 || exif.getUint32(0) !== 0x45786966 || exif.getUint16(4) !== 0) { return; } if (exif.getUint16(6) === 0x4949) { little = true; } else if (exif.getUint16(6) === 0x4d4d) { little = false; } else { return; } if (exif.getUint16(8, little) !== 0x002a) { return; } offset = 8 + exif.getUint32(10, little); const count = exif.getUint16(offset - 2, little); for (j = 0, ref = count; ref >= 0 ? j < ref : j > ref; ref >= 0 ? ++j : --j) { if (exif.byteLength < offset + 10) { return; } if (exif.getUint16(offset, little) === 0x0112) { exifCallback(offset + 8, little); } offset += 12; } }; const setExifOrientation = (exif, orientation) => { findExifOrientation(exif, (offset, littleEndian) => exif.setUint16(offset, orientation, littleEndian)); }; const replaceExif = async (blob, exif, isExifApplied) => { if (isExifApplied) { setExifOrientation(exif, 1); } return replaceJpegChunk(blob, 0xe1, [exif.buffer]); }; const processImage = (image, src) => { return new Promise((resolve, reject) => { if (src) { image.src = src; } if (image.complete) { resolve(image); } else { image.addEventListener('load', () => { resolve(image); }); image.addEventListener('error', () => { reject(new Error('Failed to load image. Probably not an image.')); }); } }); }; const imageLoader = (image) => { return processImage(new Image(), image); }; const allowLayers = [ 1, // L (black-white) 3 // RGB ]; const markers = [ 0xc0, // ("SOF0", "Baseline DCT", SOF) 0xc1, // ("SOF1", "Extended Sequential DCT", SOF) 0xc2, // ("SOF2", "Progressive DCT", SOF) 0xc3, // ("SOF3", "Spatial lossless", SOF) 0xc5, // ("SOF5", "Differential sequential DCT", SOF) 0xc6, // ("SOF6", "Differential progressive DCT", SOF) 0xc7, // ("SOF7", "Differential spatial", SOF) 0xc9, // ("SOF9", "Extended sequential DCT (AC)", SOF) 0xca, // ("SOF10", "Progressive DCT (AC)", SOF) 0xcb, // ("SOF11", "Spatial lossless DCT (AC)", SOF) 0xcd, // ("SOF13", "Differential sequential DCT (AC)", SOF) 0xce, // ("SOF14", "Differential progressive DCT (AC)", SOF) 0xcf // ("SOF15", "Differential spatial (AC)", SOF) ]; const sizes = { squareSide: [ // Safari (iOS < 9, ram >= 256) // We are supported mobile safari < 9 since widget v2, by 5 Mpx limit // so it's better to continue support despite the absence of this browser in the support table Math.floor(Math.sqrt(5 * 1000 * 1000)), // IE Mobile (Windows Phone 8.x) // Safari (iOS >= 9) 4096, // IE 9 (Win) 8192, // Firefox 63 (Mac, Win) 11180, // Chrome 68 (Android 6) 10836, // Chrome 68 (Android 5) 11402, // Chrome 68 (Android 7.1-9) 14188, // Chrome 70 (Mac, Win) // Chrome 68 (Android 4.4) // Edge 17 (Win) // Safari 7-12 (Mac) 16384 ], dimension: [ // IE Mobile (Windows Phone 8.x) 4096, // IE 9 (Win) 8192, // Edge 17 (Win) // IE11 (Win) 16384, // Chrome 70 (Mac, Win) // Chrome 68 (Android 4.4-9) // Firefox 63 (Mac, Win) 32767, // Chrome 83 (Mac, Win) // Safari 7-12 (Mac) // Safari (iOS 9-12) // Actually Safari has a much bigger limits - 4194303 of width and 8388607 of height, // but we will not use them 65535 ] }; const shouldSkipShrink = async (blob) => { let skip = false; const { promiseReadJpegChunks, stack } = readJpegChunks(); return await promiseReadJpegChunks(blob) .then(() => { stack.forEach(({ marker, view }) => { if (!skip && markers.indexOf(marker) >= 0) { const layer = view.getUint8(5); if (allowLayers.indexOf(layer) < 0) { skip = true; } } }); return skip; }) .catch(() => skip); }; const memoize = (fn, serializer) => { const cache = {}; return (...args) => { const key = serializer(args, cache); return key in cache ? cache[key] : (cache[key] = fn(...args)); }; }; /** * Memoization key serealizer, that prevents unnecessary canvas tests. No need * to make test if we know that: * * - Browser supports higher canvas size * - Browser doesn't support lower canvas size */ const memoKeySerializer = (args, cache) => { const [w] = args; const cachedWidths = Object.keys(cache) .map((val) => parseInt(val, 10)) .sort((a, b) => a - b); for (let i = 0; i < cachedWidths.length; i++) { const cachedWidth = cachedWidths[i]; const isSupported = !!cache[cachedWidth]; // higher supported canvas size, return it if (cachedWidth > w && isSupported) { return cachedWidth; } // lower unsupported canvas size, return it if (cachedWidth < w && !isSupported) { return cachedWidth; } } // use canvas width as the key, // because we're doing dimension test by width - [dimension, 1] return w; }; // add constants const TestPixel = { R: 55, G: 110, B: 165, A: 255 }; const FILL_STYLE = `rgba(${TestPixel.R}, ${TestPixel.G}, ${TestPixel.B}, ${TestPixel.A / 255})`; const canvasTest = (width, height) => { try { const fill = [width - 1, height - 1, 1, 1]; // x, y, width, height const { canvas: cropCvs, ctx: cropCtx } = createCanvas(); cropCvs.width = 1; cropCvs.height = 1; const { canvas: testCvs, ctx: testCtx } = createCanvas(); testCvs.width = width; testCvs.height = height; if (testCtx) { testCtx.fillStyle = FILL_STYLE; testCtx.fillRect(...fill); // Render the test pixel in the bottom-right corner of the // test canvas in the top-left of the 1x1 crop canvas. This // dramatically reducing the time for getImageData to complete. cropCtx.drawImage(testCvs, width - 1, height - 1, 1, 1, 0, 0, 1, 1); } const imageData = cropCtx && cropCtx.getImageData(0, 0, 1, 1).data; let isTestPass = false; if (imageData) { // On IE10, imageData have type CanvasPixelArray, not Uint8ClampedArray. // CanvasPixelArray supports index access operations only. // Array buffers can't be destructuredd and compared with JSON.stringify isTestPass = imageData[0] === TestPixel.R && imageData[1] === TestPixel.G && imageData[2] === TestPixel.B && imageData[3] === TestPixel.A; } testCvs.width = testCvs.height = 1; return isTestPass; } catch (e) { console.error(`Failed to test for max canvas size of ${width}x${height}.`); return false; } }; function wrapAsync(fn) { return (...args) => { return new Promise((resolve) => { setTimeout(() => { const result = fn(...args); resolve(result); }, 0); }); }; } const squareTest = wrapAsync(memoize(canvasTest, memoKeySerializer)); const dimensionTest = wrapAsync(memoize(canvasTest, memoKeySerializer)); const testCanvasSize = async (w, h) => { const testSquareSide = sizes.squareSide.find((side) => side * side >= w * h); const testDimension = sizes.dimension.find((side) => side >= w && side >= h); if (!testSquareSide || !testDimension) { throw new Error('Not supported'); } const [squareSupported, dimensionSupported] = await Promise.all([ squareTest(testSquareSide, testSquareSide), dimensionTest(testDimension, 1) ]); if (squareSupported && dimensionSupported) { return true; } else { throw new Error('Not supported'); } }; const canvasResize = async (img, w, h) => { try { const { ctx, canvas } = createCanvas(); canvas.width = w; canvas.height = h; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, w, h); if (img instanceof HTMLImageElement) { img.src = '//:0'; // free memory } if (img instanceof HTMLCanvasElement) { img.width = img.height = 1; // free memory } return canvas; } catch (e) { throw new Error('Canvas resize error', { cause: e }); } }; /** * Native high-quality canvas resampling * * Browser support: * https://caniuse.com/mdn-api_canvasrenderingcontext2d_imagesmoothingenabled * Target dimensions expected to be supported by browser. */ const native = ({ img, targetW, targetH }) => canvasResize(img, targetW, targetH); /** * Goes from target to source by step, the last incomplete step is dropped. * Always returns at least one step - target. Source step is not included. * Sorted descending. * * Example with step = 0.71, source = 2000, target = 400 400 (target) <- 563 <- * 793 <- 1117 <- 1574 (dropped) <- [2000 (source)] */ const calcShrinkSteps = function ({ sourceW, targetW, targetH, step }) { const steps = []; let sW = targetW; let sH = targetH; // result should include at least one target step, // even if abs(source - target) < step * source // just to be sure nothing will break // if the original resolution / target resolution condition changes do { steps.push([sW, sH]); sW = Math.round(sW / step); sH = Math.round(sH / step); } while (sW < sourceW * step); return steps.reverse(); }; /** * Fallback resampling algorithm * * Reduces dimensions by step until reaches target dimensions, this gives a * better output quality than one-step method * * Target dimensions expected to be supported by browser, unsupported steps will * be dropped. */ const fallback = ({ img, sourceW, targetW, targetH, step }) => { const steps = calcShrinkSteps({ sourceW, targetW, targetH, step }); return steps.reduce((chain, [w, h]) => { return chain.then((canvas) => { return (testCanvasSize(w, h) .then(() => canvasResize(canvas, w, h)) // Here we assume that at least one step will be supported and HTMLImageElement will be converted to HTMLCanvasElement .catch(() => canvas)); }); }, Promise.resolve(img)); }; const isIOS = () => { if (/iPad|iPhone|iPod/.test(navigator.platform)) { return true; } else { return (navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform)); } }; const isIpadOS = navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform); const STEP = 0.71; // should be > sqrt(0.5) const shrinkImage = (img, settings) => { // do not shrink image if original resolution / target resolution ratio falls behind 2.0 if (img.width * STEP * img.height * STEP < settings.size) { throw new Error('Not required'); } const sourceW = img.width; const sourceH = img.height; const ratio = sourceW / sourceH; // target size shouldn't be greater than settings.size in any case const targetW = Math.floor(Math.sqrt(settings.size * ratio)); const targetH = Math.floor(settings.size / Math.sqrt(settings.size * ratio)); // we test the last step because we can skip all intermediate steps return testCanvasSize(targetW, targetH) .then(() => { const { ctx } = createCanvas(); const supportNative = 'imageSmoothingQuality' in ctx; // native scaling on ios gives blurry results // TODO: check if it's still true const useNativeScaling = supportNative && !isIOS() && !isIpadOS; return useNativeScaling ? native({ img, targetW, targetH }) : fallback({ img, sourceW, targetW, targetH, step: STEP }); }) .catch(() => Promise.reject('Not supported')); }; const shrinkFile = async (inputBlob, settings) => { try { const shouldSkip = await shouldSkipShrink(inputBlob); if (shouldSkip) { throw new Error('Should skipped'); } // Try to extract EXIF and ICC profile const exifResults = await Promise.allSettled([ getExif(inputBlob), isBrowserApplyExifOrientation(), getIccProfile(inputBlob) ]); const isRejected = exifResults.some((result) => result.status === 'rejected'); // If any of the promises is rejected, this is not a JPEG image const isJPEG = !isRejected; const [exifResult, isExifOrientationAppliedResult, iccProfileResult] = exifResults; // Load blob into the image const inputBlobWithoutIcc = await stripIccProfile(inputBlob).catch(() => inputBlob); const image = await imageLoader(URL.createObjectURL(inputBlobWithoutIcc)); URL.revokeObjectURL(image.src); // Shrink the image const canvas = await shrinkImage(image, settings); let format = 'image/jpeg'; let quality = settings?.quality || 0.8; if (!isJPEG && hasTransparency(canvas)) { format = 'image/png'; quality = undefined; } // Convert canvas to blob let newBlob = await canvasToBlob(canvas, format, quality); // Set EXIF for the new blob if (isJPEG && exifResult.status === 'fulfilled' && exifResult.value) { const exif = exifResult.value; const isExifOrientationApplied = isExifOrientationAppliedResult.status === 'fulfilled' ? isExifOrientationAppliedResult.value : false; newBlob = await replaceExif(newBlob, exif, isExifOrientationApplied); // TODO: should we continue shrink if failed to replace EXIF? // .catch(() => newBlob) } // Set ICC profile for the new blob if (isJPEG && iccProfileResult.status === 'fulfilled' && iccProfileResult.value.length > 0) { newBlob = await replaceIccProfile(newBlob, iccProfileResult.value); // TODO: should we continue shrink if failed to replace ICC? // .catch(() => newBlob) } return newBlob; } catch (e) { let message; if (e instanceof Error) { message = e.message; } if (typeof e === 'string') { message = e; } throw new Error(`Failed to shrink image. ${message ? `Message: "${message}".` : ''}`, { cause: e }); } }; exports.shrinkFile = shrinkFile;