Last active
December 7, 2020 15:08
-
-
Save sungchuni/45ef80c26f48084b98e50391c01048d2 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
polyfill(); | |
const browser = (() => { | |
let cantRotate = null; | |
return { | |
get cantRotate() { | |
return new Promise(async (resolve, reject) => { | |
if (cantRotate === null) { | |
try { | |
const image = Object.assign(new Image(), { | |
src: | |
"data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAIAAwMBEQACEQEDEQH/xABRAAEAAAAAAAAAAAAAAAAAAAAKEAEBAQADAQEAAAAAAAAAAAAGBQQDCAkCBwEBAAAAAAAAAAAAAAAAAAAAABEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AG8T9NfSMEVMhQvoP3fFiRZ+MTHDifa/95OFSZU5OzRzxkyejv8ciEfhSceSXGjS8eSdLnZc2HDm4M3BxcXwH/9k=", | |
}); | |
await image.decode(); | |
const {width, height} = image; | |
resolve((cantRotate = !(width === 2 && height === 3))); | |
} catch (err) { | |
reject(err); | |
} | |
} else { | |
resolve(cantRotate); | |
} | |
}); | |
}, | |
}; | |
})(); | |
export default async function downscaleImage(file, option = {}) { | |
const { | |
mimeType = "image/jpeg", | |
quality = 0.8, | |
returnTypes = ["blob"], | |
square = false, | |
cropHorizontal = "center", | |
cropVertical = "top", | |
targetWidth = 1280, | |
} = option; | |
validation(file, { | |
mimeType, | |
quality, | |
returnTypes, | |
square, | |
cropHorizontal, | |
cropVertical, | |
targetWidth, | |
}); | |
const image = await createImage(file); | |
const {canvas, ctx} = createCanvas(image, { | |
targetWidth, | |
square, | |
}); | |
draw(image, canvas, ctx, {square, cropHorizontal, cropVertical}); | |
return createResult(file, canvas, { | |
mimeType, | |
quality, | |
returnTypes, | |
}); | |
} | |
function polyfill() { | |
if (!("decode" in HTMLImageElement.prototype)) { | |
Object.assign(HTMLImageElement.prototype, { | |
decode() { | |
return new Promise((resolve) => this.addEventListener("load", resolve)); | |
}, | |
}); | |
} | |
if ( | |
!("toBlob" in HTMLCanvasElement.prototype) && | |
typeof HTMLCanvasElement.prototype.msToBlob === "function" | |
) { | |
Object.assign(HTMLCanvasElement.prototype, { | |
toBlob(resolve) { | |
resolve(this.msToBlob()); | |
}, | |
}); | |
} | |
} | |
function validation( | |
file, | |
{ | |
mimeType, | |
quality, | |
returnTypes, | |
square, | |
cropHorizontal, | |
cropVertical, | |
targetWidth, | |
} | |
) { | |
if (!(file instanceof File)) { | |
throw new TypeError("file should be instance of File"); | |
} else if (!file.type.startsWith("image/")) { | |
throw new TypeError("file type sholud be starts with image/"); | |
} else if (!mimeType.startsWith("image/")) { | |
throw new TypeError("mime type sholud be starts with image/"); | |
} else if (!Number.isFinite(quality) || quality < 0 || quality > 1) { | |
throw new TypeError("quality should be finite number between 0 and 1"); | |
} else if (!(typeof square === "boolean")) { | |
throw new TypeError("square should be boolean"); | |
} else if (!["left", "center", "right"].includes(cropHorizontal)) { | |
throw new TypeError( | |
"crop align in horizontal should be one of left, center, right" | |
); | |
} else if (!["top", "center", "bottom"].includes(cropVertical)) { | |
throw new TypeError( | |
"crop align in vertical should be one of top, center, bottom" | |
); | |
} else if (!Number.isFinite(targetWidth)) { | |
throw new TypeError("target width should be finite number"); | |
} else if ( | |
!Array.isArray(returnTypes) || | |
returnTypes.length === 0 || | |
!returnTypes.every((returnType) => ["blob", "dataURL"].includes(returnType)) | |
) { | |
throw new TypeError( | |
"return type in return types should be one of blob or dataURL" | |
); | |
} | |
} | |
async function createImage(file) { | |
const objectURL = window.URL.createObjectURL(file); | |
const image = Object.assign(new Image(), { | |
src: objectURL, | |
}); | |
await image.decode(); | |
window.URL.revokeObjectURL(objectURL); | |
return image; | |
} | |
function createCanvas(image, {targetWidth, square}) { | |
const ratio = image.height / image.width; | |
const width = square | |
? Math.min(targetWidth, image.width, image.height) | |
: Math.min(targetWidth, image.width); | |
const height = width * (square || ratio); | |
const canvas = Object.assign(document.createElement("canvas"), { | |
width, | |
height, | |
}); | |
const ctx = canvas.getContext("2d"); | |
return {canvas, ctx}; | |
} | |
function draw(image, canvas, ctx, {square, cropHorizontal, cropVertical}) { | |
ctx.fillStyle = "#ffffff"; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
const {width, height} = canvas; | |
if ("imageSmoothingQuality" in ctx) { | |
ctx.imageSmoothingEnabled = true; | |
ctx.imageSmoothingQuality = "high"; | |
if (square) { | |
const size = Math.min(image.width, image.height); | |
const sx = { | |
left: 0, | |
center: (image.width - size) * 0.5, | |
right: image.width - size, | |
}[cropHorizontal]; | |
const sy = { | |
top: 0, | |
center: (image.height - size) * 0.5, | |
bottom: image.height - size, | |
}[cropVertical]; | |
ctx.drawImage(image, sx, sy, size, size, 0, 0, width, height); | |
} else { | |
ctx.drawImage(image, 0, 0, width, height); | |
} | |
} else { | |
const offscreenCanvas = Object.assign(document.createElement("canvas"), { | |
width: image.width, | |
height: image.height, | |
}); | |
const offscreenCtx = offscreenCanvas.getContext("2d"); | |
offscreenCtx.drawImage(image, 0, 0); | |
const total = Math.max( | |
Math.ceil(Math.log(image.width / width) / Math.log(2)) - 1, | |
0 | |
); | |
const iteration = {total, count: total}; | |
while (iteration.count-- > 0) { | |
offscreenCtx.drawImage( | |
offscreenCanvas, | |
0, | |
0, | |
offscreenCanvas.width * 0.5, | |
offscreenCanvas.height * 0.5 | |
); | |
} | |
const scaledWidth = offscreenCanvas.width * 0.5 ** iteration.total; | |
const scaledHeight = offscreenCanvas.height * 0.5 ** iteration.total; | |
if (square) { | |
const size = Math.min(scaledWidth, scaledHeight); | |
ctx.drawImage( | |
offscreenCanvas, | |
(scaledWidth - size) * 0.5, | |
(scaledHeight - size) * 0.5, | |
size, | |
size, | |
0, | |
0, | |
width, | |
height | |
); | |
} else { | |
ctx.drawImage( | |
offscreenCanvas, | |
0, | |
0, | |
scaledWidth, | |
scaledHeight, | |
0, | |
0, | |
width, | |
height | |
); | |
} | |
} | |
} | |
async function createResult(file, canvas, {mimeType, quality, returnTypes}) { | |
const cantRotate = await browser.cantRotate; | |
const resultCanvas = cantRotate | |
? await getRotatedCanvas(file, canvas) | |
: canvas; | |
const result = {}; | |
if (returnTypes.includes("blob")) { | |
result.blob = await new Promise((resolve) => | |
resultCanvas.toBlob(resolve, mimeType, quality) | |
); | |
} | |
if (returnTypes.includes("dataURL")) { | |
result.dataURL = resultCanvas.toDataURL(mimeType, quality); | |
} | |
return result; | |
} | |
async function getRotatedCanvas(file, canvas) { | |
const {width, height} = canvas; | |
const {value} = await parseOrientation(file); | |
if ([3, 6, 8].includes(value)) { | |
const {PI} = Math; | |
const [angle, x, y] = | |
(value === 3 && [PI, -width, -height]) || | |
(value === 6 && [PI * 0.5, 0, -height]) || | |
(value === 8 && [-PI * 0.5, -width, 0]); | |
const rotatedCanvas = Object.assign( | |
document.createElement("canvas"), | |
angle % PI ? {width: height, height: width} : {width, height} | |
); | |
const rotatedCtx = rotatedCanvas.getContext("2d"); | |
rotatedCtx.rotate(angle); | |
rotatedCtx.translate(x, y); | |
rotatedCtx.drawImage(canvas, 0, 0, width, height, 0, 0, width, height); | |
return rotatedCanvas; | |
} else { | |
return canvas; | |
} | |
} | |
async function parseOrientation(blob) { | |
const APP1_MARKER = 0xffe1; | |
const EXIF_HEADER = Array.from("Exif") | |
.map((c) => c.charCodeAt()) | |
.reverse() | |
.reduce( | |
(accumulator, charCode, index) => | |
accumulator + charCode * 16 ** (index * 2) | |
); | |
const INTEL_BYTE_ALIGN = 0x4949; | |
const ORIENTATION_TAG_NO = 0x0112; | |
const orientation = { | |
value: 9, | |
title: undefined, | |
}; | |
const arrayBuffer = await getArrayBuffer(blob); | |
const dataView = { | |
exif: null, | |
whole: new DataView(arrayBuffer), | |
}; | |
for (let i = 0; i < dataView.whole.byteLength - 4; i++) { | |
if ( | |
dataView.whole.getUint16(i) === APP1_MARKER && | |
dataView.whole.getUint32(i + 4) === EXIF_HEADER | |
) { | |
const size = dataView.whole.getUint16(i + 2); | |
dataView.exif = new DataView(arrayBuffer.slice(i, size)); | |
break; | |
} | |
} | |
if (dataView.exif === null) { | |
return orientation; | |
} | |
const littleEndian = dataView.exif.getUint16(10) === INTEL_BYTE_ALIGN; | |
for (let i = 0; i < dataView.exif.byteLength - 2; i = i + 2) { | |
if (dataView.exif.getUint16(i, littleEndian) === ORIENTATION_TAG_NO) { | |
const value = dataView.exif.getUint16(i + 8, littleEndian); | |
const title = | |
(value === 1 && "upper left") || | |
(value === 3 && "lower right") || | |
(value === 6 && "upper right") || | |
(value === 8 && "lower left") || | |
undefined; | |
Object.assign(orientation, {value, title}); | |
break; | |
} | |
} | |
return orientation; | |
} | |
function getArrayBuffer(blob) { | |
if ("arrayBuffer" in blob) { | |
return blob.arrayBuffer(); | |
} else { | |
return new Promise((resolve) => { | |
const fileReader = new FileReader(); | |
fileReader.readAsArrayBuffer(blob); | |
fileReader.onloadend = ({target: {result}}) => resolve(result); | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment