티스토리 뷰

카테고리 없음

PDF.js 적용

코딩애벌레 2026. 5. 4. 23:54

🔥 Understanding PDF.js Layers and How to Use them in React.js

PDF.js 레이어 개요

본격적으로 시작하기 전에 PDF.js의 네 가지 주요 레이어에 대한 간략한 개요를 살펴보겠습니다. 각 레이어는 렌더링 또는 사용자 상호 작용의 특정 측면을 처리합니다.

  1. 캔버스 레이어
    이 레이어는 PDF의 핵심 시각적 요소인 도형, 이미지, 텍스트를 그래픽으로 그립니다. 이 레이어는 PDF 뷰어의 기본 구조를 이룹니다.
  2. 텍스트 레이어
    캔버스 위에 배치되는 이 레이어를 통해 사용자는 텍스트를 선택하고 검색할 수 있습니다.
  3. 주석 레이어
    이 레이어는 링크, 양식 필드, 강조 표시와 같은 대화형 항목을 관리하므로 사용자는 클릭하거나 정보를 입력하거나 문서 내에서 이동할 수 있습니다.
  4. 구조 레이어
    이 레이어는 모든 레이어의 레이아웃, 정렬 및 크기 조정을 처리하여 전체적인 질서를 유지합니다.

레이어들이 서로 어떻게 상호작용하는가

  1. 렌더링 과정
    각 페이지는 먼저 캔버스 레이어텍스트 레이어가 주석 레이어가 (시각적 콘텐츠) 에 그려집니다 . 그 다음 선택 및 검색 기능을 제공하기 위해 위에 추가됩니다. 마지막으로 링크 및 폼 필드와 같은 상호 작용 요소를 제공하는 맨 위에 배치됩니다.
  2. 상호 작용
    주석 레이어는 사용자 이벤트(링크 클릭 또는 양식 필드 입력 등)를 처리하고, 텍스트 레이어는 텍스트를 강조 표시, 복사 또는 검색할 수 있도록 합니다.
  3. PDF 뷰어
    구조 레이어는 모든 요소를 통합하여 확대/축소, 크기 조정 및 페이지 간 탐색 시 일관성을 유지합니다. 모든 레이어를 정렬하고 반응형으로 유지하여 사용자에게 완성도 높은 보기 환경을 제공합니다.

---

 

PDF.js 커스텀 뷰어에서 Layer 구조 이해하기

1. 왜 viewer.html 대신 직접 구현했는가?

PDF.js는 기본적으로 viewer.html이라는 완성형 PDF 뷰어를 제공합니다.

viewer.html을 사용하면 다음 기능을 빠르게 사용할 수 있습니다.

- PDF 렌더링
- 페이지 이동
- 확대 / 축소
- 검색
- 다운로드
- 인쇄
- 썸네일
 

하지만 서비스에 맞는 PDF 뷰어를 만들려면 기본 UI만으로는 부족할 수 있습니다.

이번 구현에서는 다음 이유로 viewer.html을 사용하지 않고, PDF.js를 직접 사용했습니다.

- 서비스 전용 상단 툴바가 필요함
- 회차 선택 드롭다운이 필요함
- 검색 UI를 직접 제어해야 함
- 현재 페이지 상태를 React 상태와 연결해야 함
- 줌 인 / 줌 아웃 시 위치 보정이 필요함
- TextLayer 선택 동작을 직접 다뤄야 함
 

즉, 이번 구현의 목적은 단순히 PDF를 보여주는 것이 아니라, 서비스 UI 안에서 동작하는 커스텀 PDF 뷰어를 만드는 것입니다.


2. 전체 구조 요약

이번 PDF 뷰어는 크게 다음 구조로 동작합니다.

PdfViewer
 ├─ 상단 툴바
 │   ├─ 회차 선택
 │   ├─ 페이지 이동
 │   ├─ 줌 인 / 줌 아웃
 │   └─ 검색
 │
 └─ PDF 렌더링 영역
     ├─ scrollAreaRef
     └─ hostRef
         ├─ page wrap
         │   ├─ canvas
         │   └─ textRoot
         ├─ page wrap
         │   ├─ canvas
         │   └─ textRoot
         └─ ...
 

코드상 핵심 ref는 다음 두 개입니다.

 
const hostRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
 
Ref역할
scrollAreaRef PDF 전체를 감싸는 스크롤 컨테이너
hostRef 실제 PDF 페이지 DOM들이 들어가는 영역

렌더링된 DOM 구조는 대략 다음과 같습니다.

scrollAreaRef
 └─ hostRef
     └─ page wrap
         ├─ canvas
         └─ textRoot
 

