blob-image/BlobImageResize.ts

  1. import * as ExifReader from 'exifreader';
  2. import { toBufferByBlob } from '../utils/toBuffer';
  3. import { ResizeType, ResizeConfig, ResizeResult, DrawBound } from './types';
  4. interface ParseMaxSize {
  5. maxWidth: number;
  6. maxHeight: number;
  7. }
  8. interface ParseMetadata {
  9. sw: number;
  10. sh: number;
  11. orientation?: number;
  12. }
  13. /**
  14. * Blob 이미지 리사이즈 용
  15. * Blob -> Canvas&Image resize -> Blob.
  16. * @class BlobImageResize
  17. */
  18. export class BlobImageResize {
  19. /**
  20. * @param {Blob} blob 변경할 원본 Blob
  21. * @param {ResizeConfig} [config={}] 리사이징 옵션
  22. */
  23. constructor(blob: Blob, config: ResizeConfig = {}) {
  24. this.blob = blob;
  25. const {
  26. expectWidth = 2000,
  27. expectHeight = 2000,
  28. quality = 0.9,
  29. resizeType = ResizeType.SCALE,
  30. expectContentType,
  31. fillBgColor,
  32. applyOrientation = false,
  33. } = config;
  34. this.quality = quality;
  35. this.maxWidth = expectWidth;
  36. this.maxHeight = expectHeight;
  37. this.resizeType = resizeType;
  38. this.forceContentType = expectContentType;
  39. this.fillBgColor = fillBgColor;
  40. this.applyOrientation = applyOrientation;
  41. }
  42. // 리사이징 대상 Blob
  43. protected blob: Blob;
  44. // 리사이징 대상 Blob 의 URL
  45. protected blobURL: string;
  46. // 리사이징 대상 Blob 을 로드할 이미지
  47. protected domImage: HTMLImageElement;
  48. // 리사이징 대상 이미지를 그려낼 캔버스
  49. protected domCanvas: HTMLCanvasElement;
  50. protected domCanvasContext: CanvasRenderingContext2D;
  51. // 캔버스에서 만들어낼 이미지 퀄리티
  52. protected quality: number;
  53. // contentType 강제 지정
  54. protected forceContentType: string;
  55. // 리사이징 최대 사이즈
  56. protected maxWidth: number;
  57. protected maxHeight: number;
  58. // 캔버스 배경 컬러
  59. protected fillBgColor: string;
  60. // 리사이징 할 때 캔버스에 그려낼 사이즈 타입
  61. protected resizeType: ResizeType;
  62. // 리사이징 완료된 Blob
  63. protected resizeBlob: Blob;
  64. // orientation 적용 여부
  65. protected applyOrientation: boolean;
  66. protected detectedOrientation: number;
  67. // 응답용 promize
  68. protected promise: Promise<ResizeResult>;
  69. protected promiseResolve: (value: ResizeResult) => void;
  70. protected promiseReject: (reason?: any) => void;
  71. /**
  72. * 리사이징 타입 - SCALE 형
  73. * 정해진 expect 사이즈를 최대 사이즈로 비율에 맞춤. 원본이 작은 경우 늘리지 않음.
  74. * @param {number} sw
  75. * @param {number} sh
  76. * @returns {DrawBound}
  77. */
  78. getResizeToScale(sw: number, sh: number): DrawBound {
  79. const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
  80. const dx: number = 0;
  81. const dy: number = 0;
  82. let dw: number = 0;
  83. let dh: number = 0;
  84. const isLandscape: boolean = sh <= sw;
  85. if (isLandscape) {
  86. dw = Math.min(maxWidth, sw);
  87. dh = Math.floor((dw / sw) * sh);
  88. } else {
  89. dh = Math.min(maxHeight, sh);
  90. dw = Math.floor((dh / sh) * sw);
  91. }
  92. return { dx, dy, dw, dh, mw: dw, mh: dh };
  93. }
  94. /**
  95. * 리사이징 타입 - SCALE 형
  96. * 정해진 expect 사이즈를 최대 사이즈로 비율에 맞춤. 원본이 작은 경우 비율에 맞춰서 늘림.
  97. * @param {number} sw
  98. * @param {number} sh
  99. * @returns {DrawBound}
  100. */
  101. getResizeToScaleStretch(sw: number, sh: number): DrawBound {
  102. const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
  103. const dx: number = 0;
  104. const dy: number = 0;
  105. let dw: number = 0;
  106. let dh: number = 0;
  107. let contentRatio: number = 1;
  108. const isLandscape: boolean = sh <= sw;
  109. if (isLandscape) {
  110. contentRatio = sw / sh;
  111. contentRatio = 1 < contentRatio ? contentRatio : 1;
  112. dw = maxWidth * contentRatio;
  113. dh = Math.floor((dw / sw) * sh);
  114. } else {
  115. contentRatio = sh / sw;
  116. contentRatio = 1 < contentRatio ? contentRatio : 1;
  117. dh = maxHeight * contentRatio;
  118. dw = Math.floor((dh / sh) * sw);
  119. }
  120. return { dx, dy, dw, dh, mw: dw, mh: dh };
  121. }
  122. /**
  123. * 리사이징 타입 - COVER 형
  124. * 정해진 expect 사이즈에 빈 여백 없이 맞춤. 원본이 작은 경우 늘리지 않으며, cover 처리가 가능한 최대 사이즈로 맞춤.
  125. * @param {number} sw
  126. * @param {number} sh
  127. * @returns {DrawBound}
  128. */
  129. getResizeToCover(sw: number, sh: number): DrawBound {
  130. const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
  131. const min = Math.min(sw, sh, maxWidth, maxHeight);
  132. const mw = Math.min(min, sw, maxWidth);
  133. const mh = Math.min(min, sh, maxHeight);
  134. let dx: number = 0;
  135. let dy: number = 0;
  136. let dw: number = 0;
  137. let dh: number = 0;
  138. let expectRatio: number = mw / mh;
  139. let contentRatio: number = sw / sh;
  140. if (expectRatio < contentRatio) {
  141. dh = mh;
  142. dw = mh * contentRatio;
  143. } else {
  144. dw = mw;
  145. dh = mw / contentRatio;
  146. }
  147. dx = (mw - dw) * 0.5;
  148. dy = (mh - dh) * 0.5;
  149. return { dx, dy, dw, dh, mw, mh };
  150. }
  151. /**
  152. * 리사이징 타입 - COVER 형
  153. * 정해진 expect 사이즈에 빈 여백 없이 맞춤. 원본이 작은 경우 늘림.
  154. * @param {number} sw
  155. * @param {number} sh
  156. * @returns {DrawBound}
  157. */
  158. getResizeToCoverStretch(sw: number, sh: number): DrawBound {
  159. const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
  160. let dx: number = 0;
  161. let dy: number = 0;
  162. let dw: number = 0;
  163. let dh: number = 0;
  164. let expectRatio: number = maxWidth / maxHeight;
  165. let contentRatio: number = sw / sh;
  166. if (expectRatio < contentRatio) {
  167. dh = maxHeight;
  168. dw = maxHeight * contentRatio;
  169. } else {
  170. dw = maxWidth;
  171. dh = maxWidth / contentRatio;
  172. }
  173. dx = (maxWidth - dw) * 0.5;
  174. dy = (maxHeight - dh) * 0.5;
  175. return { dx, dy, dw, dh, mw: maxWidth, mh: maxHeight };
  176. }
  177. /**
  178. * 리사이징 타입 - Fixed 형
  179. * 정해진 expect 사이즈에 맞춤.
  180. * @param {number} sw
  181. * @param {number} sh
  182. * @returns {DrawBound}
  183. */
  184. getResizeToFixed(sw: number, sh: number): DrawBound {
  185. const { maxWidth, maxHeight } = this.getMaxSize(sw, sh);
  186. let dw: number = maxWidth;
  187. let dh: number = maxHeight;
  188. return {
  189. dx: 0,
  190. dy: 0,
  191. dw: dw,
  192. dh: dh,
  193. mw: dw,
  194. mh: dh,
  195. };
  196. }
  197. /**
  198. * 이미지 사이즈와 옵션 조합으로 리사이징 가능한 최대 넓이, 높이 반환
  199. * @protected
  200. * @param {number} sw
  201. * @param {number} sh
  202. * @returns {ParseMaxSize}
  203. */
  204. protected getMaxSize(sw: number, sh: number): ParseMaxSize {
  205. let maxWidth = this.maxWidth;
  206. let maxHeight = this.maxHeight;
  207. if (this.maxWidth <= 0 && this.maxHeight <= 0) {
  208. maxWidth = sw;
  209. maxHeight = sh;
  210. } else if (this.maxWidth <= 0) {
  211. if (this.resizeType === ResizeType.SCALE_STRETCH) {
  212. maxWidth = sw <= sh ? sw * (this.maxHeight / sh) : this.maxHeight;
  213. } else if (this.resizeType === ResizeType.FIXED) {
  214. maxWidth = sw * (this.maxHeight / sh);
  215. } else {
  216. maxWidth = maxHeight;
  217. }
  218. } else if (this.maxHeight <= 0) {
  219. if (this.resizeType === ResizeType.SCALE_STRETCH) {
  220. maxHeight = sh <= sw ? sh * (this.maxWidth / sw) : this.maxWidth;
  221. } else if (this.resizeType === ResizeType.FIXED) {
  222. maxHeight = sh * (this.maxWidth / sw);
  223. } else {
  224. maxHeight = maxWidth;
  225. }
  226. }
  227. return {
  228. maxWidth: maxWidth,
  229. maxHeight: maxHeight,
  230. };
  231. }
  232. /**
  233. * 이미지 로드 완료
  234. * @protected
  235. */
  236. protected onImageLoaded() {
  237. URL.revokeObjectURL(this.blobURL);
  238. const imageWidth = this.domImage.naturalWidth;
  239. const imageHeight = this.domImage.naturalHeight;
  240. this.draw(imageWidth, imageHeight);
  241. }
  242. async toBufferByBlob(blob: Blob) {
  243. return await toBufferByBlob(blob);
  244. }
  245. /**
  246. * 이미지 orientation 등 설정 정보에 따라 그려져야할 사이즈, 방향 등 반환
  247. * @protected
  248. * @param {number} imageWidth
  249. * @param {number} imageHeight
  250. * @returns {Promise<ParseMetadata>}
  251. */
  252. protected async parseDrawMetadata(
  253. imageWidth: number,
  254. imageHeight: number
  255. ): Promise<ParseMetadata> {
  256. let sw = imageWidth;
  257. let sh = imageHeight;
  258. let orientation = 0;
  259. if (this.applyOrientation === true) {
  260. try {
  261. // const buffer = await this.blob.arrayBuffer();
  262. const buffer = await this.toBufferByBlob(this.blob);
  263. const result = ExifReader.load(buffer);
  264. if (result.Orientation && result.Orientation.value) {
  265. orientation = result.Orientation.value;
  266. }
  267. } catch (err) {
  268. console.error(err);
  269. }
  270. if (4 < orientation) {
  271. sw = imageHeight;
  272. sh = imageWidth;
  273. }
  274. }
  275. return { sw, sh, orientation };
  276. }
  277. /**
  278. * 이미지가 그려져야할 영역 정보 반환
  279. * @protected
  280. * @param {number} sw
  281. * @param {number} sh
  282. * @return {DrawBound}
  283. */
  284. protected parseDrawBound(sw: number, sh: number): DrawBound {
  285. let drawBound: DrawBound;
  286. switch (this.resizeType) {
  287. case ResizeType.COVER:
  288. drawBound = this.getResizeToCover(sw, sh);
  289. break;
  290. case ResizeType.COVER_STRETCH:
  291. drawBound = this.getResizeToCoverStretch(sw, sh);
  292. break;
  293. case ResizeType.SCALE_STRETCH:
  294. drawBound = this.getResizeToScaleStretch(sw, sh);
  295. break;
  296. case ResizeType.SCALE:
  297. drawBound = this.getResizeToScale(sw, sh);
  298. break;
  299. default:
  300. drawBound = this.getResizeToFixed(sw, sh);
  301. break;
  302. }
  303. return drawBound;
  304. }
  305. /**
  306. * 그리기
  307. * @protected
  308. * @param {number} imageWidth
  309. * @param {number} imageHeight
  310. * @returns {Promise<void>}
  311. */
  312. protected async draw(imageWidth: number, imageHeight: number): Promise<void> {
  313. const { sw, sh, orientation } = await this.parseDrawMetadata(imageWidth, imageHeight);
  314. const { dx, dy, dw, dh, mw, mh } = this.parseDrawBound(sw, sh);
  315. const tx = dw + dx * 2;
  316. const ty = dh + dy * 2;
  317. const contentType = this.forceContentType || this.blob.type;
  318. const canvas = this.domCanvas;
  319. const context = this.domCanvasContext;
  320. canvas.width = mw;
  321. canvas.height = mh;
  322. if (this.fillBgColor) {
  323. context.fillStyle = this.fillBgColor;
  324. context.fillRect(0, 0, mw, mh);
  325. }
  326. switch (orientation) {
  327. case 2:
  328. context.translate(tx, 0);
  329. context.scale(-1, 1);
  330. break;
  331. case 3:
  332. context.translate(tx, ty);
  333. context.rotate(Math.PI);
  334. break;
  335. case 4:
  336. context.translate(0, ty);
  337. context.scale(1, -1);
  338. break;
  339. case 5:
  340. context.rotate(Math.PI * 0.5);
  341. context.scale(1, -1);
  342. break;
  343. case 6:
  344. context.rotate(Math.PI * 0.5);
  345. context.translate(0, -tx);
  346. break;
  347. case 7:
  348. context.rotate(Math.PI * 0.5);
  349. context.translate(ty, -tx);
  350. context.scale(-1, 1);
  351. break;
  352. case 8:
  353. context.rotate(Math.PI * -0.5);
  354. context.translate(-ty, 0);
  355. break;
  356. }
  357. if (4 < orientation) {
  358. context.drawImage(this.domImage, 0, 0, sh, sw, dy, dx, dh, dw);
  359. } else {
  360. context.drawImage(this.domImage, 0, 0, sw, sh, dx, dy, dw, dh);
  361. }
  362. this.detectedOrientation = orientation;
  363. // 그리기 완료 (type 이 jpeg 인 경우만 quality 적용이 됨)
  364. canvas.toBlob(this.onResized.bind(this), contentType, this.quality);
  365. }
  366. /**
  367. * 이미지 로드 오류
  368. * @protected
  369. */
  370. protected onImageError() {
  371. URL.revokeObjectURL(this.blobURL);
  372. this.promiseReject({
  373. ...this.getState(),
  374. error: new Error('image load error'),
  375. });
  376. }
  377. /**
  378. * 이미지 리사이징 완료
  379. * @protected
  380. * @param {Blob} resizeBlob
  381. */
  382. protected onResized(resizeBlob: Blob) {
  383. this.resizeBlob = resizeBlob;
  384. this.promiseResolve(this.getState());
  385. }
  386. /**
  387. * 리사이징 이미지 생성하기
  388. * @returns {Promise<ResizeResult>}
  389. */
  390. create(): Promise<ResizeResult> {
  391. this.domCanvas = document.createElement('canvas');
  392. this.domCanvasContext = this.domCanvas.getContext('2d');
  393. this.domImage = new Image();
  394. this.domImage.onload = this.onImageLoaded.bind(this);
  395. this.domImage.onerror = this.onImageError.bind(this);
  396. this.promise = new Promise((resolve, reject) => {
  397. this.promiseResolve = resolve;
  398. this.promiseReject = reject;
  399. try {
  400. this.blobURL = URL.createObjectURL(this.blob);
  401. this.domImage.src = this.blobURL;
  402. } catch (err) {
  403. this.promiseReject({
  404. ...this.getState(),
  405. error: err,
  406. });
  407. }
  408. });
  409. return this.promise;
  410. }
  411. getState(): ResizeResult {
  412. const blob = this.resizeBlob || null;
  413. const { width = 0, height = 0 } = this.domCanvas || {};
  414. const orientation = this.detectedOrientation || 0;
  415. return {
  416. blob: blob,
  417. width: blob ? width : 0,
  418. height: blob ? height : 0,
  419. orientation,
  420. };
  421. }
  422. }