Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions docs/fig-extract-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,30 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]);
진행 중 렌더의 `RenderTask.cancel()`에서만 멈춘다. 문서를 바꾼 뒤에도 나가는 스캔이 **약 1페이지
분량**(페이지 캔버스 1장 + 그 페이지까지의 크롭)을 더 들고 있을 수 있다. "스캔 두 개가 끝까지"
대비 이득이 목적이고, 0이 목표가 아니다.
- **이전 `PDFDocumentProxy`를 `destroy()`하지 않는다** (#35). `PdfHost.#setDocument`가 `#doc`을
덮어쓸 뿐이라 한 세션에서 문서를 N번 열면 N개가 상주한다. **#34보다 큰 압력원**이다(문서를 열수록
단조 증가). v2.14.0에서는 크롭이 살아 있는 캔버스로 유지되므로 위 B7 실패에 직접 기여한다.
- ⚠ **엔진 쪽 미해결 (벤더링 시 upstream 확인 필요)**: 1차 패스의 `await page.getTextContent()`
안에서 문서가 destroy되면(#35) 그 promise가 **영원히 settle되지 않는다** — pdf.js worker의
`GetTextContent` 핸들러가 `task.terminated`면 sink를 error 처리하지 않고 빠지고,
`PDFPageProxy._destroy()`는 operator-list 스트림만 취소해 텍스트 스트림은 추적하지 않는다.
결과적으로 그 스캔의 async frame이 pinned돼 페이지 텍스트 메타데이터가 영구 상주한다(크롭
캔버스는 아니다 — 2차 패스의 `getOperatorList`는 정상 거절한다). 문서 교체마다 반복되므로
**단조 증가**다. 근본 해결은 엔진이 `getTextContent`를 abort signal과 race시키는 것이고,
Margin 쪽에서는 막을 수 없다.
- ~~이전 `PDFDocumentProxy`를 `destroy()`하지 않는다~~ → **해소 (#35)**. `PdfHost.#setDocument`가
**뷰어·linkService를 새 문서로 전환한 뒤** 이전 문서를 `destroy()`한다(순서 반대면 뷰어가 방금
파괴된 문서를 렌더하려 한다). 정리 실패는 `console.warn`으로 삼켜 새 문서 로드를 막지 않는다.
- **로드 실패 시에는 이전 문서가 잠시 남는다**: `#setDocument`에 도달하지 못하므로 정리가 다음
성공 로드로 밀린다. `#doc`은 성공에서만 전진하므로 **누적되지 않고 최대 1개**다.
실패 시점에 정리하지 않는 이유는 "화면에 떠 있어서"가 아니다(`setLoading`이 이미 뷰어를
감췄다) — **`viewer.setDocument(null)`을 부르지 않았으므로 `PDFViewer`·`PDFLinkService`가
여전히 그 문서를 가리키고 page view도 마운트된 채 재렌더 가능**하기 때문이다. 창 크기 변경 →
`refreshFitWidthIfNeeded` → `viewer.update()` → `PDFPageView.draw()` 경로가 파괴된 페이지를
렌더하려 하며 터진다.
- **실패한 로드 자신의 `PDFDocumentLoadingTask`·전용 워커는 별도로 정리한다**: pdf.js는 실패 시
promise만 reject하고 task를 회수하지 않아, 파일 없음·권한 거부가 반복되면 **워커 스레드가
단조 증가**한다. `#awaitLoad`가 실패 경로에서 `loadingTask.destroy()`를 부른다.
- ⚠ **`GlobalWorkerOptions.workerPort`를 설정하면 이 정리가 위험해진다**: pdf.js가
`PDFWorker.fromPort`로 모든 문서가 공유하는 싱글턴 워커를 넘기므로, 한 문서를 destroy하면
나머지 문서의 워커까지 종료된다. 문서당 워커 1개가 전제다.

## 주의사항

Expand Down
18 changes: 15 additions & 3 deletions src/viewer/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { Highlight, Memo, PenColor } from '../core/types';
import { HighlightOverlay } from './overlay-highlights';
import { FiguresTab } from './panel/tab-figures';
import { MemoTab } from './panel/tab-memos';
import { type FlatOutlineItem, PdfHost } from './pdf-host';
import { type FlatOutlineItem, PdfHost, isLoadSuperseded } from './pdf-host';

const PANEL_WIDTH_KEY = 'margin:panelWidth';
const PANEL_MIN_WIDTH = 264;
Expand Down Expand Up @@ -147,7 +147,14 @@ let downloadName = 'document.pdf';
async function downloadCurrentPdf(): Promise<void> {
const doc = host.pdfDocument;
if (!doc || downloadButton.disabled) return;
const data = await doc.getData();
/* 내려받는 도중 사용자가 다른 PDF를 열면 이 문서는 destroy된다 (#35) — getData가 거절하므로
* unhandled rejection이 되지 않게 삼킨다. 이미 사라진 문서라 재시도할 대상도 없다. */
let data: Awaited<ReturnType<typeof doc.getData>>;
try {
data = await doc.getData();
} catch {
return;
}
// getData()의 Uint8Array<ArrayBufferLike>는 BlobPart와 타입이 안 맞아 ArrayBuffer 사본으로 감싼다.
const blob = new Blob([new Uint8Array(data)], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
Expand Down Expand Up @@ -274,6 +281,8 @@ async function loadUrl(file: string): Promise<void> {
setPageUi(host.currentPage, host.pageCount);
renderToc(await host.getOutlineItems());
} catch (error) {
/* 이 로드가 더 최신 로드로 밀려났다면 화면은 그쪽 것이다 — 건드리지 않고 물러난다 (#35) */
if (isLoadSuperseded(error)) return;
figuresTab.setDocument(null);
if (isLocalFile && isMissingPdfError(error)) {
showMissingFileState(file);
Expand All @@ -299,6 +308,8 @@ async function loadSelectedFile(file: File): Promise<void> {
setPageUi(host.currentPage, host.pageCount);
renderToc(await host.getOutlineItems());
} catch (error) {
/* 이 로드가 더 최신 로드로 밀려났다면 화면은 그쪽 것이다 — 건드리지 않고 물러난다 (#35) */
if (isLoadSuperseded(error)) return;
figuresTab.setDocument(null);
setError(error);
}
Expand Down Expand Up @@ -886,9 +897,10 @@ tocList.addEventListener('click', (event) => {
if (!row) return;
const item = outlineItems.find((candidate) => candidate.id === row.dataset.id);
if (!item) return;
/* 이동 중 문서가 교체되면 getDestination이 거절한다 (#35) — 조용히 흘린다 */
void host.jumpToOutline(item).then(() => {
if (!pinned) closePanel();
});
}).catch(() => {});
});

const file = readFileParam();
Expand Down
115 changes: 101 additions & 14 deletions src/viewer/pdf-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
import type { PageViewport } from 'pdfjs-dist/types/src/display/display_utils';
import type { DocId, DocMeta } from '../core/types';

/* workerSrc만 설정한다 — `workerPort`는 절대 쓰지 말 것 (#35). workerPort를 주면 pdf.js가
* `PDFWorker.fromPort`로 **모든 문서가 공유하는 싱글턴 워커**를 넘기고, 그러면 #releaseDocument의
* destroy()가 다른 문서들의 워커까지 종료시킨다. 문서당 워커 1개라는 전제 위에 서 있는 코드다. */
GlobalWorkerOptions.workerSrc = workerSrc;

export type OutlineNode = {
Expand All @@ -37,6 +40,22 @@ type PdfHostElements = {
viewer: HTMLDivElement;
};

/**
* 로드가 끝나기 전에 사용자가 다른 문서를 요청해 이 로드가 밀려났다는 신호 (#35).
* **오류가 아니다** — 호출 측은 화면을 건드리지 말고 조용히 물러나야 한다. 이걸 일반 실패로
* 처리하면 정상 로드된 새 문서 위에 오류 화면이 뜬다.
*/
export class PdfLoadSupersededError extends Error {
constructor() {
super('이 PDF 로드는 더 최신 로드로 대체되었습니다.');
this.name = 'PdfLoadSupersededError';
}
}

export const isLoadSuperseded = (error: unknown): boolean =>
typeof error === 'object' && error !== null
&& (error as { name?: unknown }).name === 'PdfLoadSupersededError';

type PdfHostCallbacks = {
onPageChange?: (page: number, pageCount: number) => void;
onScaleChange?: (scale: number, presetValue?: string) => void;
Expand All @@ -49,6 +68,8 @@ export class PdfHost {
readonly version = pdfjsVersion;

#doc: PDFDocumentProxy | null = null;
/** 로드 요청 일련번호 — 완료 시점에 자기가 아직 최신인지 판별한다 (#35) */
#loadSeq = 0;
#callbacks: PdfHostCallbacks;

constructor(elements: PdfHostElements, callbacks: PdfHostCallbacks = {}) {
Expand Down Expand Up @@ -95,27 +116,60 @@ export class PdfHost {
}

async loadUrl(url: string): Promise<PDFDocumentProxy> {
const seq = ++this.#loadSeq;
const loadingTask = getDocument({
url,
docBaseUrl: url,
isEvalSupported: false
});
const doc = await loadingTask.promise;
this.#setDocument(doc, url);
return doc;
const doc = await this.#awaitLoad(loadingTask, seq);
return this.#adopt(doc, seq, url);
}

async loadFile(file: File): Promise<PDFDocumentProxy> {
const seq = ++this.#loadSeq;
const data = new Uint8Array(await file.arrayBuffer());
const loadingTask = getDocument({
data,
isEvalSupported: false
});
const doc = await loadingTask.promise;
this.#setDocument(doc);
const doc = await this.#awaitLoad(loadingTask, seq);
return this.#adopt(doc, seq);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/* 늦게 끝난 로드가 사용자가 **마지막으로 요청한** 문서를 밀어내지 않게 한다 (#35).
* 느린 URL(A)을 로드하는 중에 파일(B)을 드롭하면 B가 먼저 적용되는데, 그 뒤 A가 완료되면
* #setDocument(A)가 화면에 떠 있는 B를 destroy해 버린다 — 이 destroy는 이 PR이 추가한 것이라
* 여기서 함께 막는다. 밀려난 쪽은 방금 받은 자기 문서를 스스로 버리고 물러난다.
* (표시 상태·TOC·docData의 stale 갱신은 main.ts 레벨 문제라 #37에 남아 있다.) */
#adopt(doc: PDFDocumentProxy, seq: number, url?: string): PDFDocumentProxy {
if (seq !== this.#loadSeq) {
void this.#releaseDocument(doc);
throw new PdfLoadSupersededError();
}
this.#setDocument(doc, url);
return doc;
}

/* 로드가 실패하면 loadingTask를 명시로 정리한다 (#35). getDocument는 문서마다 전용 PDFWorker를
* 띄우는데, 실패 시 pdf.js는 promise만 reject하고 task·worker를 회수하지 않는다 — 파일 없음·권한
* 거부가 일상적인 UX라 실패 N번이 워커 스레드 N개로 쌓인다. 성공 경로는 #setDocument가 이전
* 문서를 destroy할 때 함께 정리되므로 여기서 건드리지 않는다. */
async #awaitLoad(
loadingTask: { promise: Promise<PDFDocumentProxy>; destroy(): Promise<void> },
seq: number
) {
try {
return await loadingTask.promise;
} catch (error) {
void loadingTask.destroy().catch(() => {});
/* 이미 밀려난 로드의 **실패**도 화면에 띄우면 안 된다 (#35). 그대로 올려보내면 호출 측이
* 일반 오류로 처리해 figuresTab을 비우고 오류 화면을 띄우는데, 그 화면에는 사용자가
* 실제로 연 다른 문서가 떠 있다. 성공 경로의 #adopt와 같은 판정을 실패 경로에도 적용한다. */
throw seq !== this.#loadSeq ? new PdfLoadSupersededError() : error;
}
}

async getTitle(fallback: string): Promise<string> {
if (!this.#doc) return fallback;
try {
Expand Down Expand Up @@ -201,22 +255,34 @@ export class PdfHost {
this.viewer.update();
}

/* 문서 하나에 고정해서 읽는다 (#35). 원격 PDF의 outline은 아직 안 받은 range를 기다릴 수 있어
* 이 함수가 수 초 매달릴 수 있는데, 그 사이 사용자가 다른 PDF를 열면 #releaseDocument가 이 문서를
* destroy한다 — 그러면 getOutline이 AbortException으로 거절하고, 그 거절이 **이전 로드의 catch**로
* 흘러가 방금 열린 새 문서 위에 오류 화면을 띄운다. 거절을 삼키고, 교체를 감지하면 빈 목록으로
* 빠진다. this.#doc을 다시 읽지 않는 것도 같은 이유다(교체 후 새 문서에 옛 dest를 풀지 않는다). */
async getOutlineItems(): Promise<FlatOutlineItem[]> {
if (!this.#doc) return [];
const outline = (await this.#doc.getOutline()) as OutlineNode[] | null;
if (!outline?.length) return [];
const doc = this.#doc;
if (!doc) return [];
let outline: OutlineNode[] | null;
try {
outline = (await doc.getOutline()) as OutlineNode[] | null;
} catch {
return []; // 파괴된 문서이거나 outline을 못 읽는 문서 — TOC 없음으로 취급
}
if (this.#doc !== doc || !outline?.length) return [];

const items: FlatOutlineItem[] = [];
let nextId = 0;
const walk = async (nodes: OutlineNode[], depth: number): Promise<void> => {
for (const node of nodes) {
if (this.#doc !== doc) return;
const item: FlatOutlineItem = {
id: `toc-${nextId++}`,
title: node.title,
depth,
dest: node.dest,
url: node.url,
page: await this.#resolveDestPage(node.dest)
page: await this.#resolveDestPage(node.dest, doc)
};
items.push(item);
if (node.items?.length) {
Expand All @@ -226,7 +292,7 @@ export class PdfHost {
};

await walk(outline, 0);
return items;
return this.#doc === doc ? items : [];
}

async jumpToOutline(item: FlatOutlineItem): Promise<void> {
Expand All @@ -242,24 +308,45 @@ export class PdfHost {
}

#setDocument(doc: PDFDocumentProxy, url?: string): void {
const previous = this.#doc;
this.#doc = doc;
const linkService = this.linkService as PDFLinkService & {
setDocument(pdfDocument: PDFDocumentProxy, baseUrl?: string | null): void;
};
linkService.setDocument(doc, url ?? null);
this.viewer.setDocument(doc);
this.refreshLayoutSoon();
/* 이전 문서는 **뷰어·linkService가 새 문서로 전환된 뒤에** 정리한다 (#35). pdf.js는
* PDFDocumentProxy를 destroy()하지 않으면 워커 측 자원과 페이지 프록시를 계속 붙들고 있어
* GC 대상이 되지 않는다 — 한 세션에서 문서를 N번 열면 N개가 그대로 상주하고, 벤더링본
* v2.14.0에서는 크롭도 살아 있는 캔버스라 Chrome이 캔버스 백킹 스토어를 회수하는 조건
* (엔진 백로그 B7)에 직접 기여한다.
* 순서가 중요하다 — 전환 전에 destroy하면 뷰어가 방금 파괴된 문서를 렌더하려 한다. */
if (previous && previous !== doc) void this.#releaseDocument(previous);
}

/** 이전 문서의 워커 자원을 반환한다. 실패해도 새 문서 로드를 막지 않는다 (#35). */
async #releaseDocument(doc: PDFDocumentProxy): Promise<void> {
try {
await doc.destroy();
} catch (error) {
console.warn('이전 PDF 문서를 정리하지 못했습니다', error);
}
}

async #resolveDestPage(dest: string | unknown[] | null): Promise<number | null> {
if (!this.#doc || !dest) return null;
/** doc을 명시로 받는다 — 호출 중 문서가 교체돼도 옛 dest를 새 문서에 풀지 않는다 (#35). */
async #resolveDestPage(
dest: string | unknown[] | null,
doc: PDFDocumentProxy | null = this.#doc
): Promise<number | null> {
if (!doc || !dest) return null;
try {
const destArray = typeof dest === 'string' ? await this.#doc.getDestination(dest) : dest;
const destArray = typeof dest === 'string' ? await doc.getDestination(dest) : dest;
if (!destArray?.length) return null;
const first = destArray[0];
if (typeof first === 'number') return first + 1;
if (first && typeof first === 'object' && 'num' in first && 'gen' in first) {
return (await this.#doc.getPageIndex(first as { num: number; gen: number })) + 1;
return (await doc.getPageIndex(first as { num: number; gen: number })) + 1;
}
return null;
} catch {
Expand Down
Loading