3. PDF.js Layer 구조

PDF.js 기반 뷰어는 보통 여러 레이어를 겹쳐서 구성합니다.

PDF Page Wrapper
 ├─ Canvas Layer
 ├─ Text Layer
 └─ Annotation Layer
 

이번 구현에서는 주로 다음 3가지 역할을 직접 구성했습니다.

구분코드상 요소역할
Structural Layer wrap, hostRef, scrollAreaRef 페이지 크기, 스크롤, 줌 기준 관리
Canvas Layer canvas PDF 화면 렌더링
Text Layer textRoot, TextLayer 텍스트 선택, 검색 위치 계산

현재 코드에는 Annotation Layer는 아직 포함되어 있지 않습니다.


4. PDF.js Worker 설정

PDF.js는 PDF 파싱과 렌더링 준비 작업을 Worker에서 처리합니다.

Vite 환경에서는 worker 파일을 URL로 import해서 설정합니다.

 
import { getDocument, GlobalWorkerOptions, TextLayer, Util } from "pdfjs-dist";
import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist";
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";

GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
 

핵심은 이 부분입니다.

 
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
 

이 설정이 없으면 PDF.js가 worker 파일을 찾지 못해 렌더링 오류가 발생할 수 있습니다.


5. PDF 문서 로드 흐름

이번 구현에서는 백엔드에서 PDF 파일을 base64 문자열로 받아온 뒤, PDF.js가 읽을 수 있는 바이트 배열로 변환합니다.

 
const response = await fetchPdfFileContentByRound(projectCode, roundCode);

const binary = atob(response.fileContent);
const bytes = new Uint8Array(binary.length);

for (let i = 0; i < binary.length; i++) {
  bytes[i] = binary.charCodeAt(i);
}

const buffer = bytes.buffer;

loadingTask = getDocument({ data: buffer });
const doc = await loadingTask.promise;

setPdfDoc(doc);
 

흐름은 다음과 같습니다.

fetchPdfFileContentByRound()
→ PDF 파일을 base64 문자열로 가져옴

atob()
→ base64 문자열을 binary 문자열로 변환

Uint8Array
→ PDF.js가 읽을 수 있는 바이트 배열 생성

getDocument({ data: buffer })
→ PDF.js 문서 객체 로드

setPdfDoc(doc)
→ React 상태에 PDF 문서 저장
 

pdfDoc이 설정되면 페이지 렌더링 effect가 실행됩니다.


6. 회차 변경 시 PDF 다시 로드하기

회차가 바뀌면 기존 PDF DOM을 제거하고 새 PDF를 다시 로드합니다.

 
useEffect(() => {
  if (!roundCode) return;

  let cancelled = false;
  let loadingTask: ReturnType<typeof getDocument> | null = null;

  const loadPdf = async () => {
    try {
      setStatus("loading");
      setErrorMessage(null);
      setPdfDoc(null);

      hostRef.current?.replaceChildren();

      const response = await fetchPdfFileContentByRound(projectCode, roundCode);

      // base64 → Uint8Array 변환
      const binary = atob(response.fileContent);
      const bytes = new Uint8Array(binary.length);

      for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
      }

      const buffer = bytes.buffer;

      if (cancelled) return;

      loadingTask = getDocument({ data: buffer });
      const doc = await loadingTask.promise;

      if (cancelled) {
        void doc.destroy();
        return;
      }

      setPdfDoc(doc);
      resetPageToFirst();
      setStatus("ready");
    } catch (error) {
      if (cancelled) return;

      hostRef.current?.replaceChildren();
      setPdfDoc(null);
      setStatus("error");
      setErrorMessage(
        error instanceof Error ? error.message : "PDF 로드 실패",
      );
    }
  };

  void loadPdf();

  return () => {
    cancelled = true;
    void loadingTask?.destroy();
  };
}, [projectCode, resetPageToFirst, roundCode]);
 

여기서 중요한 부분은 cleanup입니다.

 
return () => {
  cancelled = true;
  void loadingTask?.destroy();
};
 

회차가 빠르게 바뀌거나 컴포넌트가 사라질 때, 이전 PDF 로딩 작업이 남아 있으면 불필요한 렌더링이나 메모리 누수가 생길 수 있습니다.


7. Structural Layer: 페이지 wrapper 만들기

PDF의 각 페이지는 wrap이라는 div로 감싸집니다.

 
const wrap = document.createElement("div");

wrap.dataset.pageWrap = String(p);
wrap.dataset.baseWidth = String(viewport.width);
wrap.dataset.baseHeight = String(viewport.height);

wrap.style.width = `${viewport.width * initialRatio}px`;
wrap.style.height = `${viewport.height * initialRatio}px`;
wrap.style.position = "relative";

