컴포넌트 이미지로 다운로드 구현하기 & 버그 해결

Circlewee·2022년 10월 12일
2

Project

목록 보기
2/2
post-thumbnail

0. 서비스

서비스 소개(링크)

제주일름 서비스에서 제일 중요한 기능 중 하나는 결과 컴포넌트를 이미지로 다운로드 하는 것이다.
처음 구현했을 당시에는 해커톤에서 서비스를 만들었기 때문에 시간이 촉박해서 react-component-export-image 라이브러리를 사용했고 몇 줄의 코드 추가로 원하는 대로 작동했었지만 스타일 관련한 버그가 많았었다.

대표적으로
1. 데스크탑 사이즈와 모바일에서 다운로드 되는 이미지 사이즈가 달랐다.
2. 결과 컴포넌트의 텍스트 위치보다 이미지에서의 텍스트 위치가 달랐다.
3. 모바일 device에 크기에 따라 흰색 박스가 추가되는 경우도 있었다.
이러한 버그들이 존재했다.

해커톤 이후 재 배포를 통해 우리의 결과에 대한 통계를 얻고자 하였고 이에 따라 해당 버그들은 서비스의 제일 중요한 기능이 제대로 동작하지 않게 만들었기 때문에 반드시 수정이 필요하였다.

위 1920*1080, 아래 375*667의 디바이스에서 저장했지만 아래의 이미지 사이즈가 훨씬 크다.

1. 기기별 저장된 이미지 사이즈의 차이 해결

위에 적은 것처럼 어떤 상황에서 버그가 발생하는 지 파악하고 있었기 때문에 깃헙, 구글링을 통해 원인을 찾아보고자 하였다.

1.1 라이브러리 코드

우선 해당 라이브러리의 package.json을 확인해보았다.

  ...
  "dependencies": {
    "html2canvas": "^1.0.0-rc.7",
    "jspdf": "^2.3.1"
  },
  "devDependencies": {
    "@babel/core": "^7.2.2",
    "babel-loader": "^8.0.5",
    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "webpack": "^4.28.4",
    "webpack-bundle-analyzer": "^3.8.0",
    "webpack-cli": "^3.2.1"
  }
}

webpack, babel, react를 제외한 기능에 필요한 라이브러리는 html2canvas, jspdf 두 가지였고 이 중 jspdf는 pdf파일로 export하기위한 라이브러리였기 때문에 자연스레 html2canvas에 눈이 갔다.

그리고 해당 라이브러리의 코드가 index.js에 모두 들어가 있을 정도로 큰 라이브러리가 아니었기 때문에 다운로드를 하는 함수를 찾는 것은 어렵지 않았다.

const exportComponent = (node, {
    fileName, 
    type, 
    html2CanvasOptions, // 사용자가 추가할 수 있다.
    pdfOptions
}) => {
    const element = ReactDOM.findDOMNode(node.current);
    return html2canvas(element, {
        scrollY: -window.scrollY,
        useCORS: true,
        ...html2CanvasOptions // 그리고 그 옵션은 그대로 html2canvas라이브러리에 제공된다.
    }).then(canvas => {
          saveAs(canvas.toDataURL(type, 1.0), fileName);
        }
    });
};

/**
 * @param  {React.RefObject} node
 * @param  {string} fileName='component.png'
 * @param  {object} html2CanvasOptions={}
 */
const exportComponentAsPNG = (node, parameters = {}) => exportComponent(node, {...DEFAULT_PNG, ...parameters});

여기서 확인한 것은 html2Canvas라이브러리가 제공하는 options을 추가할 수 있다는 것 이었기 때문에 html2canvas에 집중하기로 했다.

그리고 라이브러리 제작자가 html2canvas에 매우 의존적이라고 말했다.(링크)

1.2 Window.devicePixelRatio

공식 문서의 Options을 확인해봤지만 어떤 옵션을 사용해야 문제를 해결할 수 있을 지 감이 오지 않아서 라이브러리의 issue를 확인하기 시작했다.

먼저 제일 중요한 사이즈 통일을 위해 scale을 수정해 주었다.
scale option의 기본 값은 window.devicePixelRatio인데 이는 각 device마다 물리적 해상도와 논리적 해상도가 다르기 때문이 이를 비율로 환산해 제공해주는 기본 API이다.

window.devicePixelRatio = (물리적 해상도 / 논리적 해상도)

문제가 되었던 모바일의 경우엔 물리적 해상도는 FHD, QHD급 이지만 기기의 크기가 훨씬 작으므로 논리적 해상도의 개념을 이용해서 화면의 컨텐츠가 크게 보이는 것이다.
따라서 모바일의 논리적 해상도가 물리적 해상도보다 훨씬 낮기 때문에 기본 scale의 값이 1보다 커졌고, 때문에 다운로드된 이미지도 훨씬 컸던 것이다.

1.3 해결

따라서 scale에 로직을 추가했다. (react-component-export-image 라이브러리 사용)

