@uploadcare/image-shrink
Version:
Library for work with Uploadcare image shrink
665 lines (637 loc) • 22.4 kB
JavaScript
;
// 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], idx) => {
return chain.then((canvas) => {
return testCanvasSize(w, h)
.then(() => canvasResize(canvas, w, h))
.catch(() => {
if (idx === steps.length - 1) {
// If the last step is failed then we assume that we can't shrink the image at all
throw new Error('Not supported');
}
return 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;