wrap.className = "mx-auto mb-4 last:mb-0";
 

wrap은 단순한 div가 아니라, PDF 뷰어에서 중요한 기준점 역할을 합니다.

wrap의 역할
- 한 페이지의 기준 좌표가 됨
- Canvas Layer와 Text Layer의 부모가 됨
- 현재 페이지 계산 기준이 됨
- 검색 결과 스크롤 이동 기준이 됨
- 줌 인 / 줌 아웃 시 크기 변경 대상이 됨
 

DOM 구조는 다음과 같습니다.

wrap
 ├─ canvas
 └─ textRoot
 

canvas와 textRoot가 같은 부모 안에 있어야 두 레이어의 위치를 맞추기 쉽습니다.


8. Canvas Layer: PDF 화면 렌더링

Canvas Layer는 사용자가 실제로 보는 PDF 화면을 그리는 레이어입니다.

 
const canvas = document.createElement("canvas");

canvas.width = Math.floor(viewport.width * pixelRatio);
canvas.height = Math.floor(viewport.height * pixelRatio);

canvas.style.width = `${viewport.width * initialRatio}px`;
canvas.style.height = `${viewport.height * initialRatio}px`;

canvas.className =
  "relative z-0 block bg-white rounded-sm shadow-[0_1px_6px_rgba(0,0,0,0.08)]";
 

실제 렌더링은 page.render()로 수행합니다.

 
await page.render({
  canvas,
  viewport,
  transform:
    pixelRatio !== 1
      ? [pixelRatio, 0, 0, pixelRatio, 0, 0]
      : undefined,
}).promise;
 

각 값의 의미는 다음과 같습니다.

값설명
viewport PDF 페이지의 크기와 좌표계
canvas.width 실제 렌더링 픽셀 너비
canvas.height 실제 렌더링 픽셀 높이
canvas.style.width 화면에 표시되는 CSS 너비
canvas.style.height 화면에 표시되는 CSS 높이
pixelRatio 고해상도 화면에서 흐릿함을 줄이기 위한 배율

즉, 실제 렌더링 픽셀 크기브라우저에 표시되는 CSS 크기를 분리해서 처리합니다.


9. Text Layer: 텍스트 선택과 검색을 위한 레이어

Canvas는 PDF를 이미지처럼 그립니다.
따라서 Canvas만 있으면 텍스트 선택이나 검색 결과 선택이 어렵습니다.

이를 위해 PDF.js의 TextLayer를 사용합니다.

 
const textRoot = document.createElement("div");

textRoot.dataset.pdfTextRoot = "";
textRoot.className = "textLayer";

textRoot.style.position = "absolute";
textRoot.style.left = "0";
textRoot.style.top = "0";
textRoot.style.inset = "0";

textRoot.style.transformOrigin = "0 0";
textRoot.style.transform = `scale(${initialRatio})`;

textRoot.style.pointerEvents = "auto";
textRoot.style.zIndex = "1";
 

Text Layer 렌더링은 다음처럼 처리합니다.

 
const textContent = await page.getTextContent();

const layer = new TextLayer({
  textContentSource: textContent,
  container: textRoot,
  viewport: viewport.clone({ dontFlip: true }),
});

await layer.render();
 

흐름은 다음과 같습니다.

page.getTextContent()
→ PDF 페이지의 텍스트 조각을 가져옴

new TextLayer()
→ 텍스트 조각을 DOM span으로 렌더링

layer.render()
→ textRoot 안에 투명 텍스트 레이어 생성
 

Text Layer는 실제 글자를 보여주기 위한 레이어라기보다, 텍스트 선택과 검색 위치 계산을 위한 투명 레이어에 가깝습니다.


10. Text Layer CSS 처리

Text Layer의 글자가 Canvas 위에 보이면 같은 글자가 두 번 보일 수 있습니다.
따라서 Text Layer의 글자는 투명하게 처리합니다.

 
.pdf-viewer .textLayer span,
.pdf-viewer .textLayer br {
  color: transparent;
  position: absolute;
  white-space: pre;
  cursor: text;
  transform-origin: 0% 0%;
  z-index: 1;
}
 

선택 영역은 배경만 보이게 합니다.

 
.pdf-viewer .textLayer span::selection {
  color: transparent;
  background: rgba(0 0 255 / 0.25);
  background: color-mix(in srgb, AccentColor, transparent 75%);
}
 

정리하면 다음과 같습니다.

Canvas
→ 실제 PDF 화면 표시

Text Layer
→ 투명한 텍스트 영역 제공
→ 드래그 선택, 검색 선택 지원

