import * as ExifReader from 'exifreader';
import { toBufferByBlob } from '../utils/toBuffer';
import { ResizeType, ResizeConfig, ResizeResult, DrawBound } from './types';
interface ParseMaxSize {
maxWidth: number;
maxHeight: number;
}
interface ParseMetadata {
sw: number;
sh: number;
orientation?: number;
}
/**
* Blob 이미지 리사이즈 용
* Blob -> Canvas&Image resize -> Blob.
* @class BlobImageResize
*/
export class BlobImageResize {
/**
* @param {Blob} blob 변경할 원본 Blob
* @param {ResizeConfig} [config={}] 리사이징 옵션
*/
constructor(blob: Blob, config: ResizeConfig = {}) {
this.blob = blob;
const {
expectWidth = 2000,
expectHeight = 2000,
quality = 0.9,
resizeType = ResizeType.SCALE,
expectContentType,
fillBgColor,
applyOrientation = false,
} = config;
this.quality = quality;
this.maxWidth = expectWidth;
this.maxHeight = expectHeight;
this.resizeType = resizeType;
this.forceContentType = expectContentType;
this.fillBgColor = fillBgColor;
this.applyOrientation = applyOrientation;
}
// 리사이징 대상 Blob
protected blob: Blob;
// 리사이징 대상 Blob 의 URL
protected blobURL: string;
// 리사이징 대상 Blob 을 로드할 이미지
protected domImage: HTMLImageElement;
// 리사이징 대상 이미지를 그려낼 캔버스
protected domCanvas: HTMLCanvasElement;
protected domCanvasContext: CanvasRenderingContext2D;
// 캔버스에서 만들어낼 이미지 퀄리티
protected quality: number;
// contentType 강제 지정
protected forceContentType: string;
// 리사이징 최대 사이즈
protected maxWidth: number;
protected maxHeight: number;
// 캔버스 배경 컬러
protected fillBgColor: string;
// 리사이징 할 때 캔버스에 그려낼 사이즈 타입
protected resizeType: ResizeType;
// 리사이징 완료된 Blob
protected resizeBlob: Blob;
// orientation 적용 여부
protected applyOrientation: boolean;
protected detectedOrientation: number;
// 응답용 promize
protected promise: Promise<ResizeResult>;
protected promiseResolve: (value: ResizeResult) => void;
protected promiseReject: (reason?: any) => void;
/**
* 리사이징 타입 - SCALE 형
* 정해진 expect 사이즈를 최대 사이즈로 비율에 맞춤. 원본이 작은 경우 늘리지 않음.
* @param {number} sw
* @param {number} sh
* @returns {DrawBound}
*/
getResizeToScale(sw: number, sh: number): DrawBound {
const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
const dx: number = 0;
const dy: number = 0;
let dw: number = 0;
let dh: number = 0;
const isLandscape: boolean = sh <= sw;
if (isLandscape) {
dw = Math.min(maxWidth, sw);
dh = Math.floor((dw / sw) * sh);
} else {
dh = Math.min(maxHeight, sh);
dw = Math.floor((dh / sh) * sw);
}
return { dx, dy, dw, dh, mw: dw, mh: dh };
}
/**
* 리사이징 타입 - SCALE 형
* 정해진 expect 사이즈를 최대 사이즈로 비율에 맞춤. 원본이 작은 경우 비율에 맞춰서 늘림.
* @param {number} sw
* @param {number} sh
* @returns {DrawBound}
*/
getResizeToScaleStretch(sw: number, sh: number): DrawBound {
const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
const dx: number = 0;
const dy: number = 0;
let dw: number = 0;
let dh: number = 0;
let contentRatio: number = 1;
const isLandscape: boolean = sh <= sw;
if (isLandscape) {
contentRatio = sw / sh;
contentRatio = 1 < contentRatio ? contentRatio : 1;
dw = maxWidth * contentRatio;
dh = Math.floor((dw / sw) * sh);
} else {
contentRatio = sh / sw;
contentRatio = 1 < contentRatio ? contentRatio : 1;
dh = maxHeight * contentRatio;
dw = Math.floor((dh / sh) * sw);
}
return { dx, dy, dw, dh, mw: dw, mh: dh };
}
/**
* 리사이징 타입 - COVER 형
* 정해진 expect 사이즈에 빈 여백 없이 맞춤. 원본이 작은 경우 늘리지 않으며, cover 처리가 가능한 최대 사이즈로 맞춤.
* @param {number} sw
* @param {number} sh
* @returns {DrawBound}
*/
getResizeToCover(sw: number, sh: number): DrawBound {
const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
const min = Math.min(sw, sh, maxWidth, maxHeight);
const mw = Math.min(min, sw, maxWidth);
const mh = Math.min(min, sh, maxHeight);
let dx: number = 0;
let dy: number = 0;
let dw: number = 0;
let dh: number = 0;
let expectRatio: number = mw / mh;
let contentRatio: number = sw / sh;
if (expectRatio < contentRatio) {
dh = mh;
dw = mh * contentRatio;
} else {
dw = mw;
dh = mw / contentRatio;
}
dx = (mw - dw) * 0.5;
dy = (mh - dh) * 0.5;
return { dx, dy, dw, dh, mw, mh };
}
/**
* 리사이징 타입 - COVER 형
* 정해진 expect 사이즈에 빈 여백 없이 맞춤. 원본이 작은 경우 늘림.
* @param {number} sw
* @param {number} sh
* @returns {DrawBound}
*/
getResizeToCoverStretch(sw: number, sh: number): DrawBound {
const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
let dx: number = 0;
let dy: number = 0;
let dw: number = 0;
let dh: number = 0;
let expectRatio: number = maxWidth / maxHeight;
let contentRatio: number = sw / sh;
if (expectRatio < contentRatio) {
dh = maxHeight;
dw = maxHeight * contentRatio;
} else {
dw = maxWidth;
dh = maxWidth / contentRatio;
}
dx = (maxWidth - dw) * 0.5;
dy = (maxHeight - dh) * 0.5;
return { dx, dy, dw, dh, mw: maxWidth, mh: maxHeight };
}
/**
* 리사이징 타입 - Fixed 형
* 정해진 expect 사이즈에 맞춤.
* @param {number} sw
* @param {number} sh
* @returns {DrawBound}
*/
getResizeToFixed(sw: number, sh: number): DrawBound {
const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
let dw: number = maxWidth;
let dh: number = maxHeight;
return {
dx: 0,
dy: 0,
dw: dw,
dh: dh,
mw: dw,
mh: dh,
};
}
/**
* 이미지 사이즈와 옵션 조합으로 리사이징 가능한 최대 넓이, 높이 반환
* @protected
* @param {number} sw
* @param {number} sh
* @returns {ParseMaxSize}
*/
protected getMaxSize(sw: number, sh: number): ParseMaxSize {
let maxWidth = this.maxWidth;
let maxHeight = this.maxHeight;
if (this.maxWidth <= 0 && this.maxHeight <= 0) {
maxWidth = sw;
maxHeight = sh;
} else if (this.maxWidth <= 0) {
if (this.resizeType === ResizeType.SCALE_STRETCH) {
maxWidth = sw <= sh ? sw * (this.maxHeight / sh) : this.maxHeight;
} else if (this.resizeType === ResizeType.FIXED) {
maxWidth = sw * (this.maxHeight / sh);
} else {
maxWidth = maxHeight;
}
} else if (this.maxHeight <= 0) {
if (this.resizeType === ResizeType.SCALE_STRETCH) {
maxHeight = sh <= sw ? sh * (this.maxWidth / sw) : this.maxWidth;
} else if (this.resizeType === ResizeType.FIXED) {
maxHeight = sh * (this.maxWidth / sw);
} else {
maxHeight = maxWidth;
}
}
return {
maxWidth: maxWidth,
maxHeight: maxHeight,
};
}
/**
* 이미지 로드 완료
* @protected
*/
protected onImageLoaded() {
URL.revokeObjectURL(this.blobURL);
const imageWidth = this.domImage.naturalWidth;
const imageHeight = this.domImage.naturalHeight;
this.draw(imageWidth, imageHeight);
}
async toBufferByBlob(blob: Blob) {
return await toBufferByBlob(blob);
}
/**
* 이미지 orientation 등 설정 정보에 따라 그려져야할 사이즈, 방향 등 반환
* @protected
* @param {number} imageWidth
* @param {number} imageHeight
* @returns {Promise<ParseMetadata>}
*/
protected async parseDrawMetadata(
imageWidth: number,
imageHeight: number
): Promise<ParseMetadata> {
let sw = imageWidth;
let sh = imageHeight;
let orientation = 0;
if (this.applyOrientation === true) {
try {
// const buffer = await this.blob.arrayBuffer();
const buffer = await this.toBufferByBlob(this.blob);
const result = ExifReader.load(buffer);
if (result.Orientation && result.Orientation.value) {
orientation = result.Orientation.value;
}
} catch (err) {
console.error(err);
}
if (4 < orientation) {
sw = imageHeight;
sh = imageWidth;
}
}
return { sw, sh, orientation };
}
/**
* 이미지가 그려져야할 영역 정보 반환
* @protected
* @param {number} sw
* @param {number} sh
* @return {DrawBound}
*/
protected parseDrawBound(sw: number, sh: number): DrawBound {
let drawBound: DrawBound;
switch (this.resizeType) {
case ResizeType.COVER:
drawBound = this.getResizeToCover(sw, sh);
break;
case ResizeType.COVER_STRETCH:
drawBound = this.getResizeToCoverStretch(sw, sh);
break;
case ResizeType.SCALE_STRETCH:
drawBound = this.getResizeToScaleStretch(sw, sh);
break;
case ResizeType.SCALE:
drawBound = this.getResizeToScale(sw, sh);
break;
default:
drawBound = this.getResizeToFixed(sw, sh);
break;
}
return drawBound;
}
/**
* 그리기
* @protected
* @param {number} imageWidth
* @param {number} imageHeight
* @returns {Promise<void>}
*/
protected async draw(imageWidth: number, imageHeight: number): Promise<void> {
const { sw, sh, orientation } = await this.parseDrawMetadata(imageWidth, imageHeight);
const { dx, dy, dw, dh, mw, mh } = this.parseDrawBound(sw, sh);
const tx = dw + dx * 2;
const ty = dh + dy * 2;
const contentType = this.forceContentType || this.blob.type;
const canvas = this.domCanvas;
const context = this.domCanvasContext;
canvas.width = mw;
canvas.height = mh;
if (this.fillBgColor) {
context.fillStyle = this.fillBgColor;
context.fillRect(0, 0, mw, mh);
}
switch (orientation) {
case 2:
context.translate(tx, 0);
context.scale(-1, 1);
break;
case 3:
context.translate(tx, ty);
context.rotate(Math.PI);
break;
case 4:
context.translate(0, ty);
context.scale(1, -1);
break;
case 5:
context.rotate(Math.PI * 0.5);
context.scale(1, -1);
break;
case 6:
context.rotate(Math.PI * 0.5);
context.translate(0, -tx);
break;
case 7:
context.rotate(Math.PI * 0.5);
context.translate(ty, -tx);
context.scale(-1, 1);
break;
case 8:
context.rotate(Math.PI * -0.5);
context.translate(-ty, 0);
break;
}
if (4 < orientation) {
context.drawImage(this.domImage, 0, 0, sh, sw, dy, dx, dh, dw);
} else {
context.drawImage(this.domImage, 0, 0, sw, sh, dx, dy, dw, dh);
}
this.detectedOrientation = orientation;
// 그리기 완료 (type 이 jpeg 인 경우만 quality 적용이 됨)
canvas.toBlob(this.onResized.bind(this), contentType, this.quality);
}
/**
* 이미지 로드 오류
* @protected
*/
protected onImageError() {
URL.revokeObjectURL(this.blobURL);
this.promiseReject({
...this.getState(),
error: new Error('image load error'),
});
}
/**
* 이미지 리사이징 완료
* @protected
* @param {Blob} resizeBlob
*/
protected onResized(resizeBlob: Blob) {
this.resizeBlob = resizeBlob;
this.promiseResolve(this.getState());
}
/**
* 리사이징 이미지 생성하기
* @returns {Promise<ResizeResult>}
*/
create(): Promise<ResizeResult> {
this.domCanvas = document.createElement('canvas');
this.domCanvasContext = this.domCanvas.getContext('2d');
this.domImage = new Image();
this.domImage.onload = this.onImageLoaded.bind(this);
this.domImage.onerror = this.onImageError.bind(this);
this.promise = new Promise((resolve, reject) => {
this.promiseResolve = resolve;
this.promiseReject = reject;
try {
this.blobURL = URL.createObjectURL(this.blob);
this.domImage.src = this.blobURL;
} catch (err) {
this.promiseReject({
...this.getState(),
error: err,
});
}
});
return this.promise;
}
getState(): ResizeResult {
const blob = this.resizeBlob || null;
const { width = 0, height = 0 } = this.domCanvas || {};
const orientation = this.detectedOrientation || 0;
return {
blob: blob,
width: blob ? width : 0,
height: blob ? height : 0,
orientation,
};
}
}