diff --git a/docs/fig-extract-integration.md b/docs/fig-extract-integration.md index d663b4d..66b716f 100644 --- a/docs/fig-extract-integration.md +++ b/docs/fig-extract-integration.md @@ -61,9 +61,13 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); - **suspectedMissing** (v2.4.0+): 감지된 정수 번호 1..최대 중 빠진 번호 목록 (미탐지 의심). 소비자가 무시해도 되는 보고 필드 — "이 논문에 Fig N이 있을 텐데 못 잡았다" UI에 활용 가능. - **취소** (v2.5.0+): `opts.signal`(AbortSignal) 전달 시 페이지 단위로 체크해 AbortError로 reject. - 문서 교체 시 이전 스캔 중단에 사용 (#12). 페이지 렌더 도중에는 다음 체크 지점까지 진행됨(협조적). -- **canvas 수명/메모리** (#12): `figure.canvas`는 같은 페이지 figure들이 공유하는 전체 페이지 렌더 - 참조 — 프리뷰 생성 후 참조를 버리면 GC 회수 가능. 페이지 렌더 LRU·object URL revoke는 Margin 몫. + 문서 교체 시 이전 스캔 중단에 사용 (#12). v2.5.1+: abort 시 진행 중 페이지 렌더도 `RenderTask.cancel()`로 + 즉시 중단 — 페이지 경계까지 기다리지 않는다. **호스트는 문서 교체 시 반드시 signal을 abort해야 한다** + (엔진은 메커니즘만 제공 — signal 미전달 시 스캔이 끝까지 진행됨). +- **크롭 캔버스 수명/메모리** (#12, v2.5.1+): `figure.cropCanvas`는 그림 영역만의 크롭 렌더(scale 2.2)다. + 엔진은 페이지 전체 캔버스를 보관하지 않는다(스캔 중 동시 상주 최대 1장) — figure마다 페이지 전체 + 캔버스를 물던 구조(~9.4MB/페이지 상주)가 사라졌다. 프리뷰 생성 후 `cropCanvas` 참조를 버리면 GC 회수. + 페이지 렌더 LRU·object URL revoke는 여전히 Margin 몫. - **pdf.js 버전**: 엔진은 pdfjs-dist 4.10.38(프로젝트 고정 버전) 기준으로 테스트 샘플 검증됨. - **confidence**: 현재 1.0 고정 (플레이스홀더). 추후 감지 경로별 실측 값으로 교체 예정. - **Table 미지원**: 엔진은 figure만 감지한다. Table region은 v1에서 수동 크롭으로 처리. diff --git a/src/core/fig-engine.ts b/src/core/fig-engine.ts index 25c721f..9e9c045 100644 --- a/src/core/fig-engine.ts +++ b/src/core/fig-engine.ts @@ -29,8 +29,8 @@ export interface EngineFigure { bboxPt: EngineBox; // 그림 영역만 — 캡션 제외 captionBoxPt: EngineBox; // 캡션 블록 영역 bboxPx: EngineBox; // 분석 렌더 픽셀 (pt × 2.2) - canvas: HTMLCanvasElement; // 해당 페이지 전체 렌더 (scale 2.2) — 같은 페이지 figure들이 공유하는 - // 참조. 프리뷰 생성 후 참조를 버리면 메모리 회수 가능 (#12) + cropCanvas: HTMLCanvasElement; // v2.5.1+: 그림 영역만의 크롭 렌더 (scale 2.2). 엔진은 페이지 전체 + // 캔버스를 보관하지 않는다 (#12). 프리뷰 생성 후 참조를 버리면 GC 회수 } // v2.5.0: figure 식별 키 = (num, page). 같은 num이 다른 페이지에 복수 등장 가능 // (합본 논문·부록 번호 재시작 — #14). num 단독을 키로 쓰지 말 것 (toFigureEntries의 @@ -48,12 +48,13 @@ export interface EngineResult { export interface ExtractOptions { onProgress?: (msg: string) => void; debug?: (msg: string) => void; - maxPages?: number; // 기본 60 + maxPages?: number; // v2.5.1+: 스캔 페이지 상한 (미지정 시 전체 페이지) /** 이미 로드된 문서 재사용 (지정 시 data는 null 가능) */ pdfDocument?: PDFDocumentProxy; /** 호스트의 페이지 렌더 캐시 주입 (미지정 시 엔진이 자체 렌더) */ renderPage?: (pageNum: number, scale: number) => Promise; - /** v2.5.0+: 협조 취소 — 페이지 단위 체크, abort 시 AbortError로 reject (#12 문서 교체 대응) */ + /** v2.5.0+: 협조 취소 — 페이지 단위 체크, abort 시 AbortError로 reject (#12 문서 교체 대응). + * v2.5.1+: abort 시 진행 중 페이지 렌더도 RenderTask.cancel()로 즉시 중단 */ signal?: AbortSignal; } diff --git a/src/core/fig-extract.js b/src/core/fig-extract.js index 254669b..4007a32 100644 --- a/src/core/fig-extract.js +++ b/src/core/fig-extract.js @@ -11,16 +11,17 @@ * const result = await FigExtract.extract(uint8Array, { * onProgress: msg => {}, // 진행 상태 문자열 * debug: msg => {}, // 상세 진단 로그 (선택) - * maxPages: 60, // 선택 + * maxPages: 200, // 선택 — 스캔 페이지 상한 (미지정 시 전체 페이지) * pdfDocument: doc, // 선택 — 이미 로드된 PDFDocumentProxy 재사용 (지정 시 data는 null 가능) * renderPage: async (pageNum, scale) => canvas, // 선택 — 호스트 렌더 캐시 주입 * signal: abortCtrl.signal, // 선택 — 협조 취소 (페이지 단위 체크, abort 시 AbortError throw) * }); * // result = { title, numPages, engineVersion, figures: [...], suspectedMissing: ["1", ...] } - * // figure = { num, page, confidence, caption, bboxPt(그림만), captionBoxPt, bboxPx, canvas } + * // figure = { num, page, confidence, caption, bboxPt(그림만), captionBoxPt, bboxPx, cropCanvas } * // 식별 키 = (num, page). 같은 num이 다른 페이지에 복수 등장 가능 (v2.5.0+ — 합본·부록 번호 재시작) * // suspectedMissing = 감지된 정수 번호 1..최대 중 빠진 번호 (미탐지 의심 — 소비자가 무시해도 됨) - * // canvas는 페이지당 공유 참조 — 소비자는 프리뷰 생성 후 참조를 버려 메모리를 회수할 수 있다 + * // cropCanvas = 그림 영역만의 크롭 렌더 (scale 2.2). 페이지 전체 캔버스는 보관하지 않는다 (PDFViewer#12) + * // — 소비자는 프리뷰 생성 후 cropCanvas 참조를 버려 메모리를 회수할 수 있다 * // 크롭 이미지: FigExtract.cropDataURL(fig) / FigExtract.cropBlob(fig) * // 좌표: pt, 좌상단 원점. PDF user space 변환은 y' = pageHeight - y * @@ -30,7 +31,12 @@ const FigExtract = (() => { -const VERSION = "2.5.0"; +const VERSION = "2.5.1"; +// 2.5.1: 메모리 패치 (PDFViewer#12) — ① 페이지 스캔 상한 기본값(60) 제거: 미지정 시 전체 페이지 스캔, +// opts.maxPages는 선택적 상한으로 유지. ② figure.canvas(페이지 전체 렌더 보관) 폐지 → 스캔 중 +// 즉시 크롭한 figure.cropCanvas로 대체: 페이지 캔버스 동시 상주 최대 1장, dedup·크기 필터를 +// 페이지 루프 안으로 이동(탈락 후보는 크롭 생략). 탐지·bbox 로직 무변경 (스냅샷 물리 diff 0 기대). +// ③ opts.signal abort 시 진행 중 페이지 렌더도 RenderTask.cancel()로 즉시 중단(페이지 경계 대기 제거). // 2.5.0: [BREAKING] figures의 num 유일성 폐기 — dedup을 (num, page) 인스턴스 단위로 완화 (PDFViewer#14: // 합본 논문·부록 번호 재시작 보존. 같은 페이지 중복만 score 선택). 정렬을 page→num 자연순으로 결정화. // 소비자 식별 키는 (num, page) — Margin은 fig{num}-p{page} ID로 이미 대응. @@ -49,7 +55,6 @@ const CAP_RE = /^(figure|fig)\s*\.?\s*(\d+(?:\.\d+)?|[A-D]\.\d+|[IVXLC]+)\s*([. // 공백 제거 버전: PDF.js가 small-caps를 "F IGURE 2"처럼 조각내는 경우 대응 const CAP_RE2 = /^(figure|fig)\.?(\d+(?:\.\d+)?|[A-D]\.\d+|[IVXLC]+)([.:|]|$)/i; const S = 2.2; // 분석/크롭 렌더 스케일 (px per pt) -const MAX_PAGES = 60; // 분석할 최대 페이지 수 /* ===================== 기하 유틸 ===================== */ const ox = (a, b) => Math.min(a.left + a.w, b.left + b.w) - Math.max(a.left, b.left); @@ -424,7 +429,7 @@ function detectPage(pg, lines, dom, grid, dbg) { async function extract(data, opts = {}) { const onProgress = opts.onProgress || (() => {}); const dbg = opts.debug || (() => {}); - const maxPages = opts.maxPages || MAX_PAGES; + const maxPages = opts.maxPages; // 미지정 시 전체 페이지 스캔 (기본 상한 없음) /* 협조적 취소 (PDFViewer#12): 페이지 단위로 signal 체크 — 문서 교체 시 호스트가 abort */ const checkAborted = () => { if (opts.signal && opts.signal.aborted) @@ -440,7 +445,7 @@ async function extract(data, opts = {}) { title = (meta.info && meta.info.Title && meta.info.Title.trim()) || null; } catch (e) { /* 무시 */ } - const nPages = Math.min(pdf.numPages, maxPages); + const nPages = maxPages ? Math.min(pdf.numPages, maxPages) : pdf.numPages; /* 1차: 전체 텍스트 → 라인/도미넌트 폰트 */ const pageData = []; const fontW = {}; @@ -470,23 +475,41 @@ async function extract(data, opts = {}) { const vp = pd.page.getViewport({ scale: S }); canvas = document.createElement("canvas"); canvas.width = Math.ceil(vp.width); canvas.height = Math.ceil(vp.height); - await pd.page.render({ canvasContext: canvas.getContext("2d"), viewport: vp }).promise; + /* abort 시 진행 중 렌더도 즉시 취소 — 페이지 경계까지 안 기다림 (PDFViewer#12 in-flight). + * 렌더 시작 직전 재확인: 앞선 await(getImageBoxes 등) 도중 이미 abort된 경우, 그 abort는 + * 아래 리스너보다 먼저 방출돼 onAbort가 안 불리므로 여기서 걸러 불필요한 렌더를 건너뛴다. */ + checkAborted(); + const task = pd.page.render({ canvasContext: canvas.getContext("2d"), viewport: vp }); + const onAbort = () => task.cancel(); + if (opts.signal) opts.signal.addEventListener("abort", onAbort, { once: true }); + try { + await task.promise; + } catch (e) { + if (opts.signal && opts.signal.aborted) + throw new DOMException("figure 추출이 취소됨", "AbortError"); + throw e; // 취소 아닌 실제 렌더 오류는 그대로 전파 + } finally { + if (opts.signal) opts.signal.removeEventListener("abort", onAbort); + } } const grid = makeInk(canvas); const figs = detectPage(pd, pd.lines, dom, grid, dbg); - for (const f of figs) { f.canvas = canvas; allFigs.push(f); } - } - /* 중복 번호 dedup은 (num, page) 인스턴스 단위 (PDFViewer#14) — 합본 논문·부록 번호 재시작에서 - * 같은 번호가 다른 페이지에 재등장하는 figure를 보존한다. 같은 페이지의 중복 후보만 - * 더 그럴듯한 것(래스터 포함 > 큰 높이)을 선택. 소비자 식별 키 = (num, page). */ - const best = {}; - for (const f of allFigs) { - const key = f.num + "@" + f.page; - const score = (f.raster_ ? 1e9 : 0) + f.h_; - if (!(key in best) || score > best[key].score) best[key] = { score, f }; + /* 중복 번호 dedup은 (num, page) 인스턴스 단위 (PDFViewer#14) — 합본 논문·부록 번호 재시작에서 + * 같은 번호가 다른 페이지에 재등장하는 figure를 보존한다. 경쟁은 같은 페이지 안에서만 발생하므로 + * dedup·최소 크기 필터를 페이지 단위로 끝내고, 살아남은 figure만 즉시 크롭해 보관한다. + * 페이지 전체 캔버스는 여기서 참조를 버림 — 스캔 중 동시 상주 최대 1장 (PDFViewer#12). */ + const best = {}; + for (const f of figs) { + const score = (f.raster_ ? 1e9 : 0) + f.h_; + if (!(f.num in best) || score > best[f.num].score) best[f.num] = { score, f }; + } + for (const { f } of Object.values(best)) { + if ((f.x1 - f.x0) < 30 || (f.y1 - f.y0) < 30) continue; + f.cropCanvas = makeCrop(canvas, f); + allFigs.push(f); + } } - const figures = Object.values(best).map(v => v.f) - .filter(f => (f.x1 - f.x0) >= 30 && (f.y1 - f.y0) >= 30) + const figures = allFigs .sort((a, b) => a.page - b.page || String(a.num).localeCompare(String(b.num), undefined, { numeric: true })); for (const f of figures) { @@ -512,17 +535,20 @@ async function extract(data, opts = {}) { } /* ===================== 크롭 헬퍼 ===================== */ -function cropCanvas(f) { +/* 스캔 루프 안에서 페이지 캔버스로부터 그림 영역만 잘라낸다 — 페이지 캔버스는 보관하지 않는다 (#12) */ +function makeCrop(pageCanvas, f) { const cw = f.x1 - f.x0, ch = f.y1 - f.y0; const c2 = document.createElement("canvas"); c2.width = cw; c2.height = ch; const ctx = c2.getContext("2d"); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, cw, ch); - ctx.drawImage(f.canvas, f.x0, f.y0, cw, ch, 0, 0, cw, ch); + ctx.drawImage(pageCanvas, f.x0, f.y0, cw, ch, 0, 0, cw, ch); return c2; } -const cropDataURL = f => cropCanvas(f).toDataURL("image/png"); -const cropBlob = f => new Promise(res => cropCanvas(f).toBlob(res, "image/png")); +/* v2.5.1: 크롭은 스캔 중 이미 생성됨 — 아래 셋은 f.cropCanvas를 읽는 접근자 (시그니처 불변) */ +const cropCanvas = f => f.cropCanvas; +const cropDataURL = f => f.cropCanvas.toDataURL("image/png"); +const cropBlob = f => new Promise(res => f.cropCanvas.toBlob(res, "image/png")); return { VERSION, extract, cropCanvas, cropDataURL, cropBlob, isCaption, buildLines }; diff --git a/test/fig-engine.test.ts b/test/fig-engine.test.ts index 7c0c353..832d4ee 100644 --- a/test/fig-engine.test.ts +++ b/test/fig-engine.test.ts @@ -38,7 +38,7 @@ describe('figure engine integration', () => { bboxPt: { x0: 10, y0: 20, x1: 110, y1: 220 }, captionBoxPt: { x0: 10, y0: 225, x1: 110, y1: 250 }, bboxPx: { x0: 22, y0: 44, x1: 242, y1: 484 }, - canvas: {} as HTMLCanvasElement + cropCanvas: {} as HTMLCanvasElement } ] }; @@ -60,7 +60,7 @@ describe('figure engine integration', () => { }); it('preserves figure numbers reused on different pages', () => { - const canvas = {} as HTMLCanvasElement; + const cropCanvas = {} as HTMLCanvasElement; const box = { x0: 10, y0: 20, x1: 110, y1: 220 }; const captionBox = { x0: 10, y0: 225, x1: 110, y1: 250 }; const pixelBox = { x0: 22, y0: 44, x1: 242, y1: 484 }; @@ -68,6 +68,7 @@ describe('figure engine integration', () => { title: 'Paper with per-chapter numbering', numPages: 20, engineVersion: 'next', + suspectedMissing: [], figures: [ { num: '1', @@ -77,7 +78,7 @@ describe('figure engine integration', () => { bboxPt: box, captionBoxPt: captionBox, bboxPx: pixelBox, - canvas + cropCanvas }, { num: '1', @@ -87,7 +88,7 @@ describe('figure engine integration', () => { bboxPt: box, captionBoxPt: captionBox, bboxPx: pixelBox, - canvas + cropCanvas } ] }; diff --git a/test/tab-figures.test.ts b/test/tab-figures.test.ts index fa833a0..c57c88e 100644 --- a/test/tab-figures.test.ts +++ b/test/tab-figures.test.ts @@ -65,7 +65,7 @@ const figure = (page: number, num = String(page)): EngineFigure => ({ bboxPt: { x0: 0, y0: 0, x1: 10, y1: 10 }, captionBoxPt: { x0: 0, y0: 10, x1: 10, y1: 12 }, bboxPx: { x0: 0, y0: 0, x1: 22, y1: 22 }, - canvas: {} as HTMLCanvasElement + cropCanvas: {} as HTMLCanvasElement }); const result = (figures: EngineFigure[]): EngineResult => ({