selection background
→ 선택된 영역만 시각적으로 표시
 

11. 전체 페이지 렌더링 흐름

pdfDoc이 변경되면 전체 페이지를 순회하며 Canvas와 Text Layer를 생성합니다.

 
useEffect(() => {
  if (!pdfDoc || !hostRef.current) return;

  const host = hostRef.current;
  let cancelled = false;

  const render = async () => {
    try {
      pendingZoomAnchorRef.current = null;
      isZoomingRef.current = false;
      setIsInitialRendering(true);

      const initialRatio = zoom / renderScale;
      const fragment = document.createDocumentFragment();

      for (let p = 1; p <= pdfDoc.numPages; p++) {
        if (cancelled) break;

        const page = await pdfDoc.getPage(p);
        const viewport = page.getViewport({ scale: renderScale });
        const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);

        const wrap = document.createElement("div");
        const canvas = document.createElement("canvas");
        const textRoot = document.createElement("div");

        // wrapper / canvas / textRoot 설정 생략

        wrap.appendChild(canvas);
        wrap.appendChild(textRoot);
        fragment.appendChild(wrap);

        await page.render({
          canvas,
          viewport,
          transform:
            pixelRatio !== 1
              ? [pixelRatio, 0, 0, pixelRatio, 0, 0]
              : undefined,
        }).promise;

        const textContent = await page.getTextContent();

        const layer = new TextLayer({
          textContentSource: textContent,
          container: textRoot,
          viewport: viewport.clone({ dontFlip: true }),
        });

        await layer.render();
      }

      if (!cancelled) {
        host.replaceChildren(fragment);
        setRenderVersion((prev) => prev + 1);
      }
    } finally {
      if (!cancelled) {
        setIsInitialRendering(false);
      }
    }
  };

  void render();

  return () => {
    cancelled = true;
  };
}, [pdfDoc]);
 

특징은 DocumentFragment를 사용한다는 점입니다.

 
const fragment = document.createDocumentFragment();
 

페이지를 하나씩 바로 DOM에 붙이지 않고, fragment에 모은 뒤 한 번에 삽입합니다.

 
host.replaceChildren(fragment);
 

이렇게 하면 중간 렌더링 과정에서 DOM 변경이 너무 자주 발생하는 것을 줄일 수 있습니다.


12. 현재 페이지 판단

현재 페이지는 스크롤 영역에서 가장 많이 보이는 페이지를 기준으로 계산합니다.

 
function resolveVisiblePageFromScroll(root: HTMLElement): number | null {
  const rootRect = root.getBoundingClientRect();
  const pageWraps = root.querySelectorAll<HTMLElement>("[data-page-wrap]");

  let bestPage: number | null = null;
  let bestIntersectionArea = 0;

  for (const pageWrap of pageWraps) {
    const pageRect = pageWrap.getBoundingClientRect();

    const intersectionHeight =
      Math.min(pageRect.bottom, rootRect.bottom) -
      Math.max(pageRect.top, rootRect.top);

    const intersectionWidth =
      Math.min(pageRect.right, rootRect.right) -
      Math.max(pageRect.left, rootRect.left);

    const intersectionArea =
      intersectionHeight > 0 && intersectionWidth > 0
        ? intersectionHeight * intersectionWidth
        : 0;

    if (intersectionArea > bestIntersectionArea) {
      bestIntersectionArea = intersectionArea;

      const pageNumber = Number(pageWrap.dataset.pageWrap);

      if (!Number.isNaN(pageNumber)) {
        bestPage = pageNumber;
      }
    }
  }

  return bestPage;
}
 

계산 기준은 다음과 같습니다.

1. 스크롤 영역의 화면 좌표를 구한다.
2. 각 PDF 페이지 wrapper의 화면 좌표를 구한다.
3. 스크롤 영역과 페이지가 겹치는 면적을 계산한다.
4. 가장 많이 보이는 페이지를 현재 페이지로 판단한다.
 

이 방식은 중앙선 기준보다 조금 더 안정적으로 동작할 수 있습니다.

특히 페이지가 크거나, 확대 상태에서 한 페이지가 화면을 크게 차지하는 경우에도 현재 페이지를 비교적 자연스럽게 판단할 수 있습니다.


13. 스크롤 이벤트 최적화

스크롤 이벤트는 매우 자주 발생합니다.
따라서 매번 현재 페이지를 계산하면 성능에 좋지 않습니다.

