웹에서 PDF 뷰어 연동하기(1): pdf.js & pdfjs-dist

Jimin Lee·2022년 7월 3일
4

pdfjs

목록 보기
1/2


React에서 PDF 뷰어 처리하는 방법을 소개하고자 한다. 구현 방법이 궁금하면 pdfjs-dist부터



직접 연동하기

iframe

가장 간단한 방법으로 iframe을 사용하는 방법이 있다. 단순하게 pdf를 볼 때 유용하다.

const renderPDF = (
    <PreviewModal>
      <iframe
        src={"/preview-test/ninetree.pdf"}
        className={styles.pdfViewer}
      ></iframe>
    </PreviewModal>
  );

IE를 제외하고는 정상 작동

embed

iframe과 동일하게 작동한다.
type을 추가해야 한다는 점이 특이하다.

const renderPDF = (
    <PreviewModal>
      <embed src={"/preview-test/ninetree.pdf"} type="application/pdf" 
        className={styles.pdfViewer} />
    </PreviewModal>
  );



라이브러리 사용하기

뷰어 커스터마이징을 위해 라이브러리를 사용해보자. 다양한 라이브러리가 존재하나 주로 언급되는 3가지 라이브러리에 대해 알아보고자 한다. 포스팅이 길어져서 react-pdf는 투비컨티뉴

  • pdf.js
  • pdfjs-dist
  • react-pdf



pdf.js

https://github.com/mozilla/pdf.js
mozila에서 제공하는 PDF 뷰어이다. 많은 pdf뷰어 라이브러리들의 조상님이다.

설치하기

pdf.js는 오픈 소스로 mozila 레포에서 클론을 통해 다운받을 수 있다.
하지만 웹개발(리액트)에서 사용하기 위해선 pdf.js의 generic build와 viewer가 필요하다.

빌드하는 방법은
1) pdf.js를 깃 클론하여 직접 빌드하는 방법과
2) prebuilt된 소스를 사용하는 방법이 있다.
npm을 이용해서 다운받고 싶다면 아래 pdfjs-dist를 참고하자.

1) pdf.js를 직접 다운&빌드하기

깃 레포에서 pdf.js를 클론

$ git clone https://github.com/mozilla/pdf.js.git
$ cd pdf.js

gulp package를 global로 설치

$ npm install -g gulp-cli

PDF.js안에 dependencies를 설치

$ npm install

/src 파일을 번들링하여 generic viewer를 빌드

$ gulp generic

위에서 빌드된 pdf.js/build/generic 폴더 내용물을 내 플젝의 /public 폴더에 옮기기

2) prebuilt 사용하기

위 방법으로도 가능하지만 초큼 귀찮다. 더 간단한 방법은 pdfjs-dist를 사용하는 방법이 있다.

아래 링크에서는 prebuilt 파일을 다운로드할 수 있다.
https://mozilla.github.io/pdf.js/getting_started/#download
다운받은 후 마찬가지로 /public에 옮겨주면 된다.



pdf.js 살펴보기

레이어 구성

레이어설명
Corebinary PDF를 파싱하고 해석한다. 다른 레이어들의 근본-이다.
Display코어 레이어를 사용하여 PDF를 랜더링하고 문서에서 다른 정보를 가져오기 위한 api를 제공한다.
Viewerdisplay 레이어에 내장되어 있는 PDF 뷰어 UI이다.

각 파일의 역할



pdfjs-dist

위에서 언급한 pdf.js의 generic build 버전이다. pdf.js와 작동방식은 동일하다.

설치하기

https://github.com/mozilla/pdf.js/wiki/Setup-pdf.js-in-a-website

npm install pdfjs-dist --save





구현

구현하는 방법은 크게 두 단계로 구성된다.
1. pdf.js를 사용해서 pdf 파일을 읽어오기
2. 1을 canvas를 사용해서 그리기



1. import 라이브러리

사실 JS로 짠다면 예제를 따라하면 제대로 동작할 것이다. (안해봄)

하지만 React ts를 사용하는 경우 import할 때 부터 문제가 발생한다.

@types/pdfjs-dist를 설치하면 좋겠으나... @types/pdfjs-dist는 mozila 공식 지원 라이브러리가 아닌 관계로! 직접 .d.ts를 추가해준다.

