UNPKG

js-image-compress

Version:

JavaScript image compressor.typescript support.

647 lines (641 loc) 18.9 kB
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 };