이번 구현에서는 requestAnimationFrame을 사용해 한 프레임에 한 번만 계산합니다.

 
useEffect(() => {
  const root = scrollAreaRef.current;
  const host = hostRef.current;

  if (!root || !host || !pdfDoc) return;

  const wraps = host.querySelectorAll<HTMLElement>("[data-page-wrap]");

  if (wraps.length === 0 || wraps.length !== pdfDoc.numPages) return;

  lastResolvedPageRef.current = null;

  let raf = 0;

  const flush = () => {
    raf = 0;

    const n = resolveVisiblePageFromScroll(root);
    if (n == null) return;

    if (lastResolvedPageRef.current === n) return;

    lastResolvedPageRef.current = n;
    setCurrentPage(n);
    setPageInput(String(n));
  };

  const onScroll = () => {
    if (isZoomingRef.current) return;
    if (raf) return;

    raf = requestAnimationFrame(flush);
  };

  flush();

  root.addEventListener("scroll", onScroll, { passive: true });

  return () => {
    root.removeEventListener("scroll", onScroll);
    if (raf) cancelAnimationFrame(raf);
  };
}, [pdfDoc, renderVersion]);
 

핵심은 세 가지입니다.

requestAnimationFrame
→ 스크롤 계산을 브라우저 렌더링 타이밍에 맞춤

lastResolvedPageRef
→ 같은 페이지로 반복 setState 되는 것을 방지

isZoomingRef
→ 줌 보정 중 발생한 scroll 이벤트는 무시
 

줌 보정 중에는 코드가 의도적으로 scrollTop을 바꾸기 때문에, 이때 발생한 scroll 이벤트로 현재 페이지를 다시 계산하면 상태가 흔들릴 수 있습니다.


14. 줌 구조: 고배율 렌더링 후 CSS 스케일 조정

이번 구현에서는 사용자가 줌을 변경할 때마다 PDF를 다시 렌더링하지 않습니다.

대신 PDF를 처음 렌더링할 때 높은 배율로 그려두고, 실제 화면 표시 크기는 CSS로 조정합니다.

 
const ZOOM_LEVELS = [0.5, 1, 1.5, 2, 3, 4];
const RENDER_SCALE = 3;
 
 
const ratio = zoom / renderScale;
 

각 값의 의미는 다음과 같습니다.

값역할
RENDER_SCALE PDF를 Canvas에 실제로 렌더링하는 기준 배율
zoom 사용자가 선택한 표시 배율
ratio 렌더링된 Canvas와 Text Layer를 화면에 표시할 CSS 비율

예를 들어 RENDER_SCALE = 3일 때 사용자가 100%로 보고 있다면:

zoom = 1
renderScale = 3
ratio = 1 / 3
 

즉, 3배율로 먼저 선명하게 렌더링한 뒤, CSS로 1/3 크기로 줄여 보여주는 방식입니다.


15. Canvas Layer와 Text Layer의 배율 맞추기

줌을 변경할 때는 Canvas만 키우면 안 됩니다.
Canvas 위에 올라간 Text Layer도 같은 비율로 조정해야 합니다.

 
const applyDisplayScale = useCallback(() => {
  const host = hostRef.current;
  if (!host) return;

  const ratio = zoom / renderScale;
  const wraps = host.querySelectorAll<HTMLElement>("[data-page-wrap]");

  wraps.forEach((wrap) => {
    const baseWidth = Number(wrap.dataset.baseWidth || "0");
    const baseHeight = Number(wrap.dataset.baseHeight || "0");

    if (!baseWidth || !baseHeight) return;

    // 페이지 wrapper 크기 조정
    wrap.style.width = `${baseWidth * ratio}px`;
    wrap.style.height = `${baseHeight * ratio}px`;

    // Canvas 표시 크기 조정
    const canvas = wrap.querySelector("canvas");

    if (canvas) {
      canvas.style.width = `${baseWidth * ratio}px`;
      canvas.style.height = `${baseHeight * ratio}px`;
    }

    // Text Layer도 같은 비율로 조정
    const textRoot = wrap.querySelector<HTMLElement>("[data-pdf-text-root]");

    if (textRoot) {
      textRoot.style.transform = `scale(${ratio})`;
    }
  });
}, [zoom]);
 

핵심은 다음과 같습니다.

Canvas Layer
→ PDF 화면을 보여주는 레이어

Text Layer
→ 텍스트 선택과 검색 위치를 담당하는 투명 레이어

둘의 scale이 다르면
→ 검색 선택 위치와 실제 PDF 글자 위치가 어긋날 수 있음
 

따라서 wrap, canvas, textRoot를 같은 ratio 기준으로 조정해야 합니다.


16. 이 줌 방식의 장단점

장점

- 줌을 변경할 때마다 PDF를 다시 렌더링하지 않아도 됨
- 줌 반응이 빠름
- Canvas와 Text Layer를 같은 비율로 맞추기 쉬움
- 100%, 150%, 200% 수준에서는 비교적 부드럽게 동작함
 