이제 import 해보자.

import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry";

pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;

// cdn에서 불러와도 된다.
// pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.js`;

번거롭지만 workerSrc는 무조건 정의해야한다.안하면 에러남;



2. pdf 읽어오기

PDF.js는 기본적으로 Promises 기반이다. 자세한 api 설명을 알고 싶다면 api docs를 참고한다.

const getPDF = useCallback(async () => {
    const loadingTask = pdfjsLib.getDocument("./Master_the_Interview.pdf");

    try {
      const doc = await loadingTask.promise;
      console.log("pdf document 로딩 성공");
      const currentPage = await doc.getPage(page);
      console.log(`${page}로딩 성공`);
      const viewport = currentPage.getViewport({ scale: 1.5 }); // each pdf has its own viewport


      // canvas 그리기
      const context = drawCanvas({
        width: viewport.width,
        height: viewport.height,
      });

      const renderContext = {
        canvasContext: context,
        viewport: viewport,
      };

      await currentPage.render(renderContext).promise;
      console.log("pdf 로딩 성공이라네");
    } catch (e) {
      console.log("pdf 로딩 실패!");
      console.log(e);
    }
  }, [drawCanvas]);



3. canvas 그리기

  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [page, setPage] = useState<number>(1);

  const drawCanvas = useCallback(
    ({ width, height }: CanvasProps) => {
      if (!canvasRef.current) {
        throw new Error("canvasRef가 없음");
      }
      const canvas: HTMLCanvasElement = canvasRef.current;
      canvas.width = width;
      canvas.height = height;

      const context = canvas.getContext("2d");
      if (context) {
        return context;
      } else {
        throw new Error("canvas context가 없음");
      }
    },
    [canvasRef]
  );
  
  //...
  return (
    <div>
      <canvas ref={canvasRef} style={{ height: "100vh" }} />
    </div>
  );
  



Single Page rendering

import React, { useCallback, useEffect, useRef, useState } from "react";
import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry";
import { PDFDocumentProxy } from "pdfjs-dist";
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;

type CanvasProps = {
  width: number;
  height: number;
};

type PDFViewerProps = {
  pdfPath: string;
};

const PDFViewer = ({ pdfPath }: PDFViewerProps) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [page, setPage] = useState<number>(1);

  const getFileURL = (path: string) => {};

  const drawCanvas = useCallback(
    ({ width, height }: CanvasProps) => {
      if (!canvasRef.current) {
        throw new Error("canvasRef가 없음");
      }
      const canvas: HTMLCanvasElement = canvasRef.current;
      canvas.width = width;
      canvas.height = height;

      const context = canvas.getContext("2d");
      if (context) {
        console.log("contex 생성 성공!");
        return context;
      } else {
        throw new Error("canvas context가 없음");
      }
    },
    [canvasRef]
  );

  const renderPage = useCallback(
    async (doc: PDFDocumentProxy) => {
      const currentPage = await doc.getPage(page);
      const viewport = currentPage.getViewport({ scale: 1.0 }); // each pdf has its own viewport
      const context = drawCanvas({
        width: viewport.width,
        height: viewport.height,
      });

      const renderContext = {
        canvasContext: context,
        viewport: viewport,
      };

      await currentPage.render(renderContext).promise;
      console.log(`${page}로딩 성공`);
    },
    [page, drawCanvas]
  );

  const getPDF = useCallback(
    async (pdfPath: string) => {
      try {
        const loadingTask = pdfjsLib.getDocument(pdfPath);
        const doc = await loadingTask.promise;

        const pageNum = doc.numPages;
        console.log(`document 로딩 성공: 전체 페이지 ${pageNum}`);

        renderPage(doc);
        console.log("pdf 로딩 성공이라네");
      } catch (e) {
        console.log("pdf 로딩 실패!");
        console.log(e);
      }
    },
    [renderPage]
  );

  useEffect(() => {
    getPDF(pdfPath);
  }, [pdfPath]);

  return (
    <div>
      <canvas ref={canvasRef} style={{ height: "100vh" }} />
    </div>
  );
};

export default PDFViewer;



Multiple Pages rendering

Page마다 분리하여 각각 렌더링했다.
Page.tsx