const IMAGE_SIZE = 440;
exportComponentAsPNG(exportImgRef, {
  fileName: `${original}_jejuileum`,
  html2CanvasOptions: { scale: IMAGE_SIZE / exportImgRef.current.offsetWidth },
});

IMAGE_SIZE는 항상 width: 440, height: 440의 크기로 저장되길 원했기 때문에 추가된 값이고,
offsetWidth로 나누는 것은 device마다 결과값으로 출력되는 컴포넌트의 width가 다르기 때문에 나눠준 것이다.

결국 scale에 비율을 넣어주면 항상 동일한 사이즈가 되는 것을 이용한 것이다.


2. 하지만 직접 구현하기

사실 1번만 보면 아직까진 라이브러리를 사용해도 괜찮았다. 하지만 아래 저장된 이미지를 보면... 한숨만 나온다.

내가 파악하기로는 위에서 scale을 강제했기 때문에 device에 따라 안에 있는 스타일이 깨진다고 생각했고 다시 깃헙과 구글을 돌아다니기 시작했다.

2.1 버전이 왜 이래?

1번의 문제를 해결하기위해 html2canvas의 깃헙을 돌아다니면서 꽤나 자주 마주쳤던 문구가 있다.
특히 대부분의 closed된 이슈를 보면 마지막에

제작자가 커밋하면서 이슈를 언급한 부분과 PR을 확인할 수 있다.

분명 나와 매우 유사한 문제의 issue였고 이미 해결되어 closed인데 왜 내꺼에서만 해결되지 않는 것일까라는 의문이 생겼고 react-component-export-imagehtml2canvas 버전을 확인해 본 결과 위에도 적혀있지만 이미 2년전에 나왔던 버전이었다.

해당 라이브러리가 2년 전에 업데이트를 멈췄고 앞으로 더 될일은 없다고 판단해 결국 직접 구현하게 되었다.

2.2 html2canvas

The script allows you to take "screenshots" of webpages or parts of it, directly on the users browser.

먼저 html2canvas에 대해 간단히 설명하자면 사용자의 브라우저에서 웹페이지 또는 웹페이지의 일부를 스크린샷의 형태로 만들어준다고 적혀있다.
여기서 말하는 스크린샷이란 HTMLCanvasElement를 의미하는 것이다.

다시 라이브러리로 돌아가서,

// 라이브러리의 코드
const html2canvas = (element: HTMLElement, options: Partial<Options> = {}): Promise<HTMLCanvasElement> => {
    return renderElement(element, options);
};

우리가 사용할 html2canvas함수는 단순하다. HTMLElement와 options을 인자로 받아 HTMLCanvasElement를 Promise형태로 돌려준다는 것. (비동기)

그리고 renderElement()과정에서 문제가 생긴다면 Promise.reject()한다는 것.

2.3 알아낸 것 적용하기

import html2canvas, { Options } from 'html2canvas';

type DownloadOptions = {
  fileName: string;
  imageQuality?: number;
  canvasOptions?: Partial<Options>;
};

const defaultOptions: DownloadOptions = {
  fileName: 'default',
  imageQuality: 1,
  canvasOptions: {},
};

const IMAGETYPE = 'image/png';

const donwload = (url: string, filename: string) => {}

const downloadToPNG = async (
  element: React.RefObject<HTMLElement> | null,
  options: DownloadOptions
) => {
  if (!element || !element.current) toast.error('결과가 존재하지 않습니다.');

  const { fileName, imageQuality, canvasOptions } = options;
  try {
    const canvas = await html2canvas(element.current, {
      ...defaultOptions,
      ...canvasOptions,
      useCORS: true,
  	});

  	download(canvas.toDataURL(IMAGETYPE, imageQuality), fileName);  
  } catch(err) {
    toast.error(err);
  }
};

우선 html2canvas()는 비동기로 처리되기 때문에 비동기 함수로 만들었다. 그리고 try-catch로 감싸 에러 핸들링을 하였다.
라이브러리를 사용하여 canvas element를 만들고 download라는 별도의 함수를 만들어 저장을 하는 것이 전부다.

다음으로 download 함수를 만들기 전 canvas의 메소드에 대해 살펴보자면 toDataURL이 사용되었는 데 이것은 canvas 이미지를 데이터(문자열)의 형태로 바꿔주는 것이다.
여기서 우리는 PNG를 원하기 때문에 image/png, 해상도는 1을 사용했다.

그리고 download 내부를 살펴보면 단순하다.

const download = (url: string, filename: string) => {
  const linkElement = document.createElement('a');
  linkElement.download = filename;
  linkElement.href = url;
  linkElement.style.display = 'none';

  document.body.appendChild(linkElement);
  linkElement.click();
  document.body.removeChild(linkElement);
};
  1. a태그를 만들고
  2. 파일 이름, 문자열의 형태인 canvas element를 속성에 추가하고
  3. 보이지 않게 한뒤
  4. body에 추가 -> 클릭 -> 제거의 순으로 동작하는 것이다.