단점

- RENDER_SCALE보다 큰 zoom에서는 선명도가 떨어질 수 있음
- 처음 PDF를 렌더링할 때 비용이 커질 수 있음
- 페이지 수가 많으면 초기 렌더링 시간이 길어질 수 있음
- Text Layer transform 보정이 어긋나면 검색 선택 위치가 틀어질 수 있음
 

현재 구조는 초기 렌더링 비용을 감수하고, 이후 줌 반응성을 높이는 방식입니다.


17. 줌 보정이 필요한 이유

줌은 단순히 크기만 바꾸는 기능이 아닙니다.
사용자가 보고 있던 위치를 최대한 유지해야 자연스럽게 느껴집니다.

예를 들어 보정이 없으면 이런 문제가 생길 수 있습니다.

줌 전:
3페이지 중간을 보고 있음

줌 후:
3페이지 상단 또는 다른 페이지로 튐
 

이를 방지하기 위해 줌 전 기준점을 저장합니다.

 
type ZoomAnchor = {
  page: number;
  offsetRatioY: number;
  offsetRatioX: number;
};
 

각 필드의 의미는 다음과 같습니다.

필드의미
page 줌 전 화면 중앙에 있던 페이지 번호
offsetRatioY 해당 페이지 내부에서 화면 중앙이 위치한 세로 비율
offsetRatioX 해당 페이지 내부에서 화면 중앙이 위치한 가로 비율

예를 들어 다음 값은:

 
{
  page: 3,
  offsetRatioY: 0.4,
  offsetRatioX: 0.5,
}
 

이렇게 해석할 수 있습니다.

줌 전 화면 중앙은
3페이지의 세로 40%, 가로 50% 지점에 있었다.
 

줌 후에도 이 지점이 다시 화면 중앙 근처에 오도록 스크롤을 복원합니다.


18. 줌 전 기준점 저장하기

줌 버튼을 누르기 직전에 getZoomAnchor()를 실행합니다.

이 함수는 현재 스크롤 영역의 중앙 좌표를 기준으로, 어떤 페이지의 어느 위치를 보고 있었는지 계산합니다.

 
const getZoomAnchor = useCallback((): ZoomAnchor | null => {
  const root = scrollAreaRef.current;
  if (!root) return null;

  const rootRect = root.getBoundingClientRect();

  // 현재 스크롤 영역의 화면 중앙 좌표
  const centerY = rootRect.top + rootRect.height / 2;
  const centerX = rootRect.left + rootRect.width / 2;

  const wraps = Array.from(
    root.querySelectorAll<HTMLElement>("[data-page-wrap]"),
  );

  let selectedWrap: HTMLElement | null = null;
  let minDistance = Number.POSITIVE_INFINITY;

  for (const wrap of wraps) {
    const rect = wrap.getBoundingClientRect();

    // 화면 중앙이 페이지 안에 들어와 있으면 우선 선택
    const containsCenterY = rect.top <= centerY && rect.bottom >= centerY;

    // 그렇지 않다면 중앙과 가장 가까운 페이지 선택
    const pageCenterY = rect.top + rect.height / 2;
    const distance = Math.abs(pageCenterY - centerY);

    if (containsCenterY || distance < minDistance) {
      selectedWrap = wrap;
      minDistance = distance;
    }
  }

  if (!selectedWrap) return null;

  const pageRect = selectedWrap.getBoundingClientRect();
  const page = Number(selectedWrap.dataset.pageWrap);

  if (Number.isNaN(page) || pageRect.height <= 0 || pageRect.width <= 0) {
    return null;
  }

  return {
    page,

    // 페이지 내부에서 화면 중앙이 어느 세로 비율에 있는지 계산
    offsetRatioY: Math.min(
      Math.max((centerY - pageRect.top) / pageRect.height, 0),
      1,
    ),

    // 가로 스크롤이 생기는 경우를 대비해 x축 비율도 저장
    offsetRatioX: Math.min(
      Math.max((centerX - pageRect.left) / pageRect.width, 0),
      1,
    ),
  };
}, []);
 

동작 흐름은 다음과 같습니다.

1. 스크롤 영역의 중앙 좌표를 구한다.
2. 중앙 좌표와 가장 관련 있는 페이지 wrapper를 찾는다.
3. 해당 페이지 내부에서 중앙 좌표가 어느 비율 위치인지 계산한다.
4. page, offsetRatioY, offsetRatioX를 저장한다.
 

19. 줌 후 기준점 복원하기

줌 후에는 페이지 크기가 바뀌어 있습니다.

