React에서 PDF 뷰어 처리하는 방법을 소개하고자 한다. 구현 방법이 궁금하면 pdfjs-dist부터
가장 간단한 방법으로 iframe을 사용하는 방법이 있다. 단순하게 pdf를 볼 때 유용하다.
const renderPDF = (
<PreviewModal>
<iframe
src={"/preview-test/ninetree.pdf"}
className={styles.pdfViewer}
></iframe>
</PreviewModal>
);
IE를 제외하고는 정상 작동
iframe과 동일하게 작동한다.
type을 추가해야 한다는 점이 특이하다.
const renderPDF = (
<PreviewModal>
<embed src={"/preview-test/ninetree.pdf"} type="application/pdf"
className={styles.pdfViewer} />
</PreviewModal>
);
뷰어 커스터마이징을 위해 라이브러리를 사용해보자. 다양한 라이브러리가 존재하나 주로 언급되는 3가지 라이브러리에 대해 알아보고자 한다. 포스팅이 길어져서 react-pdf는 투비컨티뉴
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를 참고하자.
깃 레포에서 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 폴더에 옮기기
위 방법으로도 가능하지만 초큼 귀찮다. 더 간단한 방법은 pdfjs-dist를 사용하는 방법이 있다.
아래 링크에서는 prebuilt 파일을 다운로드할 수 있다.
https://mozilla.github.io/pdf.js/getting_started/#download
다운받은 후 마찬가지로 /public에 옮겨주면 된다.
레이어 | 설명 |
---|---|
Core | binary PDF를 파싱하고 해석한다. 다른 레이어들의 근본-이다. |
Display | 코어 레이어를 사용하여 PDF를 랜더링하고 문서에서 다른 정보를 가져오기 위한 api를 제공한다. |
Viewer | display 레이어에 내장되어 있는 PDF 뷰어 UI이다. |
위에서 언급한 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를 사용해서 그리기
사실 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는 무조건 정의해야한다.안하면 에러남;
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]);
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>
);
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;
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;
파일에 따라 InvalidPdfException이 발생한다. 정말 성가시다 🤦♀️
해결: document를 가져올 때 URL을 이용하는 방법 대신 pdf파일을 base64로 인코딩한 데이터를 사용한다.
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`;
https://github.com/mozilla/pdf.js
mozila에서 제공하는 공식 문서
https://github.com/mozilla/pdf.js/tree/master/examples
mozila에서 제공하는 만큼 예시가 꽤나 많다. learning 폴더에 들어가면 기초 예제를 볼 수 있다.
http://mozilla.github.io/pdf.js/examples/
기초 예제에 대한 자세한 설명
https://github.com/mozilla/pdf.js/wiki/Setup-pdf.js-in-a-website
web application에서 pdf.js 사용하기
https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html
pdfjsLib의 api 설명