diff --git a/docs/fig-extract-integration.md b/docs/fig-extract-integration.md index 42ca25d..3fcd8a3 100644 --- a/docs/fig-extract-integration.md +++ b/docs/fig-extract-integration.md @@ -48,6 +48,70 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); - 엔진은 전역 `pdfjsLib`(OPS 등)에 의존하는데, 번들 환경에서는 `fig-engine.ts`가 pdfjs-dist import를 전역에 주입해 해결한다 — 엔진 사용 전 `fig-engine.ts`를 거치면 됨. +### 벤더링본 v2.14.0 vs 선언 타입 (2026-07-28 현재) + +`src/core/fig-extract.js`는 **v2.14.0**이고, `fig-engine.ts` 타입은 여기에 **v2.19.1의 *제거*만 +반영**한 상태다 — v2.19.1 계약 전체를 반영한 것이 **아니다**. + +운영 원칙: **선언 타입은 벤더링 런타임이 실제로 제공하는 것의 부분집합으로 유지한다.** +제거는 지금 반영하고, 추가는 벤더링과 함께 반영한다. + +근거는 "좁히는 게 안전해서"가 아니라 **두 방향의 실패 방식이 다르기 때문**이다. +- **좁히기는 컴파일 타임에 요란하게 실패한다** — 누군가 그 필드를 쓰고 있었다면 그 PR에서 즉시 타입 + 에러가 난다. 단, 런타임이 여전히 그 필드에 의존한다면(아래 `cropCanvas`가 정확히 그렇다) 그 + 사실을 **다른 곳에 남겨야 한다**. 그래서 이 절이 존재한다. +- **넓히기는 런타임에 조용히 실패한다** — 컴파일도 되고 배포도 되고 아무 일도 안 일어난다. 누구도 + 알아차릴 계기가 없다. 전체 v2.19.1 계약을 미리 선언하면 벤더링 시점에 "이 필드를 이제 처리해야 + 한다"고 강제하는 **컴파일 에러라는 유일한 강제 장치**까지 없어진다. + +이 원칙은 **필드·옵션 같은 구조에만** 적용된다. 동작 서술(주석·문서)은 별도로, 벤더링본에서 +성립하지 않으면 그 사실을 명시한다. 버전 스큐 자체는 `fig-engine.ts`의 `VENDORED_ENGINE_VERSION` +상수와 이를 검사하는 테스트가 지킨다 — 벤더링하면 테스트가 깨지고, 그게 이 목록으로 돌아오라는 신호다. + +그래서 v2.15.0~v2.19.1이 **추가한** 것들(`captionPage`, `onDiagnostic`, `cropImages`)은 아직 타입에 +없고 아래 §다음 벤더링 할 일에 있다. + +**현재 실제로 돌아가는 코드(v2.14.0)의 크롭 동작** — 아래 §주의사항의 v2.19.1 서술과 다르다: + +- `fig-extract.js:2333`이 figure마다 `cropCanvas`를 만들고, `:2377`의 `cropDataURL`은 **그 캔버스를 + 읽는 순수 접근자**다. 즉 `cropCanvas`는 타입에서 사라졌어도 **런타임에서는 여전히 load-bearing**이다. + → 엔진이 준 figure 객체를 **그대로** `cropDataURL`에 넘겨야 한다. 선언된 필드만으로 재구성하거나 + `structuredClone`·`JSON.parse(JSON.stringify(...))`를 거치면 타입 검사는 통과하고 런타임에서 + `Cannot read properties of undefined (reading 'toDataURL')`로 죽는다. M3의 storage 저장 작업이 + 정확히 이 함정을 부른다. +- `tab-figures.ts:72`가 `result.figures`를 세션 내내 보관하고 `:110`은 스캔이 **다 끝난 뒤** 렌더에서야 + `cropDataURL`을 부른다 — v2.5.1~v2.19.0의 "프리뷰 생성 후 참조를 버려라" 지침이 여기서는 지켜지지 + 않는다. 그래서 백로그 B7이 기술한 실패(Chrome이 캔버스 백킹 스토어를 회수 → 전면 투명)가 + **이 확장에서도 일어날 수 있고**, v2.14.0에는 `FigRenderError`가 없으므로 증상은 **오류도 재시도 + 버튼도 없이 프리뷰 카드가 백지로 뜨는 것**이다. 벤더링 전까지는 이 상태다. + +#### 다음 벤더링 할 일 + +1. 엔진 repo(**PDFViewer-Figure-Extract** — 로컬 체크아웃 이름은 `figure-preview-test`)의 + `fig-extract.js`(v2.19.1+) → `src/core/fig-extract.js` 복사 (PB-5, byte-identical 확인). +2. **`EngineFigure`에 `captionPage?: number` 추가 + `toFigureEntries`에서 보존** (v2.19.0 12-B). + **미룬 이유는 부분집합 원칙이 아니라 스키마 결정이다** — 이 필드는 소비자가 *읽는* 값이고 + v2.14.0은 cross-page figure를 아예 방출하지 않으므로 지금 선언해도 `undefined`가 정직한 답이다 + (`cropImages`/`onDiagnostic`처럼 "껐는데 안 꺼지는" 거짓이 아니다). 진짜 이유는 보존하려면 + `FigureSeed`/`FigureEntry` 스키마를 넓혀야 하고 그건 M3 설계와 함께 정할 일이라는 것이다. + 방치 시 결과: 캡션이 다음 장 상단이고 그림이 앞 페이지면 엔진이 `page`(그림) ≠ `captionPage`로 + 방출하는데 `toFigureEntries`가 명시적 리터럴을 만들며 이 필드를 **버려서**, M3의 captionAnchor + 계산이 그림 페이지에서 캡션을 찾다가 **오류 없이 조용히 실패**한다. +3. `ExtractOptions`에 `cropImages?: boolean`(v2.19.1 진단 전용) 추가. **벤더링 전에는 넣지 말 것** — + v2.14.0은 이 옵션을 무시하므로 타입만 먼저 있으면 "껐는데 안 꺼지는" 오용을 부른다. +4. `ExtractOptions`에 `onDiagnostic?: (records: unknown[]) => void` 추가 (v2.15.0 `[필드 추가]`). + v2.14.0에서는 무시돼 record가 조용히 안 온다 — 역시 벤더링과 함께. +5. `tab-figures.ts`에서 `error.name === 'FigRenderError'`를 분기해 "메모리가 부족했을 수 있어요 — + 다시 시도해 주세요" 문구를 노출 (현재는 일반 실패 문구 + 재시도 버튼). + +### 벤더링과 무관한 선재 결함 (지금도 유효) + +- **`tab-figures.ts`에 `AbortController` 배선이 없다.** §취소가 "호스트는 문서 교체 시 반드시 signal을 + abort해야 한다"고 요구하는데 `#scan()`은 `signal`을 넘기지 않는다. `setDocument`는 `#scanGeneration`을 + 올려 **결과만 버리고 작업은 안 멈춘다**. 문서를 빠르게 갈아타면 스캔 두 개가 동시에 돌아 크롭 세트가 + 두 벌 상주한다 — 백로그 B7이 기술한 메모리 압력을 호스트가 스스로 만들고 있다. + **엔진 버전과 무관하게 지금 v2.14.0에서도 유효한 결함**이라 벤더링을 기다릴 이유가 없다. + ## 주의사항 > 이 섹션의 원천 사실은 엔진 repo 문서(`docs/DEV.md` §통합 계약, `docs/ALGORITHM.md` §알려진 한계)가 정본 — @@ -64,15 +128,34 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); 문서 교체 시 이전 스캔 중단에 사용 (#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 몫. +- **크롭 이미지 수명/메모리** (#12 → 엔진 백로그 B7, **v2.19.1에서 재설계 · `[BREAKING]`**): + 크롭은 이제 스캔 중 **PNG로 즉시 직렬화**되고 캔버스는 그 자리에서 반환된다. `figure.cropCanvas` + 필드와 `cropCanvas()` 접근자는 **제거**됐고, 이미지는 `cropDataURL(fig)` / `cropBlob(fig)`로만 받는다 + (두 함수의 시그니처는 불변 — 단 `cropDataURL`은 `cropImages:false`로 추출한 figure에 대해 **throw** + 한다. v2.14.0에서는 throw하지 않았다). **소비자가 캔버스 수명을 관리할 필요가 없어졌다** — + v2.5.1~v2.19.0의 "프리뷰 생성 후 참조를 버려라"는 지침은 폐기다. + - 왜 바꿨나: 크롭 캔버스를 문서 스캔이 끝날 때까지(수십 초) 들고 있으면 Chrome이 메모리 압력을 + 받을 때 백킹 스토어를 **예외 없이 회수**한다. 회수된 캔버스는 그리기가 전부 무성과로 끝나고 + 읽으면 전면 투명이라, 엔진 배치 실행에서 한 논문 크롭 23장이 통째로 백지 PNG가 됐다. + PNG 문자열은 회수 대상이 아니고, 논문당 상주도 최대 130MB → 수MB로 떨어진다. + - 페이지 전체 캔버스도 스캔 중 동시 상주 최대 1장이고 페이지가 끝나면 즉시 반환된다. + - 페이지 렌더 LRU·object URL revoke는 여전히 Margin 몫이다. +- **`FigRenderError`** (v2.19.1+): **엔진이 직접 렌더한** 페이지 캔버스의 불투명 픽셀이 절반 미만이거나 + 크롭 캔버스가 투명하면 `extract()`가 `name === 'FigRenderError'`인 오류로 reject한다 — 렌더 결과가 + 존재하지 않는다는 뜻이다(위 메모리 압력 상황). pdf.js가 페이지를 불투명 흰색으로 채우고 시작하므로 + 정상 렌더의 기대 투명 비율은 0이다. **`renderPage`로 캔버스를 주입하면 이 불변식이 없어 검사가 + 적용되지 않는다** — Margin이 렌더 캐시를 주입하기 시작하면 이 보호도 함께 사라진다는 뜻이다. + 조용히 빈 그림을 내놓는 대신 실패시킨다는 판단이며, **일시적 조건이라 재시도가 유효하다**. + `tab-figures.ts`의 기존 try/catch → 에러 상태 → "다시 시도" 경로가 **오류를 처리하기에는** 충분하다 + (상태 기계 추적 결과 스캔이 멈춰 있는 경로 없음). 다만 두 가지가 남는다: ① 메시지가 일반 문구라 + "메모리가 부족했을 수 있으니 다시 시도해 보세요"를 알리려면 `error.name` 분기가 필요하고, + ② `#scan()`이 `signal`을 넘기지 않아 문서 교체 시 이전 스캔이 계속 돌면서 **이 오류의 발생 확률을 + 호스트가 스스로 올리고 있다**. ①은 위 §다음 벤더링 할 일, ②는 위 §벤더링과 무관한 선재 결함에 있다. - **pdf.js 버전**: 엔진은 pdfjs-dist 4.10.38(프로젝트 고정 버전) 기준으로 테스트 샘플 검증됨. - **confidence**: 현재 1.0 고정 (플레이스홀더). 추후 감지 경로별 실측 값으로 교체 예정. - **Table 미지원**: 엔진은 figure만 감지한다. Table region은 v1에서 수동 크롭으로 처리. - **텍스트 레이어 없는 PDF(스캔본)**: 캡션을 찾지 못해 figures가 빈 배열 — 정상 동작. -- **캡션 앵커·다방향 한계**: "Figure N" 표기가 아예 없는 문서는 구조적 미탐지다. v2.8.0부터 캡션 위·아래·좌·우 figure 후보를 지원하지만, side caption의 세로 정렬 증거가 약하거나 기존 상향 후보가 강하면 보수적으로 미탐지/기존 영역을 유지할 수 있다. 캡션과 figure가 서로 다른 페이지인 레이아웃도 미지원이다 (엔진 repo ALGORITHM.md §알려진 한계). +- **캡션 앵커·다방향 한계**: "Figure N" 표기가 아예 없는 문서는 구조적 미탐지다. v2.8.0부터 캡션 위·아래·좌·우 figure 후보를 지원하지만, side caption의 세로 정렬 증거가 약하거나 기존 상향 후보가 강하면 보수적으로 미탐지/기존 영역을 유지할 수 있다. 캡션이 다음 장 상단에 있고 그림이 앞 페이지에 있는 레이아웃은 **v2.19.0 12-B에서 지원**한다(그 경우 `page`≠`captionPage`) — 벤더링본 v2.14.0에는 아직 없다 (엔진 repo ALGORITHM.md §알려진 한계). - **캡션 표기 확대 (v2.9.x)**: 번호 뒤 구분자가 없는 표기(RSC·Springer `Fig. 1 본문…`, Wiley 자간 분리 `F I G U R E 1 본문…`)를 **문서 수준 게이트를 통과한 문서에서만** 앵커로 승격한다 — 한 문서가 캡션 관습을 하나만 쓴다는 전제라, hard 앵커가 이미 잡히는 문서에는 적용되지 않는다(표기가 섞인 문서는 미적용). 나란한 figure의 캡션이 8pt 미만 간격으로 한 줄에 붙은 경우도 분해해 각각 앵커한다. - **번호 글리프에 ToUnicode 매핑이 없는 PDF는 원리상 미탐지**: 번호가 화면에는 정상으로 보이는데 텍스트 레이어에 문자가 없는 문서가 있다(Wiley 일부). 엔진이 아니라 PDF 쪽 문제라 사용자 눈에는 "번호가 멀쩡히 보이는데 안 잡힌다"로 보인다 — 문의가 오면 수동 크롭 안내가 맞다. - **영역 경계 정밀화 (v2.10.x)**: figure/table·나란한 컬럼 경계 판정을 개선했다 — table 캡션을 **경계로만** 인식해 인접 figure 크롭에서 table을 제외(v2.10.0, table 자체 방출은 없음), 좌우로 나란한 두 figure가 서로를 통째로 크롭하던 것을 각자 캡션 컬럼으로 분리(v2.10.1 같은 baseline, v2.10.2 baseline 어긋난 offset). 출력 필드·좌표계·(num,page) 식별자 불변 — bbox가 더 타이트해질 뿐이라 소비자 코드 변경은 불요. diff --git a/src/core/fig-engine.ts b/src/core/fig-engine.ts index 9e9c045..73a089d 100644 --- a/src/core/fig-engine.ts +++ b/src/core/fig-engine.ts @@ -13,6 +13,16 @@ import type { FigureEntry, PdfRect } from "./types"; const globalScope = globalThis as Record; if (!globalScope.pdfjsLib) globalScope.pdfjsLib = pdfjs; +/** + * 벤더링된 `fig-extract.js`의 버전. **이 파일의 타입과 주석은 이 버전을 기준으로 쓰여 있다.** + * + * 엔진을 새로 벤더링하면 이 상수도 함께 올려야 하고, 그 시점에 + * `docs/fig-extract-integration.md` §다음 벤더링 할 일을 처리해야 한다. + * `test/fig-engine.test.ts`가 불일치를 실패로 만들어 벤더링 PR이 스스로 알리게 한다 — + * 버전 스큐를 문서 문단이 아니라 테스트가 지키게 하려는 것이다. + */ +export const VENDORED_ENGINE_VERSION = '2.14.0'; + /** pt 단위, 좌상단 원점 사각형 (엔진 좌표계) */ export interface EngineBox { x0: number; @@ -29,8 +39,12 @@ export interface EngineFigure { bboxPt: EngineBox; // 그림 영역만 — 캡션 제외 captionBoxPt: EngineBox; // 캡션 블록 영역 bboxPx: EngineBox; // 분석 렌더 픽셀 (pt × 2.2) - cropCanvas: HTMLCanvasElement; // v2.5.1+: 그림 영역만의 크롭 렌더 (scale 2.2). 엔진은 페이지 전체 - // 캔버스를 보관하지 않는다 (#12). 프리뷰 생성 후 참조를 버리면 GC 회수 + // `cropCanvas` 필드는 엔진 v2.19.1이 제거했고([BREAKING]) 선언에서도 뺐다. 이미지는 + // cropDataURL()/cropBlob()으로 받는다. + // ⚠ **현재 벤더링본은 v2.14.0이라 런타임에는 `cropCanvas`가 여전히 있고, `cropDataURL`은 그 + // 필드를 읽는 순수 접근자다.** 즉 엔진이 준 figure 객체를 그대로 넘겨야 한다 — 선언된 필드만으로 + // 재구성하거나 structuredClone/JSON 왕복을 거치면 타입은 통과하고 런타임에서 죽는다. + // (v2.19.1을 벤더링하면 이 주의는 사라진다. docs/fig-extract-integration.md §벤더링본 v2.14.0) } // v2.5.0: figure 식별 키 = (num, page). 같은 num이 다른 페이지에 복수 등장 가능 // (합본 논문·부록 번호 재시작 — #14). num 단독을 키로 쓰지 말 것 (toFigureEntries의 @@ -60,8 +74,13 @@ export interface ExtractOptions { export interface FigExtractApi { VERSION: string; + /** AbortError(취소) 외에 `name === 'FigRenderError'`로 reject될 수 있다 — 렌더 결과가 존재하지 + * 않는 경우다(메모리 압력을 받은 Chrome이 캔버스 백킹 스토어를 회수). 조용히 빈 그림을 내놓는 + * 대신 실패시킨다. 일시적 조건이므로 재시도가 유효하다 — FiguresTab의 "다시 시도" 경로가 적용된다. + * ⚠ **엔진 v2.19.1+ 에서만 발생한다. 현재 벤더링본 v2.14.0은 이 오류를 던지지 않으므로, + * 지금 `e.name === 'FigRenderError'` 분기를 쓰면 죽은 코드다** — 같은 상황에서 v2.14.0은 + * 오류 없이 백지 프리뷰를 낸다. 분기는 벤더링과 함께 넣을 것. */ extract(data: Uint8Array | null, opts?: ExtractOptions): Promise; - cropCanvas(fig: EngineFigure): HTMLCanvasElement; cropDataURL(fig: EngineFigure): string; cropBlob(fig: EngineFigure): Promise; } diff --git a/test/fig-engine.test.ts b/test/fig-engine.test.ts index 832d4ee..38563ec 100644 --- a/test/fig-engine.test.ts +++ b/test/fig-engine.test.ts @@ -1,13 +1,22 @@ import { describe, expect, it } from 'vitest'; import { + FigExtract, + VENDORED_ENGINE_VERSION, requireFigExtract, toFigureEntries, toPdfRect, + type EngineFigure, type EngineResult, type FigExtractApi } from '../src/core/fig-engine'; describe('figure engine integration', () => { + it('pins the vendored engine version these types were written for', () => { + /* 이 파일의 타입·주석과 docs/fig-extract-integration.md의 "다음 벤더링 할 일"은 이 버전을 + * 전제한다. 엔진을 벤더링하면 여기서 깨지고, 그게 TODO 목록으로 돌아가라는 신호다. */ + expect(FigExtract.VERSION).toBe(VENDORED_ENGINE_VERSION); + }); + it('fails clearly when the vendored global is missing', () => { expect(() => requireFigExtract({})).toThrow(/FigExtract가 전역에 등록되지 않았습니다/); }); @@ -38,8 +47,12 @@ 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 }, - cropCanvas: {} as HTMLCanvasElement - } + /* 엔진이 실어 보내는 "선언에 없는 필드"의 대리다 — 벤더링본 v2.14.0의 `cropCanvas`, + * v2.19.1의 `cropPng_`, v2.19.0의 `captionPage`가 전부 여기 해당한다. 아래 toEqual이 + * 이걸 잡아야 "toFigureEntries가 엔진 객체를 그대로 흘리지 않는다"가 보장된다. + * (spread 리팩터가 들어오면 FigureSeed에 캔버스나 수 MB data URL이 실려 storage로 간다.) */ + surplusEngineField: 'must not leak into FigureSeed' + } as unknown as EngineFigure ] }; @@ -60,7 +73,6 @@ describe('figure engine integration', () => { }); it('preserves figure numbers reused on different pages', () => { - 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 }; @@ -77,8 +89,7 @@ describe('figure engine integration', () => { caption: 'Figure 1. Chapter one result', bboxPt: box, captionBoxPt: captionBox, - bboxPx: pixelBox, - cropCanvas + bboxPx: pixelBox }, { num: '1', @@ -87,8 +98,7 @@ describe('figure engine integration', () => { caption: 'Figure 1. Chapter two result', bboxPt: box, captionBoxPt: captionBox, - bboxPx: pixelBox, - cropCanvas + bboxPx: pixelBox } ] }; diff --git a/test/tab-figures.test.ts b/test/tab-figures.test.ts index c57c88e..497ad6a 100644 --- a/test/tab-figures.test.ts +++ b/test/tab-figures.test.ts @@ -64,8 +64,7 @@ const figure = (page: number, num = String(page)): EngineFigure => ({ caption: `Figure ${num}`, 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 }, - cropCanvas: {} as HTMLCanvasElement + bboxPx: { x0: 0, y0: 0, x1: 22, y1: 22 } }); const result = (figures: EngineFigure[]): EngineResult => ({