따라서 기존 픽셀 좌표를 그대로 쓰지 않고, 저장해 둔 페이지 비율을 기준으로 새 위치를 계산해야 합니다.

 
const restoreZoomAnchor = useCallback((anchor: ZoomAnchor) => {
  const root = scrollAreaRef.current;
  if (!root) return;

  const wrap = root.querySelector<HTMLElement>(
    `[data-page-wrap="${anchor.page}"]`,
  );

  if (!wrap) return;

  // 줌 후 페이지 크기 기준으로 같은 비율 위치를 다시 계산
  const targetY = wrap.offsetTop + wrap.offsetHeight * anchor.offsetRatioY;
  const targetX = wrap.offsetLeft + wrap.offsetWidth * anchor.offsetRatioX;

  const maxTop = Math.max(0, root.scrollHeight - root.clientHeight);
  const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth);

  // targetY가 화면 중앙에 오도록 scrollTop 복원
  root.scrollTop = Math.min(
    Math.max(targetY - root.clientHeight / 2, 0),
    maxTop,
  );

  // 가로 스크롤도 같은 방식으로 복원
  root.scrollLeft = Math.min(
    Math.max(targetX - root.clientWidth / 2, 0),
    maxLeft,
  );
}, []);
 

핵심은 다음입니다.

줌 전:
3페이지의 40% 지점을 보고 있었다.

줌 후:
새롭게 커진 3페이지의 40% 지점을 다시 계산한다.

그 위치가 화면 중앙에 오도록 scrollTop을 조정한다.
 

20. useLayoutEffect에서 줌 적용과 복원을 처리하기

줌 보정은 useEffect보다 useLayoutEffect에서 처리하는 것이 좋습니다.

useLayoutEffect는 DOM 변경 후, 브라우저가 화면을 그리기 전에 실행됩니다.
그래서 화면이 그려진 뒤 뒤늦게 스크롤을 보정하는 것보다 덜컹임을 줄이기 쉽습니다.

 
useLayoutEffect(() => {
  // 변경된 zoom 값을 기준으로 wrapper, canvas, textRoot 크기를 조정
  applyDisplayScale();

  const anchor = pendingZoomAnchorRef.current;
  pendingZoomAnchorRef.current = null;

  if (!anchor) {
    isZoomingRef.current = false;
    return;
  }

  // 줌 전 저장한 위치를 기준으로 스크롤 복원
  restoreZoomAnchor(anchor);

  const root = scrollAreaRef.current;

  // 보정 후 실제 현재 페이지를 다시 동기화
  if (root) {
    const sync = resolveVisiblePageFromScroll(root);

    if (sync != null) {
      setCurrentPage(sync);
      setPageInput(String(sync));
    }
  }

  requestAnimationFrame(() => {
    isZoomingRef.current = false;
  });
}, [applyDisplayScale, renderVersion, restoreZoomAnchor]);
 

줌 버튼을 눌렀을 때는 다음처럼 처리합니다.

 
const applyZoom = (val: number) => {
  const clamped = clampZoom(val);

  if (clamped === zoom) {
    setZoomInput(String(Math.round(clamped * 100)));
    return;
  }

  // 줌 중에는 scroll 이벤트로 currentPage가 흔들리지 않도록 표시
  isZoomingRef.current = true;

  // 줌 전 기준점 저장
  pendingZoomAnchorRef.current = getZoomAnchor();

  // zoom 상태 변경
  // 실제 DOM 크기 변경과 스크롤 복원은 useLayoutEffect에서 처리
  setZoom(clamped);
  setZoomInput(String(Math.round(clamped * 100)));
};
 

흐름을 정리하면 다음과 같습니다.

applyZoom()
→ 줌 전 기준점 저장
→ zoom 상태 변경

useLayoutEffect()
→ 새 zoom 기준으로 레이어 크기 변경
→ 저장된 기준점으로 scrollTop 복원
→ currentPage 재동기화
 

21. 검색 파이프라인 요약

검색 기능은 크게 네 단계로 동작합니다.

1. page.getTextContent()로 페이지별 텍스트 추출
2. 텍스트 조각을 하나의 문자열처럼 이어서 검색어 매칭
3. 검색 결과의 page, itemIndex, start, end 저장
4. 검색 결과 선택 시 해당 좌표로 스크롤하고 Text Layer에서 Range 선택
 

전체 흐름은 다음과 같습니다.

검색어 입력
  ↓
collectSearchMatches()
  ↓
각 페이지의 getTextContent() 순회
  ↓
collectMatchesOnPageFlat()
  ↓
PdfSearchMatch[] 생성
  ↓
