js-image-compress
Version:
JavaScript image compressor.typescript support.
647 lines (641 loc) • 18.9 kB
JavaScript
const defaultOptions = {
strict: true,
checkOrientation: true,
retainExif: false,
maxWidth: Infinity,
maxHeight: Infinity,
minWidth: 0,
minHeight: 0,
width: void 0,
height: void 0,
resize: "none",
quality: 0.8,
mimeType: "auto",
convertTypes: ["image/png"],
convertSize: 5e6,
beforeDraw: null,
drew: null,
success: null,
error: null
};
const CanvasPrototype = window.HTMLCanvasElement && window.HTMLCanvasElement.prototype;
const hasBlobConstructor = window.Blob && (function() {
try {
return Boolean(new Blob());
} catch (e) {
return false;
}
})();
const hasArrayBufferViewSupport = hasBlobConstructor && window.Uint8Array && (function() {
try {
return new Blob([new Uint8Array(100)]).size === 100;
} catch (e) {
return false;
}
})();
const BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder;
const dataURIPattern = /^data:((.*?)(;charset=.*?)?)(;base64)?,/;
const dataURLtoBlob = (hasBlobConstructor || BlobBuilder) && window.atob !== void 0 && window.ArrayBuffer && window.Uint8Array && function(dataURI) {
let matches = null;
let mediaType = "";
let isBase64 = false;
let dataString = "";
let byteString = "";
let arrayBuffer;
let intArray;
let i;
let bb;
matches = dataURI.match(dataURIPattern);
if (!matches) {
throw new Error("invalid data URI");
}
mediaType = matches[2] ? matches[1] : "text/plain" + (matches[3] || ";charset=US-ASCII");
isBase64 = !!matches[4];
dataString = dataURI.slice(matches[0].length);
if (isBase64) {
byteString = atob(dataString);
} else {
byteString = decodeURIComponent(dataString);
}
arrayBuffer = new ArrayBuffer(byteString.length);
intArray = new Uint8Array(arrayBuffer);
for (i = 0; i < byteString.length; i += 1) {
intArray[i] = byteString.charCodeAt(i);
}
if (hasBlobConstructor) {
return new Blob([hasArrayBufferViewSupport ? intArray : arrayBuffer], {
type: mediaType
});
}
bb = new BlobBuilder();
bb.append(arrayBuffer);
return bb.getBlob(mediaType);
};
if (window.HTMLCanvasElement && !CanvasPrototype.toBlob) {
if (CanvasPrototype.mozGetAsFile) {
CanvasPrototype.toBlob = function(callback, type, quality) {
var self = this;
setTimeout(function() {
if (quality && CanvasPrototype.toDataURL !== void 0 && dataURLtoBlob) {
callback(dataURLtoBlob(self.toDataURL(type, quality)));
} else {
callback(self.mozGetAsFile("blob", type));
}
});
};
} else if (CanvasPrototype.toDataURL && dataURLtoBlob) {
if (CanvasPrototype.msToBlob) {
CanvasPrototype.toBlob = function(callback, type, quality) {
var self = this;
setTimeout(function() {
if ((type && type !== "image/png" || quality) && CanvasPrototype.toDataURL !== void 0 && dataURLtoBlob) {
callback(dataURLtoBlob(self.toDataURL(type, quality)));
} else {
callback(self.msToBlob(type));
}
});
};
} else {
CanvasPrototype.toBlob = function(callback, type, quality) {
var self = this;
setTimeout(function() {
callback(dataURLtoBlob(self.toDataURL(type, quality)));
});
};
}
}
}
const toBlob = CanvasPrototype.toBlob;
const WINDOW = window;
function isBlob(value) {
if (typeof Blob === "undefined") {
return false;
}
return value instanceof Blob || Object.prototype.toString.call(value) === "[object Blob]";
}
const isPositiveNumber = (value) => value > 0 && value < Infinity;
const { slice } = Array.prototype;
function toArray(value) {
return Array.from ? Array.from(value) : slice.call(value);
}
const REGEXP_IMAGE_TYPE = /^image\/.+$/;
function isImageType(value) {
return REGEXP_IMAGE_TYPE.test(value);
}
const { fromCharCode } = String;
function getStringFromCharCode(dataView, start, length) {
let str = "";
let i;
length += start;
for (i = start; i < length; i += 1) {
str += fromCharCode(dataView.getUint8(i));
}
return str;
}
const { btoa } = WINDOW;
function arrayBufferToDataURL(arrayBuffer, mimeType) {
const chunks = [];
const chunkSize = 8192;
let uint8 = new Uint8Array(arrayBuffer);
while (uint8.length > 0) {
chunks.push(fromCharCode.apply(null, toArray(uint8.subarray(0, chunkSize))));
uint8 = uint8.subarray(chunkSize);
}
return `data:${mimeType};base64,${btoa(chunks.join(""))}`;
}
function resetAndGetOrientation(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let orientation;
try {
let littleEndian;
let app1Start;
let ifdStart;
if (dataView.getUint8(0) === 255 && dataView.getUint8(1) === 216) {
const length = dataView.byteLength;
let offset = 2;
while (offset + 1 < length) {
if (dataView.getUint8(offset) === 255 && dataView.getUint8(offset + 1) === 225) {
app1Start = offset;
break;
}
offset += 1;
}
}
if (app1Start) {
const exifIDCode = app1Start + 4;
const tiffOffset = app1Start + 10;
if (getStringFromCharCode(dataView, exifIDCode, 4) === "Exif") {
const endianness = dataView.getUint16(tiffOffset);
littleEndian = endianness === 18761;
if (littleEndian || endianness === 19789) {
if (dataView.getUint16(tiffOffset + 2, littleEndian) === 42) {
const firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian);
if (firstIFDOffset >= 8) {
ifdStart = tiffOffset + firstIFDOffset;
}
}
}
}
}
if (ifdStart) {
const length = dataView.getUint16(ifdStart, littleEndian);
let offset;
let i;
for (i = 0; i < length; i += 1) {
offset = ifdStart + i * 12 + 2;
if (dataView.getUint16(offset, littleEndian) === 274) {
offset += 8;
orientation = dataView.getUint16(offset, littleEndian);
dataView.setUint16(offset, 1, littleEndian);
break;
}
}
}
} catch (e) {
orientation = 1;
}
return orientation;
}
function parseOrientation(orientation) {
let rotate = 0;
let scaleX = 1;
let scaleY = 1;
switch (orientation) {
// Flip horizontal
case 2:
scaleX = -1;
break;
// Rotate left 180°
case 3:
rotate = -180;
break;
// Flip vertical
case 4:
scaleY = -1;
break;
// Flip vertical and rotate right 90°
case 5:
rotate = 90;
scaleY = -1;
break;
// Rotate right 90°
case 6:
rotate = 90;
break;
// Flip horizontal and rotate right 90°
case 7:
rotate = 90;
scaleX = -1;
break;
// Rotate left 90°
case 8:
rotate = -90;
break;
}
return {
rotate,
scaleX,
scaleY
};
}
const REGEXP_DECIMALS = /\.\d*(?:0|9){12}\d*$/;
function normalizeDecimalNumber(value, times = 1e11) {
return REGEXP_DECIMALS.test(value.toString()) ? Math.round(value * times) / times : value;
}
function getAdjustedSizes({
aspectRatio,
height,
width
}, type = "none") {
const isValidWidth = isPositiveNumber(width);
const isValidHeight = isPositiveNumber(height);
if (isValidWidth && isValidHeight) {
const adjustedWidth = height * aspectRatio;
if ((type === "contain" || type === "none") && adjustedWidth > width || type === "cover" && adjustedWidth < width) {
height = width / aspectRatio;
} else {
width = height * aspectRatio;
}
} else if (isValidWidth) {
height = width / aspectRatio;
} else if (isValidHeight) {
width = height * aspectRatio;
}
return {
width,
height
};
}
function getExif(arrayBuffer) {
const array = toArray(new Uint8Array(arrayBuffer));
const { length } = array;
const segments = [];
let start = 0;
while (start + 3 < length) {
const value = array[start];
const next = array[start + 1];
if (value === 255 && next === 218) {
break;
}
if (value === 255 && next === 216) {
start += 2;
} else {
const offset = array[start + 2] * 256 + array[start + 3];
const end = start + offset + 2;
const segment = array.slice(start, end);
segments.push(segment);
start = end;
}
}
return segments.reduce((exifArray, current) => {
if (current[0] === 255 && current[1] === 225) {
return exifArray.concat(current);
}
return exifArray;
}, []);
}
function insertExif(arrayBuffer, exifArray) {
const array = toArray(new Uint8Array(arrayBuffer));
if (array[2] !== 255 || array[3] !== 224) {
return arrayBuffer;
}
const app0Length = array[4] * 256 + array[5];
const newArrayBuffer = [255, 216].concat(exifArray, array.slice(4 + app0Length));
return new Uint8Array(newArrayBuffer);
}
const { ArrayBuffer: ArrayBuffer$1, FileReader } = WINDOW;
const URL = WINDOW.URL || WINDOW.webkitURL;
const AnotherCompressor = WINDOW.Compressor;
class Compressor {
file;
options;
image;
aborted;
exif;
result;
reader;
constructor(file, options) {
this.file = file;
this.exif = [];
this.image = new Image();
this.options = {
...defaultOptions,
...options
};
this.aborted = false;
this.result = null;
this.reader = null;
this.init();
}
init() {
const { file, options } = this;
if (!isBlob(file)) {
this.fail(new Error("The first argument must be a File or Blob object."));
return;
}
const mimeType = file.type;
if (!isImageType(mimeType)) {
this.fail(new Error("The first argument must be an image File or Blob object."));
return;
}
if (!URL || !FileReader) {
this.fail(new Error("The current browser does not support image compression."));
return;
}
if (!ArrayBuffer$1) {
options.checkOrientation = false;
options.retainExif = false;
}
const isJPEGImage = mimeType === "image/jpeg";
const checkOrientation = isJPEGImage && options.checkOrientation;
const retainExif = isJPEGImage && options.retainExif;
if (URL && !checkOrientation && !retainExif) {
this.load({
url: URL.createObjectURL(file)
});
} else {
const reader = new FileReader();
this.reader = reader;
reader.onload = ({ target }) => {
const result = target?.result;
const data = {};
let orientation = 1;
if (checkOrientation) {
orientation = resetAndGetOrientation(result);
if (orientation > 1) {
Object.assign(data, parseOrientation(orientation));
}
}
if (retainExif) {
this.exif = getExif(result);
}
if (checkOrientation || retainExif) {
if (!URL || // Generate a new URL with the default orientation value 1.
orientation > 1) {
data.url = arrayBufferToDataURL(result, mimeType);
} else {
data.url = URL.createObjectURL(file);
}
} else {
data.url = result;
}
this.load(data);
};
reader.onabort = () => {
this.fail(new Error("Aborted to read the image with FileReader."));
};
reader.onerror = () => {
this.fail(new Error("Failed to read the image with FileReader."));
};
reader.onloadend = () => {
this.reader = null;
};
if (checkOrientation || retainExif) {
reader.readAsArrayBuffer(file);
} else {
reader.readAsDataURL(file);
}
}
}
load(data) {
const { file, image } = this;
image.onload = () => {
this.draw({
...data,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight
});
};
image.onabort = () => {
this.fail(new Error("Aborted to load the image."));
};
image.onerror = () => {
this.fail(new Error("Failed to load the image."));
};
if (WINDOW.navigator && /(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(WINDOW.navigator.userAgent)) {
image.crossOrigin = "anonymous";
}
image.alt = file.name;
image.src = data.url;
}
draw({
naturalWidth,
naturalHeight,
rotate = 0,
scaleX = 1,
scaleY = 1
}) {
const { file, image, options } = this;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
const is90DegreesRotated = Math.abs(rotate) % 180 === 90;
const resizable = (options.resize === "contain" || options.resize === "cover") && isPositiveNumber(options.width ?? naturalWidth) && isPositiveNumber(options.height ?? naturalHeight);
let maxWidth = Math.max(options.maxWidth ?? defaultOptions.maxWidth, 0) || Infinity;
let maxHeight = Math.max(options.maxHeight ?? defaultOptions.maxHeight, 0) || Infinity;
let minWidth = Math.max(options.minWidth ?? defaultOptions.minWidth, 0) || 0;
let minHeight = Math.max(options.minHeight ?? defaultOptions.minHeight, 0) || 0;
let aspectRatio = naturalWidth / naturalHeight;
let { width = 0, height = 0 } = options;
if (is90DegreesRotated) {
[maxWidth, maxHeight] = [maxHeight, maxWidth];
[minWidth, minHeight] = [minHeight, minWidth];
[width, height] = [height, width];
}
if (resizable) {
aspectRatio = width / height;
}
({ width: maxWidth, height: maxHeight } = getAdjustedSizes(
{
aspectRatio,
width: maxWidth,
height: maxHeight
},
"contain"
));
({ width: minWidth, height: minHeight } = getAdjustedSizes(
{
aspectRatio,
width: minWidth,
height: minHeight
},
"cover"
));
if (resizable) {
({ width, height } = getAdjustedSizes(
{
aspectRatio,
width,
height
},
options.resize
));
} else {
({ width = naturalWidth, height = naturalHeight } = getAdjustedSizes({
aspectRatio,
width,
height
}));
}
width = Math.floor(normalizeDecimalNumber(Math.min(Math.max(width, minWidth), maxWidth)));
height = Math.floor(normalizeDecimalNumber(Math.min(Math.max(height, minHeight), maxHeight)));
const destX = -width / 2;
const destY = -height / 2;
const destWidth = width;
const destHeight = height;
const params = [];
if (resizable) {
let srcX = 0;
let srcY = 0;
let srcWidth = naturalWidth;
let srcHeight = naturalHeight;
let _resize = void 0;
if (options.resize === "contain") ;
if (options.resize === "cover") ;
({ width: srcWidth, height: srcHeight } = getAdjustedSizes(
{
aspectRatio,
width: naturalWidth,
height: naturalHeight
},
_resize
));
srcX = (naturalWidth - srcWidth) / 2;
srcY = (naturalHeight - srcHeight) / 2;
params.push(srcX, srcY, srcWidth, srcHeight);
}
params.push(destX, destY, destWidth, destHeight);
if (is90DegreesRotated) {
[width, height] = [height, width];
}
canvas.width = width;
canvas.height = height;
if (!isImageType(options.mimeType ?? "")) {
options.mimeType = file.type;
}
let fillStyle = "transparent";
if (file.size > (options.convertSize ?? defaultOptions.convertSize) && (options.convertTypes ?? defaultOptions.convertTypes).indexOf(
options.mimeType ?? defaultOptions.mimeType
) >= 0) {
options.mimeType = "image/jpeg";
}
const isJPEGImage = options.mimeType === "image/jpeg";
if (isJPEGImage) {
fillStyle = "#fff";
}
if (context) {
context.fillStyle = fillStyle;
context.fillRect(0, 0, width, height);
}
if (options.beforeDraw && context) {
options.beforeDraw.call(this, context, canvas);
}
if (this.aborted) {
return;
}
if (context) {
context.save();
context.translate(width / 2, height / 2);
context.rotate(rotate * Math.PI / 180);
context.scale(scaleX, scaleY);
context.drawImage(image, ...params);
context.restore();
}
if (options.drew && context) {
options.drew.call(this, context, canvas);
}
if (this.aborted) {
return;
}
const callback = (blob) => {
if (!this.aborted) {
const done = (result) => this.done({
naturalWidth,
naturalHeight,
result
});
if (blob && isJPEGImage && options.retainExif && this.exif && this.exif.length > 0) {
const next = (arrayBuffer) => done(
// @ts-ignore
toBlob(arrayBufferToDataURL(insertExif(arrayBuffer, this.exif), options.mimeType))
);
if (blob.arrayBuffer) {
blob.arrayBuffer().then(next).catch(() => {
this.fail(
new Error("Failed to read the compressed image with Blob.arrayBuffer().")
);
});
} else {
const reader = new FileReader();
this.reader = reader;
reader.onload = ({ target }) => {
next(target?.result);
};
reader.onabort = () => {
this.fail(new Error("Aborted to read the compressed image with FileReader."));
};
reader.onerror = () => {
this.fail(new Error("Failed to read the compressed image with FileReader."));
};
reader.onloadend = () => {
this.reader = null;
};
reader.readAsArrayBuffer(blob);
}
} else {
done(blob);
}
}
};
if (canvas.toBlob) {
canvas.toBlob(callback, options.mimeType, options.quality);
} else {
callback(toBlob(canvas.toDataURL(options.mimeType, options.quality)));
}
}
done({
naturalWidth,
naturalHeight,
result
}) {
const { file, image, options } = this;
if (URL && image.src.indexOf("blob:") === 0) {
URL.revokeObjectURL(image.src);
}
result = file;
this.result = result;
if (options.success) {
options.success.call(this, result);
}
}
fail(err) {
const { options } = this;
if (options.error) {
options.error.call(this, err);
} else {
throw err;
}
}
abort() {
if (!this.aborted) {
this.aborted = true;
if (this.reader) {
this.reader.abort();
} else if (!this.image.complete) {
this.image.onload = null;
if (this.image.onabort) {
this.image.onabort(new UIEvent("abort"));
}
} else {
this.fail(new Error("The compression process has been aborted."));
}
}
}
static noConflict() {
window.Compressor = AnotherCompressor;
return Compressor;
}
static setDefaults(options) {
Object.assign(defaultOptions, options);
}
}
export { Compressor as default };