import { PDFDocumentProxy } from "pdfjs-dist";
import React, { useCallback, useEffect, useRef } from "react";

type CanvasProps = {
  width: number;
  height: number;
};

type PageProps = {
  doc: PDFDocumentProxy;
  page: number;
  scale?: number;
};

const Page = ({ page, doc, scale = 1 }: PageProps) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const drawCanvas = useCallback(
    ({ width, height }: CanvasProps) => {
      if (!canvasRef.current) {
        throw new Error("canvasRef가 없음");
      }
      const canvas: HTMLCanvasElement = canvasRef.current;
      canvas.width = width;
      canvas.height = height;

      const context = canvas.getContext("2d");
      if (context) {
        return context;
      } else {
        throw new Error("canvas context가 없음");
      }
    },
    [canvasRef]
  );

  const renderPage = useCallback(async () => {
    try {
      const currentPage = await doc.getPage(page);
      const viewport = currentPage.getViewport({ scale }); // each pdf has its own viewport
      const context = drawCanvas({
        width: viewport.width,
        height: viewport.height,
      });

      const renderContext = {
        canvasContext: context,
        viewport: viewport,
      };
      await currentPage.render(renderContext).promise;
    } catch (e) {
      throw new Error(`${page}번째 페이지 로딩 실패`);
    }
  }, [doc, page, scale, drawCanvas]);

  useEffect(() => {
    renderPage();
  }, [renderPage]);

  return <canvas ref={canvasRef} style={{ margin: "5px auto" }} width={800} />;
};

export default Page;




PDFViewer.tsx

import React, { useCallback, useEffect, useState } from "react";
import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry";
import Page from "./Page";
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;

type PDFViewerProps = {
  pdfPath: string;
};

const PDFViewer = ({ pdfPath }: PDFViewerProps) => {
  const [pages, setPages] = useState<JSX.Element[]>([]);
  const [total, setTotal] = useState<number>(0);
  const [error, setError] = useState<boolean>(false);
  const [scale, setScale] = useState<number>(1);

  const onLoadSuccess = () => {
    console.log(`pdf 로딩 성공`);
    setError(false);
  };

  const onLoadFail = (e: any) => {
    console.log(`pdf 로딩 실패!: ${e}`);
    setError(true);
  };

  const renderPDF = useCallback(
    async (pdfPath: string) => {
      try {
        const loadingTask = pdfjsLib.getDocument(pdfPath);
        const doc = await loadingTask.promise;

        const totalPage = doc.numPages;
        setTotal(totalPage);

        if (totalPage === 0) {
          throw new Error(`전체 페이지가 0`);
        }

        const pageArr = Array.from(Array(totalPage + 1).keys()).slice(1);
        const allPages = pageArr.map((i) => (
          <Page doc={doc} page={i} key={i} scale={scale} />
        ));
        setPages(allPages);

        onLoadSuccess();
      } catch (e) {
        onLoadFail(e);
      }
    },
    [scale]
  );

  useEffect(() => {
    renderPDF(pdfPath);
  }, [pdfPath, scale]);

  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        overflow: "scroll",
      }}
      id="canvas-scroll"
    >
      {pages}
      {error && (
        <div style={{ height: "100%", margin: "5px auto" }}>
          pdf 로딩에 실패했습니다.
        </div>
      )}
      <div> total: {total}</div>
      <button onClick={() => setScale(scale + 0.5)}>+</button>
      <button onClick={() => setScale(scale - 0.5)}>-</button>
    </div>
  );
};

export default PDFViewer;





InvalidException

파일에 따라 InvalidPdfException이 발생한다. 정말 성가시다 🤦‍♀️

해결: document를 가져올 때 URL을 이용하는 방법 대신 pdf파일을 base64로 인코딩한 데이터를 사용한다.

heap out of memory


https://github.com/mozilla/pdf.js/issues/11468
나만 있는 오류가 아닌 거 같다. 버전 문제 같긴 한데 버전을 (1.8로) 낮추자니 type이나 api가 묘하게 다르다. 해결법은 workerSrc를 pdf.worker.entry에서 받아오지 않고 cdn에서 받아오는 것이다ㅠ

pdfjsLib.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.js`;



참고 자료

0개의 댓글