2.4 구현 테스트


우선...동작은 한다! 하지만 텍스트가 전체적으로 아래로 처져있다.

3. 텍스트 끌어올리기

아래 부터는 라이브러리 코드에 대한 주관적인 해석이 들어가 있습니다. 제가 스스로 문제를 해결하면서 파악한 부분이기 때문에 제작자의 의도와 다를 수 있습니다. 이점 양해 부탁드립니다.

3.1 라이브러리 코드로 원인 파악하기

이 문제는 사실 css의 문제였다. 정확히는 line-height의 문제였다.
font에 따라서 같은 size여도 높이는 전부 다르다. 이것은 폰트 디자인의 영역이므로 우리는 여러 폰트를 사용했을 때 발생하는 스타일의 어긋남을 제어해야만 한다.

라이브러리에서 텍스트를 렌더링하는 부분을 찾아가봤다. 코드를 찾는 데는 github issue의 도움을 받았다.

// 1.
FontMetrics.prototype.parseMetrics = function (fontFamily, fontSize) {
  var container = this._document.createElement('div');
  var img = this._document.createElement('img');
  var span = this._document.createElement('span');
  var body = this._document.body;
  ...
  container.style.fontFamily = fontFamily;
  container.style.fontSize = fontSize;
  ...
  span.style.fontFamily = fontFamily;
  span.style.fontSize = fontSize;
  ...
  var baseline = img.offsetTop - span.offsetTop + 2;
  container.style.lineHeight = 'normal';
  img.style.verticalAlign = 'super';
  var middle = img.offsetTop - container.offsetTop + 2;

  return { baseline: baseline, middle: middle };
};
// 2.
CanvasRenderer.prototype.renderTextWithLetterSpacing = function (text, letterSpacing, baseline) {
  var _this = this;
  ...
  this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
};

해당 코드가 하는 역할은 font metrics분석을 위해 개발자가 사용한 fontFamily와 fontSize의 정보를 가져와 가짜 container, img, span을 만들어 해당 정보를 추가하고 기본 스타일을 정의하게 된다.

  1. 이후 인자로 받은 fontFamily와 fontSize만 적용된 span을 기준으로 baseline을 계산하고,
  2. CanvasRenderingContext2D.fillText()를 통해 text를 추가하는 방식이다.

여기서부터 나의 예상은 fillText()의 3번째 인수는 y값인데 baseline에 text의 top이 더해진 값을 제공하고 내가 기본 스타일로 설정한 text의 line-height가 더해지면서 아래로 처지게 된게 아닌가 싶다.

그리고 해당 이슈의 위쪽을 살펴보면 많은 종류의 폰트들이 보이는 화면에서와 달리 만들어진 canvas element에서 다르게 보이는 것을 경험한 개발자들이 많았다.

3.2 문제 해결

결국 font의 기본 높이에 불 필요한 line-height가 추가되었기 때문에 텍스트가 아래로 처졌으므로 제거할 필요가 있었다.

다만 global style이나 html에 직접 스타일을 추가할 경우 다른 페이지에서 스타일이 망가질 수도 있고 결과 페이지에 모든 텍스트에 line-height가 추가되어 있었기 때문에 나는 다른 방법을 이용했다.

useLayoutEffect(() => {
  const body = document.querySelector('body');
  if (!body) {
  	// error handling
  };
  
  body.style.lineHeight = '0';
  
  return () => {
    body.style.lineHeight = 'normal';
  };
}, []);

useLayoutEffect를 사용해 컴포넌트가 mount되기 전에 body에 해당 스타일을 적용하고 렌더링하였다.

그리고 해당 페이지를 벗어나게 되면 다시 원상태로 돌려야하기 때문에 initial value인 normal로 돌려주는 코드를 추가했다.

3.3 결과 확인


원하는 대로 저장된 것을 확인할 수 있다.

4. 마무리

github에서 문제를 해결하는 방식이 낯설었지만 최대한 issue안에서 찾아보려고 했다.
그나마 다행이게도 html2canvas 라이브러리는 이미 많은 개발자가 이용하는 라이브러리여서 구글이나 github에서 나와 비슷한 문제들을 찾는 것이 어렵지는 않았다.
앞으로도 이 방식을 많이 사용해야 할 것 같다.

그리고 이번 프로젝트는 디자이너와 협업하는 첫 프로젝트여서 설레기도 했고 걱정되기도 했다.
figma에 디자인을 따라가며 스타일을 구현했지만 안일하게 안드로이드 + 윈도우로 한정된 나의 테스트 기기에서 올바르게 보인다고 다른 기기들에서까지 그러리란 법이 없다는 것을 절실히 깨달을 수 있었다.

나아가 css reset, normalize의 방식을 왜 사용하는지도 느낄 수 있었다.
이번 프로젝트에선 지금 적용하면 다른 스타일이 전부 깨져버리므로 아쉽지만 다음을 기약해야만 할 것 같다.

Other references

profile
공부할 게 너무 많아요

0개의 댓글