[PDF.js] PDF.js 완벽 가이드
PDF.js는 웹에서 PDF 를 다룰 때 필요한 거의 모든 기능을 제공하는 라이브러리로 몇십년 전 부터 현재까지 꾸준히 업데이트 되고 있다.
처음 라이브러리를 사용했던건 3년 전 정도인데.. 그때와 현재를 비교해도 정말 많은 기능이 추가됐다.
뭔가 PDF 관련 기능을 만들어야 할 때 개발하기 전 PDF.js 라이브러리가 제공하는 기능인지 먼저 확인하자.
(https://github.com/mozilla/pdf.js)
npm을 통한 설치 / cdn을 사용해 script 태그로 추가 / 깃허브나 공식사이트에서 다운로드
크게 세 가지 방법으로 초기 세팅을 마치고 필요한 기능이나 디자인을 추가하거나 수정하자.
개발하면서 겪었던 몇 가지 이슈사항을 정리한다.
1. 한글 폰트 관련 문제
PDF.js 는 cmap(character map) 파일들을 브라우저 환경에서 사용할 수 있도록 번들링한 bcmap을 사용한다.
브라우저에서 폰트를 제대로 읽으려면 해당 bcmap 파일을 모두 프로젝트에 추가해 줘야 한다.
(https://github.com/mozilla/pdfjs-dist/blob/master/cmaps/GBK-EUC-H.bcmap)
위의 깃허브 링크에서 bcmap 파일을 모두 다운받고 프로젝트에 추가해주자.
추가한 후에는, 파일을 넣어 준 경로를 PDF.js 라이브러리가 읽을 수 있게 경로를 지정해 줘야 한다.
라이브러리에 포함된 pdf.worker.mjs 파일을 수정하자.
async fetchBuiltInCMap(name) {
const cachedData = this.builtInCMapCache.get(name);
if (cachedData) {
return cachedData;
}
let data;
if (this.options.cMapUrl !== null) {
//const url = `${this.options.cMapUrl}${name}.bcmap`;
const url = `cmap/${name}.bcmap`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`fetchBuiltInCMap: failed to fetch file "${url}" with "${response.statusText}".`);
}
data = {
cMapData: new Uint8Array(await response.arrayBuffer()),
compressionType: CMapCompressionType.BINARY
};
} else {
data = await this.handler.sendWithPromise("FetchBuiltInCMap", {
name
});
}
if (data.compressionType !== CMapCompressionType.NONE) {
this.builtInCMapCache.set(name, data);
}
return data;
}
사용하는 PDF.js 라이브러리의 버전에 따라 조금씩 다를 수 있겠지만..
fetchBuiltInCMap 함수를 찾아 해당 함수에서 bcmap 파일을 불러오는 부분을 수정해주자.
2. 원하는 PDF 파일 읽어오기
PDF.js는 프론트엔드쪽 라이브러리이고, 상용 서비스에서 프론트엔드 프로젝트 내부에 PDF 파일을 포함시켜놓지는 않으니 시스템을 구성할 때 백엔드 서버를 구축하고 백엔드 서버에게 PDF를 요청하는 방식으로 구성된다.
엔드포인트로 접근 시 PDF파일을 HTTP로 전달해주는 api는 만들었다고 하자.
PDF.js에서 해당 PDF 를 불러올 때는 라이브러리의 app.js 또는 viewer.mjs 부분을 조작해야 한다.
해당 js에는 페이지 탐색 / 확대 및 축소 / 북마크 등 다양한 기능이 정의되어있고, async run 메서드에서 PDF 파일을 불러온다.
async run(config) {
await this.initialize(config);
const { appConfig, eventBus } = this;
let file;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
const queryString = document.location.search.substring(1);
const params = parseQueryString(queryString);
file = params.get("file") ?? AppOptions.get("defaultUrl");
validateFileURL(file);
} else if (PDFJSDev.test("MOZCENTRAL")) {
file = window.location.href;
} else if (PDFJSDev.test("CHROME")) {
file = AppOptions.get("defaultUrl");
}
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
const fileInput = (this._openFileInput = document.createElement("input"));
fileInput.id = "fileInput";
fileInput.hidden = true;
fileInput.type = "file";
fileInput.value = null;
document.body.append(fileInput);
fileInput.addEventListener("change", function (evt) {
const { files } = evt.target;
if (!files || files.length === 0) {
return;
}
eventBus.dispatch("fileinputchange", {
source: this,
fileInput: evt.target,
});
});
// Enable dragging-and-dropping a new PDF file onto the viewerContainer.
appConfig.mainContainer.addEventListener("dragover", function (evt) {
for (const item of evt.dataTransfer.items) {
if (item.type === "application/pdf") {
evt.dataTransfer.dropEffect =
evt.dataTransfer.effectAllowed === "copy" ? "copy" : "move";
evt.preventDefault();
evt.stopPropagation();
return;
}
}
});
appConfig.mainContainer.addEventListener("drop", function (evt) {
if (evt.dataTransfer.files?.[0].type !== "application/pdf") {
return;
}
evt.preventDefault();
evt.stopPropagation();
eventBus.dispatch("fileinputchange", {
source: this,
fileInput: evt.dataTransfer,
});
});
}
if (!AppOptions.get("supportsDocumentFonts")) {
AppOptions.set("disableFontFace", true);
this.l10n.get("pdfjs-web-fonts-disabled").then(msg => {
console.warn(msg);
});
}
if (!this.supportsPrinting) {
appConfig.toolbar?.print?.classList.add("hidden");
appConfig.secondaryToolbar?.printButton.classList.add("hidden");
}
if (!this.supportsFullscreen) {
appConfig.secondaryToolbar?.presentationModeButton.classList.add(
"hidden"
);
}
if (this.supportsIntegratedFind) {
appConfig.findBar?.toggleButton?.classList.add("hidden");
}
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
if (file) {
this.open({ url: file });
} else {
this._hideViewBookmark();
}
} else if (PDFJSDev.test("MOZCENTRAL || CHROME")) {
this.setTitleUsingUrl(file, /* downloadUrl = */ file);
this.externalServices.initPassiveLoading();
} else {
throw new Error("Not implemented: run");
}
},
////
this.open({
url: `/api/pdf?fldrid=${fldrId}&filepath=${filePath}&filename=${fileName}`
});
여기서 url을 지정하는 부분을 쿼리스트링으로 받아온 데이터를 페이로드로, 구축한 api에 요청하면, 응답으로 받아온 PDF 파일을 PDF.js 내장 뷰어에서 보여준다.
3. 논리페이지와 물리페이지 다루기
개발 요청사항 중 PDF 내부적으로 관리하는 페이지인 논리페이지와 1부터 시작하는 물리페이지를 뷰어에서 함께 보여달라는 요청사항이 있었다.
viewer.mjs 또는 pdf_thumbnail_view.js 를 조작해 논리페이지와 물리페이지를 모두 보여주자.
class PDFThumbnailView {
constructor({
container,
eventBus,
id,
logicalId,
defaultViewport,
optionalContentConfigPromise,
linkService,
renderingQueue,
pageColors
}) {
this.id = id;
this.logicalId = logicalId;
this.renderingId = "thumbnail" + id;
this.pageLabel = null;
this.pdfPage = null;
this.rotation = 0;
this.viewport = defaultViewport;
this.pdfPageRotate = defaultViewport.rotation;
this._optionalContentConfigPromise = optionalContentConfigPromise || null;
this.pageColors = pageColors || null;
this.eventBus = eventBus;
this.linkService = linkService;
this.renderingQueue = renderingQueue;
this.renderTask = null;
this.renderingState = RenderingStates.INITIAL;
this.resume = null;
const anchor = document.createElement("a");
anchor.href = linkService.getAnchorUrl("#page=" + id);
anchor.setAttribute("data-l10n-id", "pdfjs-thumb-page-title");
anchor.setAttribute("data-l10n-args", this.#pageL10nArgs);
anchor.onclick = function () {
linkService.goToPage(id);
return false;
};
this.anchor = anchor;
const div = document.createElement("div");
div.className = "thumbnail";
div.setAttribute("data-page-number", this.id);
this.div = div;
this.#updateDims();
const img = document.createElement("div");
img.className = "thumbnailImage";
this._placeholderImg = img;
div.append(img);
const div2 = document.createElement("div");
const span3 = document.createElement("span");
span3.style.fontSize = "13px";
span3.style.color = "black";
div2.style.textAlign = "center";
span3.textContent = this.logicalId ? this.logicalId : this.id;
div2.append(span3);
div.append(div2);
anchor.append(div);
container.append(anchor);
}
}
먼저 생성자에서 논리페이지를 보여줄 logicalId 변수를 설정해주자.
setDocument(pdfDocument) {
if (this.pdfDocument) {
this.#cancelRendering();
this.#resetView();
}
this.pdfDocument = pdfDocument;
if (!pdfDocument) {
return;
}
const pageLabelsPromise = pdfDocument.getPageLabels();
const firstPagePromise = pdfDocument.getPage(1);
const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({
intent: "display"
});
Promise.all([firstPagePromise, pageLabelsPromise]).then(([firstPdfPage, pageLabels]) => {
const pagesCount = pdfDocument.numPages;
const viewport = firstPdfPage.getViewport({
scale: 1
});
for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) {
let pageLabel = pageLabels ? pageLabels[pageNum - 1] : pageNum;
const thumbnail = new PDFThumbnailView({
container: this.container,
eventBus: this.eventBus,
logicalId: pageLabel,
id: pageNum,
defaultViewport: viewport.clone(),
optionalContentConfigPromise,
linkService: this.linkService,
renderingQueue: this.renderingQueue,
pageColors: this.pageColors
});
this._thumbnails.push(thumbnail);
}
this._thumbnails[0]?.setPdfPage(firstPdfPage);
const thumbnailView = this._thumbnails[this._currentPageNumber - 1];
thumbnailView.div.classList.add(THUMBNAIL_SELECTED_CLASS);
}).catch(reason => {
console.error("Unable to initialize thumbnail viewer", reason);
});
}
이후 setDocument 메서드에서 pdfDocument 객체에서 관리되고 있는 논리페이지를 가져와서 설정해준다.
백엔드 단에서 처리될 문제이지, PDF.js 라이브러리에서 처리할 문제는 아니지만..
혹시 PDF 를 분할하는 경우 논리페이지와 물리페이지를 잘 구분해서 분할해야 한다.
Map을 설정해 해당 물리페이지에 해당하는 논리페이지를 지정해 두고 (해당하는 논리페이지가 없다면 기본값 사용)
PDF 분할 시 설정해 둔 Map을 사용하자.
4. 뷰어 페이지 내 드래그 구현
OCR 기능을 구현하기 위해 Ctrl + 드래그 시 OCR을 수행할 영역을 지정하는 기능을 만들어보자.
class DragArea {
constructor() {
this.currentDragArea = null;
this.currentCoords = null;
this.onDragStart();
this.onMouseLeave();
}
onMouseLeave() {
document.body.onmouseleave = () => {
document.body.onmousemove = null;
};
}
onDragStart() {
document.body.onmousedown = (e) => {
if (e.ctrlKey) {
e.preventDefault();
if (this.currentDragArea) this.currentDragArea.remove();
const dragStartLeft = e.pageX;
const dragStartTop = e.pageY;
const dragArea = document.createElement("div");
dragArea.className = "drag-area";
document.body.appendChild(dragArea);
this.currentDragArea = dragArea;
this.onDrag(dragArea, dragStartLeft, dragStartTop);
this.onDragEnd();
}
};
}
onDrag(box, dragStartLeft, dragStartTop) {
document.body.onmousemove = (e) => {
e.preventDefault();
const x = e.pageX;
const y = e.pageY;
const left = x < dragStartLeft ? x : dragStartLeft;
const top = y < dragStartTop ? y : dragStartTop;
const width = Math.abs(dragStartLeft - x);
const height = Math.abs(dragStartTop - y);
box.style.transform = `translate(${left}px, ${top}px) scale(${width}, ${height})`;
this.currentCoords = { left, top, width, height };
};
}
onDragEnd() {
document.body.onmouseup = (e) => {
document.body.onmousemove = null;
};
}
}
스타일은 적당히 설정해주고..
'Solutions' 카테고리의 다른 글
[Nginx] 리버스 프록시 서버 구축 (1) | 2024.11.13 |
---|---|
[Tomcat] 네트워크 드라이브 권한 관련 오류 (0) | 2024.11.09 |
[Spring Batch] 메타데이터 테이블과 시퀀스 (0) | 2024.11.05 |
[Spring Security] 인증 실패 오류 다루기 (0) | 2024.06.20 |
[SQL Server] 지원하지 않는 TLS 버전 설정 (1) | 2024.06.05 |
댓글
이 글 공유하기
다른 글
-
[Nginx] 리버스 프록시 서버 구축
[Nginx] 리버스 프록시 서버 구축
2024.11.13 -
[Tomcat] 네트워크 드라이브 권한 관련 오류
[Tomcat] 네트워크 드라이브 권한 관련 오류
2024.11.09 -
[Spring Batch] 메타데이터 테이블과 시퀀스
[Spring Batch] 메타데이터 테이블과 시퀀스
2024.11.05 -
[Spring Security] 인증 실패 오류 다루기
[Spring Security] 인증 실패 오류 다루기
2024.06.20