scrollToSearchMatch()
  ↓
검색 결과 위치로 스크롤
  ↓
trySelectTextLayerMatch()
  ↓
Text Layer DOM에서 해당 문자열 선택
 

검색 결과 타입은 다음과 같습니다.

 
type PdfSearchMatch = {
  page: number;
  spans: {
    itemIndex: number;
    start: number;
    end: number;
  }[];
};
 

이 타입은 검색 결과를 단순히 페이지 단위로만 저장하지 않습니다.

page
→ 검색어가 발견된 페이지 번호

itemIndex
→ TextContent item 배열에서 몇 번째 텍스트 조각인지

start / end
→ 해당 텍스트 조각 안에서 검색어가 시작 / 끝나는 문자 위치
 

이 구조 덕분에 검색 결과로 이동한 뒤, 해당 문자열을 Text Layer에서 선택할 수 있습니다.


22. 검색 결과 위치로 스크롤하기

검색 결과 위치 이동은 scrollToSearchMatch()에서 처리합니다.

 
const scrollToSearchMatch = useCallback(
  async (m: PdfSearchMatch) => {
    if (!pdfDoc) return;

    const page = await pdfDoc.getPage(m.page);
    const viewport = page.getViewport({ scale: renderScale });
    const tc = await page.getTextContent();

    const firstIdx = m.spans[0]?.itemIndex ?? 0;
    const item = tc.items[firstIdx] as PdfTextContentItem;

    if (!item?.transform) {
      scrollToPage(m.page);
      return;
    }

    const p: [number, number] = [0, 0];

    Util.applyTransform(p, item.transform);

    const [, vy] = viewport.convertToViewportPoint(p[0], p[1]);
    const ratio = zoom / renderScale;
    const yInPage = vy * ratio;

    const root = scrollAreaRef.current;
    const wrap = root?.querySelector(
      `[data-page-wrap="${m.page}"]`,
    ) as HTMLElement | null;

    if (!root || !wrap) {
      scrollToPage(m.page);
      return;
    }

    const top = wrap.offsetTop + yInPage;
    const nextTop = Math.max(0, top - root.clientHeight * 0.2);

    root.scrollTo({
      top: nextTop,
      behavior: "smooth",
    });

    setCurrentPage(m.page);
    setPageInput(String(m.page));

    window.setTimeout(() => {
      void trySelectTextLayerMatch(root, m, tc.items);
    }, 100);
  },
  [pdfDoc, zoom, scrollToPage],
);
 

핵심은 PDF 텍스트 좌표를 실제 스크롤 좌표로 변환하는 것입니다.

item.transform
→ PDF 텍스트 조각의 원본 좌표 정보

Util.applyTransform()
→ transform 좌표를 실제 PDF 좌표로 변환

viewport.convertToViewportPoint()
→ PDF 좌표를 viewport 좌표로 변환

wrap.offsetTop + yInPage
→ 전체 스크롤 영역에서 검색어가 위치한 세로 좌표
 

23. 검색 결과를 Text Layer에서 선택하기

검색 위치로 스크롤한 뒤, Text Layer 안의 실제 DOM span을 찾아 브라우저 Selection API로 선택합니다.

 
const range = document.createRange();

range.setStart(tnStart, start);
range.setEnd(tnEnd, end);

const sel = window.getSelection();

sel?.removeAllRanges();
sel?.addRange(range);
 

이 방식은 별도의 하이라이트 div를 만들지 않고, 브라우저의 텍스트 선택 기능을 활용합니다.

흐름은 다음과 같습니다.

trySelectTextLayerMatch()
→ 검색 결과가 있는 페이지 wrapper 찾기
→ textRoot 찾기
→ TextLayer의 span[role="presentation"] 목록 찾기
→ 검색 결과 itemIndex에 해당하는 span 찾기
→ Text 노드 기준으로 Range 생성
→ window.getSelection()으로 선택 표시
 

현재 방식의 장점은 구현이 비교적 단순하다는 점입니다.

다만 추후 검색 하이라이트를 더 자연스럽게 보여주려면, 좌표 기반 highlight overlay를 추가하는 방식도 고려할 수 있습니다.


24. 결론

이번 커스텀 PDF 뷰어의 핵심은 세 가지입니다.

1. Canvas Layer로 PDF 화면을 렌더링한다.
2. Text Layer로 텍스트 선택과 검색을 지원한다.
3. ZoomAnchor로 줌 전후의 시야 위치를 유지한다.
 

즉, viewer.html 없이도 PDF.js의 기본 레이어 구조를 직접 조합하면 서비스 UI에 맞는 PDF 뷰어를 만들 수 있습니다.

728x90
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함