From 62c62c055f6e334302f6069238c0111995f3180a Mon Sep 17 00:00:00 2001 From: onetwothr1 Date: Tue, 28 Jul 2026 23:58:50 +0900 Subject: [PATCH 1/2] chore: bump fig-extract to v2.19.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진 PDFViewer-Figure-Extract v2.19.4를 벤더링한다 (엔진 SHA af44f5a49c6dec19344e9893dee4963b60de76ab, 벤더링 시점 HEAD). 직전 벤더링본은 v2.14.0이었다. ## 소비자에게 영향 있는 계약 변화 **cross-page figure (v2.19.0)** — 캡션이 다음 장 상단에 있는 그림을 이제 실제 그림 페이지에 방출한다. `figure.page`는 그림 페이지이고, 캡션이 다른 페이지면 `figure.captionPage`(optional)가 실린다. same-page figure에는 필드 자체가 없다. `captionBoxPt`는 **캡션 페이지 좌표계**다 — 그림 페이지 높이로 변환하면 조용히 틀린다. `FigureSeed.captionPage`를 필수로 두고 `toFigureEntries`가 `?? page`로 정규화하며, 새 경계 함수 `toFigureEntry()`가 필드를 명시 나열해 seed 전용 필드가 `FigureEntry`(→ chrome.storage)로 새지 않게 한다. 스프레드는 TS strict에서도 초과 속성 검사를 받지 않아 타입이 막아주지 못한다. **FigRenderError (v2.19.1)** — Chrome이 메모리 압력에서 캔버스 백킹 스토어를 회수하면 엔진이 즉시 실패시킨다. 이전에는 백지 크롭이 조용히 저장됐다. `tab-figures.ts`가 이 오류를 이름으로 판별해(엔진과 동일한 duck-typing — realm을 넘으면 instanceof만 깨진다) 메모리 안내 문구를 띄운다. **부분 결과는 없다**: 앞 20페이지를 다 잡았어도 21페이지에서 죽으면 목록이 0건이다. 실패 모드가 "일부 백지"에서 "전부 없음"으로 옮겨간다. **크롭 접근자 (v2.19.1)** — `cropCanvas`가 export에서 사라졌고 `cropDataURL`· `cropBlob`은 내부 `cropPng_`를 읽는 접근자다. "엔진 figure 객체를 그대로 넘겨라 / structuredClone·JSON 왕복 금지"는 필드 이름만 바뀐 채 그대로 유효하다. **ExtractOptions** — `cropImages`(v2.19.1)·`onDiagnostic`(v2.15.0) 추가. onDiagnostic record에는 캡션 줄 원문이 앵커당 최대 180자 실리므로 로컬 디버깅 외에는 쓰지 않는다. v2.13.1~v2.19.4의 나머지 변경(감지 품질·관측 계층)은 출력 스키마에 영향이 없다. ## 검증 typecheck 통과 · 48 tests 통과(직전 41 → +7) · build 통과. 실 PDF 스모크 3편에서 cross-page 방출과 크롭 생성을 확인했다 — PLOS pbio 7@p15 (captionPage 16), Sanesi 10@p12(13), Barak 1@p2(3). 세 편 모두 captionPage를 가진 행은 그 1건뿐이고 나머지 same-page 행에는 필드가 없다. 새 테스트가 죽이는 회귀: ① 좌표 변환에 캡션 페이지 높이를 쓰는 것(페이지별로 다른 높이를 주는 mock + 호출 인자 단언) ② 카드가 캡션 페이지를 가리키는 것 ③ FigRenderError 판별을 instanceof로 좁히는 것 ④ toFigureEntry가 seed를 스프레드해 captionPage를 흘리는 것. ## 문서 §벤더링 상태를 v2.19.4 완료로 갱신하고, 버전 핀이 지키는 범위가 "상수 ↔ 엔진 파일" 한 쌍뿐이며 선언 타입이 어느 버전 기준인지는 기계가 검사할 수 없다는 점을 명시했다. 갱신 절차에 엔진 repo clean 확인과 SHA 기재를 추가했다. figure-ux.md·implementation-plan.md의 캡션 검색 페이지 기준, pdf-host.ts의 낡은 크롭 주석, v2.16.0에서 해소된 v2.11.0 캐비앳도 함께 정정했다. Co-Authored-By: Claude Opus 5 --- docs/fig-extract-integration.md | 179 ++-- docs/figure-ux.md | 15 +- docs/implementation-plan.md | 4 +- src/core/fig-engine.ts | 134 ++- src/core/fig-extract.js | 1678 +++++++++++++++++++++++++++++-- src/viewer/panel/tab-figures.ts | 22 +- src/viewer/pdf-host.ts | 7 +- test/fig-engine.test.ts | 136 ++- test/tab-figures.test.ts | 85 +- 9 files changed, 2072 insertions(+), 188 deletions(-) diff --git a/docs/fig-extract-integration.md b/docs/fig-extract-integration.md index 2b43224..0a77358 100644 --- a/docs/fig-extract-integration.md +++ b/docs/fig-extract-integration.md @@ -28,15 +28,20 @@ figure 감지 엔진(`src/core/fig-extract.js`)의 반입·사용 규약. 엔진 ## 사용법 ```ts -import { FigExtract, toFigureEntries } from "../core/fig-engine"; +import { FigExtract, toFigureEntries, toFigureEntry } from "../core/fig-engine"; // 뷰어가 이미 문서를 로드했으므로 재파싱 없이 PDFDocumentProxy를 넘긴다 (data는 null) const res = await FigExtract.extract(null, { pdfDocument: pdfHost.pdfDocument, - renderPage: (pageNum, scale) => renderCache.getPageCanvas(pageNum, scale), // 선택 + signal: abortController.signal, // 문서 교체 시 필수 — 아래 §주의사항 취소 + // renderPage: … ← **주입하면 죽은 캔버스 검사(FigRenderError)가 꺼진다.** 아래 §주의사항 + // FigRenderError 참조. 렌더 캐시 재사용 이득과 백지 크롭 보호를 맞바꾸는 선택이므로 + // 지금은 주입하지 않는다. }); const seeds = toFigureEntries(res, (p) => pageHeights[p]); -// seeds: FigureEntry에서 doc·captionAnchor만 빠진 형태 — 호출 측이 채워서 저장 +// seeds: FigureEntry에서 doc·captionAnchor가 빠지고 captionPage가 더해진 형태. +// 영속화는 반드시 toFigureEntry()로 — captionPage를 떨어뜨린다 (아래 §cross-page 캡션) +const entry = toFigureEntry(seed, docId, anchorFoundIn(seed.captionPage, seed.captionText)); ``` ## 현재 통합 상태 @@ -48,61 +53,52 @@ 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 현재) +### v2.19.4 벤더링 — 완료 (2026-07-28) -`src/core/fig-extract.js`는 **v2.14.0**이고, `fig-engine.ts` 타입은 여기에 **v2.19.1의 *제거*만 -반영**한 상태다 — v2.19.1 계약 전체를 반영한 것이 **아니다**. +엔진 `fig-extract.js` v2.19.4(엔진 SHA `af44f5a`)를 `src/core/fig-extract.js`로 벤더링하고 +`VENDORED_ENGINE_VERSION`을 함께 갱신했다. 소비자 측 대응(`fig-engine.ts`·`tab-figures.ts`·테스트· +이 문서)은 같은 변경에 포함돼 있다. -운영 원칙: **선언 타입은 벤더링 런타임이 실제로 제공하는 것의 부분집합으로 유지한다.** -제거는 지금 반영하고, 추가는 벤더링과 함께 반영한다. +#### 부분집합 원칙과 이번에 그 원칙이 적용되지 않는 이유 + +운영 원칙은 **선언 타입은 벤더링 런타임이 실제로 제공하는 것의 부분집합으로 유지한다**이다 — +제거는 먼저 반영하고, 추가는 벤더링과 **함께** 반영한다. 근거는 "좁히는 게 안전해서"가 아니라 +**두 방향의 실패 방식이 다르기 때문**이다. -근거는 "좁히는 게 안전해서"가 아니라 **두 방향의 실패 방식이 다르기 때문**이다. - **좁히기는 컴파일 타임에 요란하게 실패한다** — 누군가 그 필드를 쓰고 있었다면 그 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'`를 분기해 "메모리가 부족했을 수 있어요 — - 다시 시도해 주세요" 문구를 노출 (현재는 일반 실패 문구 + 재시도 버튼). + 에러가 난다. 단, 런타임이 여전히 그 필드에 의존한다면(v2.14.0의 `cropCanvas`, v2.19.1+의 + `cropPng_`가 정확히 그렇다) 그 사실을 **다른 곳에 남겨야 한다**. +- **넓히기는 런타임에 조용히 실패한다** — 컴파일도 되고 배포도 되고 아무 일도 안 일어난다. + +이번 벤더링은 **추가와 파일 복사를 한 변경에 담았다** — 원칙의 "함께"에 해당한다. +`ExtractOptions.cropImages`·`onDiagnostic`은 이제 런타임 v2.19.4가 실제로 지원한다. + +**핀이 지키는 범위는 좁다.** `VENDORED_ENGINE_VERSION` 핀이 검사하는 것은 **상수 ↔ 엔진 파일** +한 쌍뿐이다 — 어느 쪽을 먼저 바꿔도 깨지므로 그 둘의 스큐는 잡힌다. 그러나 **선언 타입이 어느 +버전 기준으로 쓰였는지는 어떤 테스트도 알 수 없다.** 타입만 새 계약으로 앞서 나간 상태는 초록으로 +통과한다. 그래서 타입 변경은 반드시 파일 복사와 같은 커밋에 두어야 하고, 이 규칙을 지키는 것은 +아래 §갱신 절차를 읽는 사람의 몫이다. + + + +#### 이미 반영된 것 (구 §다음 벤더링 할 일) + +| 항목 | 상태 | +|---|---| +| `EngineFigure.captionPage?: number` + `toFigureEntries` 보존 (v2.19.0 12-B) | 완료 — 아래 참조 | +| `ExtractOptions.cropImages?: boolean` (v2.19.1 진단 전용) | 완료 (선언만 — 호출부 없음) | +| `ExtractOptions.onDiagnostic?: (records: unknown[]) => void` (v2.15.0) | 완료 (선언만 — 호출부 없음) | +| `tab-figures.ts`의 `FigRenderError` 분기 문구 | 완료 (판별식은 엔진과 동일하게 `name`만 본다) | +| v2.14.0 크롭 캔버스 수명 경고 (`cropCanvas` load-bearing) | 무효화 — v2.19.1이 PNG 직렬화로 대체 | +| `toFigureEntry()` — seed→영속 엔트리 경계 (백로그에 없던 신규) | 완료 — `captionPage` 누출 차단 | + +**`captionPage`의 스키마 결정** (미뤄져 있던 진짜 쟁점): `FigureEntry`는 넓히지 **않았다**. +`FigureEntry.captionAnchor.page`가 이미 목적지 필드이기 때문이다. 대신 `FigureSeed`가 +`captionPage: number`를 나른다 — 그 값을 계산하는 데 필요한 정보를 seed가 나르고 호출 측이 +`captionAnchor`로 접는 구조다. 엔진의 optional을 그대로 흘리지 않고 **경계에서 `?? page`로 +정규화**해 항상 존재하는 `number`로 만든다: optional을 흘리면 호출 측이 보정을 잊어도 컴파일이 +통과하고 captionAnchor 검색이 그림 페이지에서 **오류 없이 조용히 실패**하는데, 그게 애초에 이 +항목이 백로그에 오른 이유였다. ### 벤더링과 무관한 선재 결함 @@ -115,7 +111,8 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); 진행 중 렌더의 `RenderTask.cancel()`에서만 멈춘다. 문서를 바꾼 뒤에도 나가는 스캔이 **약 1페이지 분량**(페이지 캔버스 1장 + 그 페이지까지의 크롭)을 더 들고 있을 수 있다. "스캔 두 개가 끝까지" 대비 이득이 목적이고, 0이 목표가 아니다. - - ⚠ **엔진 쪽 미해결 (벤더링 시 upstream 확인 필요)**: 1차 패스의 `await page.getTextContent()` + - ⚠ **엔진 쪽 미해결 — v2.19.4에서도 그대로다** (2026-07-28 upstream `fig-extract.js:3559` 확인: + `const tc = await page.getTextContent();`, signal과 race시키지 않는다): 1차 패스의 `await page.getTextContent()` 안에서 문서가 destroy되면(#35) 그 promise가 **영원히 settle되지 않는다** — pdf.js worker의 `GetTextContent` 핸들러가 `task.terminated`면 sink를 error 처리하지 않고 빠지고, `PDFPageProxy._destroy()`는 operator-list 스트림만 취소해 텍스트 스트림은 추적하지 않는다. @@ -177,31 +174,77 @@ const seeds = toFigureEntries(res, (p) => pageHeights[p]); 정상 렌더의 기대 투명 비율은 0이다. **`renderPage`로 캔버스를 주입하면 이 불변식이 없어 검사가 적용되지 않는다** — Margin이 렌더 캐시를 주입하기 시작하면 이 보호도 함께 사라진다는 뜻이다. 조용히 빈 그림을 내놓는 대신 실패시킨다는 판단이며, **일시적 조건이라 재시도가 유효하다**. - `tab-figures.ts`의 기존 try/catch → 에러 상태 → "다시 시도" 경로가 **오류를 처리하기에는** 충분하다 - (상태 기계 추적 결과 스캔이 멈춰 있는 경로 없음). 다만 두 가지가 남는다: ① 메시지가 일반 문구라 - "메모리가 부족했을 수 있으니 다시 시도해 보세요"를 알리려면 `error.name` 분기가 필요하고, - ② `#scan()`이 `signal`을 넘기지 않아 문서 교체 시 이전 스캔이 계속 돌면서 **이 오류의 발생 확률을 - 호스트가 스스로 올리고 있다**. ①은 위 §다음 벤더링 할 일, ②는 위 §벤더링과 무관한 선재 결함에 있다. + - ⚠ **벤더링 후 사용자에게 보이는 변화는 "문구가 친절해진다"가 아니다.** 이 오류는 페이지 루프 + **안에서** 던져져 `extract()` 전체를 reject시킨다 — **부분 결과가 없다.** 앞 20페이지에서 + figure를 정상적으로 다 잡았어도 21페이지에서 캔버스가 회수되면 목록은 **0건**이고 에러 카드만 + 남는다. v2.14.0에서는 같은 상황에서 목록은 전부 나오고 해당 카드만 백지였다. 즉 실패 모드가 + **"일부 백지" → "전부 없음"으로 옮겨간다.** 조용한 오염보다 낫다는 판단이지만, 사용자가 보는 + 최악의 순간은 더 나빠진다. + - 재시도는 **백오프 없이 즉시 전량 재스캔**이다(`ensureScanned` → `#scan` → `extract`). 메모리 + 압력이 아직 가시지 않았으면 같은 지점에서 다시 죽으면서 스캔 비용만 한 번 더 든다. 그래서 + 문구가 "다른 탭을 닫고"를 먼저 말한다 — 사용자가 조건을 바꾸도록 유도하는 것이 유일한 완화다. + - `tab-figures.ts`가 `error.name === 'FigRenderError'`를 분기해 "메모리가 부족했을 수 있어요. 다른 + 탭을 닫고 다시 시도해 주세요." 문구를 띄운다(그 외 실패는 일반 문구). 판별식은 엔진과 똑같이 + `name`만 본다 — `instanceof Error`를 덧붙이면 소비자가 공급자보다 좁아진다. + - `#scan()`이 `signal`을 넘겨 문서 교체 시 이전 스캔을 실제로 중단시키므로(#34) 호스트가 스스로 + 이 오류의 발생 확률을 올리던 문제는 해소됐다. +- **cross-page 캡션** (v2.19.0 12-B): 캡션이 다음 장 상단이고 그림이 앞 페이지면 엔진이 + `figure.captionPage`를 함께 방출한다(같은 페이지면 필드 자체가 없다 — 실측상 항상 `page + 1`). + **`figure.page`는 그림 페이지이고 식별 키는 여전히 `(num, page)`다.** 페이지 점프·`region`은 + `page`, **캡션 텍스트 검색과 `captionBoxPt` 좌표 변환은 `captionPage`** 기준이다 + (`toPdfRect(captionBoxPt, …)`에 그림 페이지 높이를 넣으면 조용히 틀린다). + `toFigureEntries()`가 `captionPage ?? page`로 정규화해 `FigureSeed.captionPage: number`로 넘기므로 + 소비자는 optional을 다룰 필요가 없다 — 그 값이 `FigureEntry.captionAnchor.page`가 된다. + - **방향은 구조적으로 한쪽뿐이다**: 12-B는 캡션 페이지 앵커에 대해 `candidatePage = 캡션 페이지 − 1` + 후보만 만들므로 `captionPage === page + 1`이 항상 성립한다. 관측된 경향이 아니라 후보 생성 규칙의 + 귀결이다 — `Math.abs()`나 앞/뒤 양방향 탐색 같은 일반화를 넣지 말 것. + - ⚠ **`captionPage`는 seed 전용이다. 영속 스키마(`FigureEntry`)에 넣지 말 것** — + `captionAnchor.page`와 중복이고 `store.saveDoc`은 화이트리스트 없이 통째로 저장한다. + TS strict도 스프레드(`{ ...seed, doc, captionAnchor }`)에는 초과 속성 검사를 하지 않으므로 + 컴파일이 막아주지 않는다. **`toFigureEntry(seed, doc, captionAnchor)`를 거칠 것** — 필드를 명시 + 나열해 seed 전용 필드를 떨어뜨리고, `test/fig-engine.test.ts`가 반환 키 집합을 고정한다. - **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의 세로 정렬 증거가 약하거나 기존 상향 후보가 강하면 보수적으로 미탐지/기존 영역을 유지할 수 있다. 캡션이 다음 장 상단에 있고 그림이 앞 페이지에 있는 레이아웃은 **v2.19.0 12-B에서 지원**한다(그 경우 `page`≠`captionPage`) — 벤더링본 v2.14.0에는 아직 없다 (엔진 repo ALGORITHM.md §알려진 한계). +- **캡션 앵커·다방향 한계**: "Figure N" 표기가 아예 없는 문서는 구조적 미탐지다. v2.8.0부터 캡션 위·아래·좌·우 figure 후보를 지원하지만, side caption의 세로 정렬 증거가 약하거나 기존 상향 후보가 강하면 보수적으로 미탐지/기존 영역을 유지할 수 있다. 캡션이 다음 장 상단에 있고 그림이 앞 페이지에 있는 레이아웃은 **v2.19.0 12-B에서 지원**한다(그 경우 `page`≠`captionPage` — 위 §cross-page 캡션) (엔진 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가 더 타이트해질 뿐이라 소비자 코드 변경은 불요. -- **캡션 문법 확대 (v2.11.0)**: 보충·부록 캡션의 inline 표기를 새로 잡는다 — `Fig. S1.`·`Figure S1:`·`Figure A1.`(문자접두 번호), `Supplemental`/`Supporting Figure N`(접두), `FIG. 3 (color online).`·`Figure 1 (저자명).`(괄호 한정구). 전부 **점형 canonical**(`S.N`·`A.N`)으로 방출하므로 `num` 필드에 `"S.1"`·`"A.1"` 형태가 더 자주 등장한다(v2.6.0의 `ED.N`·prefix `S.N`과 동일한 표기 규약 — 새 값 형태 아님). 출력 필드·좌표계·(num,page) 식별자·manifest 스키마 불변, 소비자 코드 변경 불요. 주의: 한 물리 figure의 캡션에 다른 계열 라벨이 중첩된 오제출 문서(예: Extended Data 캡션 본문에 `Figure S1.`)는 같은 그림을 `ED.N`+`S.N` 두 번 방출할 수 있다(candidate suppression 미구현 — 엔진 repo 백로그, n=1 코너). +- **캡션 문법 확대 (v2.11.0)**: 보충·부록 캡션의 inline 표기를 새로 잡는다 — `Fig. S1.`·`Figure S1:`·`Figure A1.`(문자접두 번호), `Supplemental`/`Supporting Figure N`(접두), `FIG. 3 (color online).`·`Figure 1 (저자명).`(괄호 한정구). 전부 **점형 canonical**(`S.N`·`A.N`)으로 방출하므로 `num` 필드에 `"S.1"`·`"A.1"` 형태가 더 자주 등장한다(v2.6.0의 `ED.N`·prefix `S.N`과 동일한 표기 규약 — 새 값 형태 아님). 출력 필드·좌표계·(num,page) 식별자·manifest 스키마 불변, 소비자 코드 변경 불요. ~~주의: 한 물리 figure의 캡션에 다른 계열 라벨이 중첩된 오제출 문서(예: Extended Data 캡션 본문에 `Figure S1.`)는 같은 그림을 `ED.N`+`S.N` 두 번 방출할 수 있다~~ → **v2.16.0에서 해소**: 줄의 identity(첫 라벨)가 ED면 같은 줄의 S 라벨은 형제 앵커로 만들지 않는다(표기 관습 근거, 임계 없음). 유령 `S.N`과 그로 인한 ED 영역 축소가 함께 사라졌다. - **전면 figure 크롭 개선 (v2.12.0)**: Nature Extended Data류 **전면(full-page) figure**가 과대 패널티에 눌려 페이지 일부만 크롭되던 것을 해소했다 — 전면 figure의 크롭 영역이 더 정확(전체)해진다. 출력 필드·좌표계·(num,page)·manifest 스키마 불변, 소비자 코드 변경 불요(bbox가 truth에 더 가까워질 뿐). +- **감지 품질 개선만 있고 소비자 코드 변경이 불요한 버전들** — 출력 필드·좌표계·`(num,page)` 식별자 전부 불변이고 bbox/검출률만 좋아진다: + v2.13.0(수평 잉크 커버리지 판별자로 전면 figure 오발 해소) · v2.13.1(폭 바닥 가드) · + v2.14.0(soft 캡션 문서 게이트 강건화 — 혼합 관습 문서의 캡션 몰살 해소) · + v2.16.0(중첩 라벨 계열 경합 — 한 캡션 줄의 `ED.N`+`S.N` 이중 방출 억제) · + v2.19.2(캡션 라벨 앞 삼각 조판 글리프 허용) · v2.19.3/.4(머리글 장식 띠 오방출 거부). + v2.17.0·v2.18.0은 진단 관측 전용으로 `[계약 무변경]`이다. - 엔진은 백그라운드 탭에서 크롬 타이머 스로틀링의 영향을 받는다(분석이 수십 배 느려짐). 전체 문서 스캔은 사용자가 뷰어를 보고 있는 동안 idle로 돌리는 것을 권장. ## fig extractor 작업자를 위한 갱신 절차 1. 엔진 전용 별도 repo에서 새 버전 검증 완료 후 (엔진 repo `docs/DEV.md` §버전 릴리스 절차) -2. `fig-extract.js`를 `src/core/`에 **그대로 복사** — v2.3.0부터 엔진 파일에 globalThis 노출이 포함되어 +2. **복사 전에 엔진 repo가 clean한지 확인** — `git -C <엔진repo> status --porcelain`이 비어 있어야 하고, + `git -C <엔진repo> rev-parse HEAD`로 SHA를 적어 둔다. + 더티한 작업 트리를 복사하면 **어느 커밋에도 존재하지 않는 엔진이 벤더링되는데 두 파일 diff는 + 0건이라 아무 검사에도 안 걸린다** — 나중에 그 코드를 되짚을 방법이 없어진다. +3. `fig-extract.js`를 `src/core/`에 **그대로 복사** — v2.3.0부터 엔진 파일에 globalThis 노출이 포함되어 byte-identical 복사면 됨. 복사 후 두 파일 diff가 0건인지 확인 -3. 엔진 헤더 체인지로그의 계약 태그 확인 — `[필드 추가]`/`[BREAKING]`이면 - `fig-engine.ts`·`fig-extract.d.ts` 타입과 이 문서의 계약 서술을 함께 갱신 -4. 이 문서 §주의사항이 새 버전과 어긋나지 않는지 확인 (예: confidence 실측화 시 해당 항목 갱신) -5. `npm run typecheck && npm run build` 확인 후 그림·표 탭에서 샘플 PDF 1개 스모크 테스트 -6. 커밋 메시지에 엔진 버전 명시 (예: `chore: bump fig-extract to v2.3.0`) +4. **`fig-engine.ts`의 `VENDORED_ENGINE_VERSION`을 같은 커밋에서 새 버전으로 갱신** — + `test/fig-engine.test.ts`의 버전 핀이 3과 4 사이 상태를 실패로 만든다. 이 실패가 + "계약 태그를 다시 읽어라"는 신호다. **핀이 지키는 것은 상수 ↔ 엔진 파일 한 쌍뿐이고, + 타입·주석이 어느 버전 기준인지는 검사하지 못한다** — 그건 아래 5가 하는 사람의 일이다. +5. 엔진 헤더 체인지로그의 계약 태그 확인 — `[필드 추가]`/`[BREAKING]`이면 `fig-engine.ts` 타입과 + 이 문서의 계약 서술을 함께 갱신. (`fig-extract.d.ts`는 `export {}` 스텁이라 갱신할 것이 없다 — + 타입은 전부 `fig-engine.ts`에 있다.) + 엔진의 실제 export 목록(파일 끝 `return { … }`)도 확인할 것 — v2.19.1의 `cropCanvas` 제거처럼 + 손으로 쓴 `FigExtractApi`와 갈라질 수 있다(`test/fig-engine.test.ts`가 뷰어가 부르는 세 함수의 + 존재만 지킨다). +6. 이 문서 §주의사항이 새 버전과 어긋나지 않는지 확인 (예: confidence 실측화 시 해당 항목 갱신) +7. `npm run typecheck && npm test && npm run build` 확인 후 그림·표 탭에서 샘플 PDF 1개 스모크 테스트 +8. 커밋 메시지에 엔진 버전과 **2에서 적어 둔 엔진 SHA**를 명시 + (예: `chore: bump fig-extract to v2.19.4` + 본문에 + `engine: onetwothr1/PDFViewer-Figure-Extract@<복사 시점 HEAD SHA>`). + 엔진 repo는 알고리즘과 무관한 커밋(truth·docs)으로도 전진하므로 **버전 문자열만으로는 어느 + 커밋을 복사했는지 특정되지 않는다** — 같은 v2.19.4가 여러 SHA에 걸쳐 있다. diff --git a/docs/figure-ux.md b/docs/figure-ux.md index 8fd3a3e..a673a5b 100644 --- a/docs/figure-ux.md +++ b/docs/figure-ux.md @@ -67,8 +67,13 @@ - 그림·표 탭 스캔 완료 시 `toFigureEntries()`로 변환해 storage 저장(문서당 1회, 재스캔 시 병합: `regionSource='manual'` 항목은 엔진 결과로 덮지 않음 — fig-extract-integration.md 규약). -- captionAnchor: 엔진 captionText를 해당 페이지 텍스트 인덱스(S_p)에서 검색해 오프셋 계산(동 문서 규약). - 실패 시 anchor 없이 저장(캡션 라벨 링크만 비활성, 나머지 기능 동작). + **저장은 `toFigureEntry(seed, doc, captionAnchor)`를 거친다** — `{ ...seed, … }` 스프레드는 seed + 전용 필드(`captionPage`)를 그대로 영속시킨다(TS가 막지 않음, 동 문서 §cross-page 캡션). +- captionAnchor: 엔진 captionText를 **`seed.captionPage`의** 텍스트 인덱스(S_p)에서 검색해 오프셋 + 계산(동 문서 규약). **그림 페이지가 아니다** — 캡션이 다음 장 상단이고 그림이 앞 페이지인 + 레이아웃(엔진 v2.19.0 12-B)에서 둘이 갈리고, 그때 그림 페이지에서 찾으면 **오류 없이** 빈손으로 + 끝난다. 같은 페이지면 `toFigureEntries()`가 `page`로 정규화해 주므로 늘 `captionPage`만 보면 된다. + 검색 실패 시 anchor 없이 저장(캡션 라벨 링크만 비활성, 나머지 기능 동작). ### 4.2 점프 유틸 (G2) — `viewer/jump.ts` @@ -89,8 +94,12 @@ jumpToRegion(page, rectPdf) // 세로 중앙(큰 region은 1/8) + region 플 - `core/mentions.ts` 스캔·주입은 §5.4 스펙 그대로(정규식·멱등 주입·캡션 자기 제외·단일 span 제한). - PDF 내장 링크: `PDFLinkService` 서브클래스 `goToDestination` 오버라이드 — dest 좌표가 어떤 - FigureEntry의 region 또는 captionAnchor 근방(같은 페이지 ±40pt)이면 **패널 열기 + 해당 카드 강조 + + FigureEntry의 region 또는 captionAnchor 근방이면 **패널 열기 + 해당 카드 강조 + 원점 칩 마킹**(DC-F1-B — §5.4 원계획의 R1 그대로), 아니면 기본 동작. + - "근방" 판정은 **비교 대상마다 기준 페이지가 다르다**: region은 `entry.region.page`, captionAnchor는 + `entry.captionAnchor.page`와 각각 대조한 뒤 ±40pt를 잰다. cross-page figure(엔진 v2.19.0 12-B)에서는 + 이 둘이 다른 페이지이므로 "같은 페이지 ±40pt"를 entry 하나에 대해 한 번만 적용하면 캡션 쪽 히트를 + 통째로 놓친다. - `a.mgn-ref`(본문 참조)와 `a.mgn-ref[data-cap]`(캡션 라벨) 클릭 = 위와 동일한 패널 열기 핸들러 공유. annotation 링크와 겹치면 annotation 우선(§5.4 유지). diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 99459fe..c23f07e 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -231,7 +231,9 @@ onMouseUp: - 시작 시점: Margin 뷰어가 `PDFDocumentProxy`를 확보하는 즉시 엔진 스캔을 시작한다. 그림·표 탭 오픈은 결과 표시 또는 진행 상태 확인만 담당한다. - **문서 내 figure 목록(존재·번호·region·captionText)의 단일 진실 공급원은 엔진이다.** -- `captionAnchor`는 엔진이 반환한 `captionText`를 해당 페이지 `S_p`에서 검색해 Margin 측이 채운다. +- `captionAnchor`는 엔진이 반환한 `captionText`를 `FigureSeed.captionPage`의 `S_p`에서 검색해 Margin 측이 채운다. + **`page`(그림 페이지)가 아니다** — 캡션이 다음 장 상단이고 그림이 앞 페이지인 레이아웃(엔진 v2.19.0 12-B)에서 + 둘이 갈린다. `toFigureEntries()`가 같은 페이지면 `page`로 정규화해 주므로 항상 `captionPage`만 보면 된다. - 엔진 미감지(figures에 없음) 또는 region 이상 시의 안전망은 §6 수동 크롭 그대로. - `confidence`는 현재 엔진이 1.0 고정으로 반환(placeholder). 실측 매핑 도입 전까지 "영역 확인 필요" 배지는 수동 크롭 유도 용도로만 사용. diff --git a/src/core/fig-engine.ts b/src/core/fig-engine.ts index 73a089d..3a6405a 100644 --- a/src/core/fig-engine.ts +++ b/src/core/fig-engine.ts @@ -7,21 +7,22 @@ import * as pdfjs from "pdfjs-dist"; import "./fig-extract.js"; import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api"; -import type { FigureEntry, PdfRect } from "./types"; +import type { DocId, FigureEntry, PdfRect } from "./types"; /* 엔진은 전역 pdfjsLib(OPS·getDocument)에 의존 — 번들 환경에서는 여기서 주입한다 */ const globalScope = globalThis as Record; if (!globalScope.pdfjsLib) globalScope.pdfjsLib = pdfjs; /** - * 벤더링된 `fig-extract.js`의 버전. **이 파일의 타입과 주석은 이 버전을 기준으로 쓰여 있다.** + * 벤더링된 `src/core/fig-extract.js`의 버전. * - * 엔진을 새로 벤더링하면 이 상수도 함께 올려야 하고, 그 시점에 - * `docs/fig-extract-integration.md` §다음 벤더링 할 일을 처리해야 한다. - * `test/fig-engine.test.ts`가 불일치를 실패로 만들어 벤더링 PR이 스스로 알리게 한다 — - * 버전 스큐를 문서 문단이 아니라 테스트가 지키게 하려는 것이다. + * `test/fig-engine.test.ts`가 이 상수를 런타임 `FigExtract.VERSION`과 대조한다. **그 핀이 지키는 + * 것은 상수 ↔ 엔진 파일, 그 한 쌍뿐이다** — 어느 쪽을 먼저 바꿔도 깨지지만, **이 파일의 타입과 + * 주석이 어느 버전을 기준으로 쓰였는지는 기계가 검사할 수 없다.** 지금이 정확히 그 사각이다 + * (아래 ⚠). 타입 ↔ 엔진 정합은 `docs/fig-extract-integration.md` §갱신 절차가 지키는 사람의 몫이고, + * 핀은 "그 절차를 다시 읽어라"는 알람일 뿐이다. */ -export const VENDORED_ENGINE_VERSION = '2.14.0'; +export const VENDORED_ENGINE_VERSION = '2.19.4'; /** pt 단위, 좌상단 원점 사각형 (엔진 좌표계) */ export interface EngineBox { @@ -33,18 +34,33 @@ export interface EngineBox { export interface EngineFigure { num: string; // "1", "3.1", "A.1", "IV" … - page: number; // 1-based + page: number; // 1-based — **그림이 실제로 있는 페이지** confidence: number; // 현재 1.0 고정 caption: string; // 캡션 전체 텍스트 (여러 줄 병합) - bboxPt: EngineBox; // 그림 영역만 — 캡션 제외 - captionBoxPt: EngineBox; // 캡션 블록 영역 + bboxPt: EngineBox; // 그림 영역만 — 캡션 제외. `page` 좌표계 + captionBoxPt: EngineBox; // 캡션 블록 영역. **`captionPage ?? page` 좌표계** (아래 참조) bboxPx: EngineBox; // 분석 렌더 픽셀 (pt × 2.2) - // `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.19.0 12-B: 캡션이 그림과 **다른 페이지**에 있을 때만 존재한다. 같은 페이지면 엔진이 아예 + * 실어 보내지 않으므로 `undefined`가 "캡션도 `page`에 있다"는 뜻이다. + * + * 방향은 **구조적으로 한쪽뿐이다**: 12-B는 캡션 페이지의 앵커에 대해 `candidatePage = 캡션 페이지 − 1` + * 후보만 만든다. 즉 `captionPage === page + 1`이 항상 성립한다 — 관측된 경향이 아니라 후보 생성 + * 규칙의 귀결이다. 그러므로 `Math.abs(captionPage - page)`나 앞/뒤 양방향 탐색 같은 일반화를 + * 넣지 말 것(엔진이 방향을 넓히면 이 주석과 타입이 먼저 바뀐다). + * + * 식별 키는 여전히 `(num, page)`이고 `page`는 그림 페이지다 — 페이지 점프·region은 `page`, + * **캡션 텍스트 검색과 `captionBoxPt` 좌표 변환은 `captionPage ?? page`**를 써야 한다. + * (`toPdfRect(f.captionBoxPt, …)`에 그림 페이지 높이를 넣으면 조용히 틀린다.) + */ + captionPage?: number; + // `cropCanvas` 필드와 `cropCanvas()` 접근자는 엔진 v2.19.1이 제거했다 ([BREAKING]). 이미지는 + // cropDataURL()/cropBlob()으로만 받으며, 소비자가 캔버스 수명을 관리할 필요는 없어졌다. + // ⚠ **선언에 없지만 런타임에서 load-bearing인 필드는 여전히 있다**: v2.19.1+는 크롭을 스캔 중 + // PNG로 직렬화해 `cropPng_`에 싣고, `cropDataURL`/`cropBlob`은 그 필드를 읽는 순수 접근자다 + // (v2.14.0에서는 같은 역할을 `cropCanvas`가 했다). 즉 엔진이 준 figure 객체를 **그대로** + // 넘겨야 한다 — 선언된 필드만으로 재구성하거나 structuredClone/JSON 왕복을 거치면 타입은 + // 통과하고 런타임에서 throw한다. (docs/fig-extract-integration.md §주의사항) } // v2.5.0: figure 식별 키 = (num, page). 같은 num이 다른 페이지에 복수 등장 가능 // (합본 논문·부록 번호 재시작 — #14). num 단독을 키로 쓰지 말 것 (toFigureEntries의 @@ -70,17 +86,37 @@ export interface ExtractOptions { /** v2.5.0+: 협조 취소 — 페이지 단위 체크, abort 시 AbortError로 reject (#12 문서 교체 대응). * v2.5.1+: abort 시 진행 중 페이지 렌더도 RenderTask.cancel()로 즉시 중단 */ signal?: AbortSignal; + /** + * v2.15.0+ `[필드 추가]`: 진단 전용. 명시적으로 준 실행에서만 anchor→candidate→selection→ + * dedup→emission의 scalar record 배열을 **문서 끝에 한 번** 전달한다. 미지정 시 recorder 자체를 + * 만들지 않는다 — 공개 출력은 지정 여부와 무관하게 동일하다. record 스키마는 엔진 repo 내부 + * 계약이라 여기서는 타입을 주지 않는다. + * + * ⚠ **record에는 사용자 PDF의 원문이 들어간다.** canvas/PNG/raw pixel은 없지만 앵커·후보마다 + * 캡션 줄 원문이 최대 180자씩 실린다(`fig-extract.js`의 `sourceText: …slice(0, 180)`). + * Margin은 사용자가 연 임의의 PDF를 다루므로, 이 콜백 출력을 콘솔에 남기거나 밖으로 보내면 + * 문서 내용이 함께 나간다. 로컬 디버깅 외의 용도로 쓰지 말 것. + */ + onDiagnostic?: (records: unknown[]) => void; + /** + * v2.19.1+ 진단 전용. `false`면 크롭 생성·PNG 직렬화를 통째로 생략한다 — `figures` 출력은 + * 완전히 동일하고 인코딩 비용만 사라진다. ⚠ 이 모드로 뽑은 figure에 `cropDataURL`/`cropBlob`을 + * 부르면 **throw**한다. 프리뷰 카드를 그리는 뷰어 경로에서는 쓰지 말 것. + */ + cropImages?: boolean; } export interface FigExtractApi { VERSION: string; - /** AbortError(취소) 외에 `name === 'FigRenderError'`로 reject될 수 있다 — 렌더 결과가 존재하지 - * 않는 경우다(메모리 압력을 받은 Chrome이 캔버스 백킹 스토어를 회수). 조용히 빈 그림을 내놓는 - * 대신 실패시킨다. 일시적 조건이므로 재시도가 유효하다 — FiguresTab의 "다시 시도" 경로가 적용된다. - * ⚠ **엔진 v2.19.1+ 에서만 발생한다. 현재 벤더링본 v2.14.0은 이 오류를 던지지 않으므로, - * 지금 `e.name === 'FigRenderError'` 분기를 쓰면 죽은 코드다** — 같은 상황에서 v2.14.0은 - * 오류 없이 백지 프리뷰를 낸다. 분기는 벤더링과 함께 넣을 것. */ + /** AbortError(취소) 외에 v2.19.1+는 `name === 'FigRenderError'`로 reject될 수 있다 — 렌더 결과가 + * 존재하지 않는 경우다(메모리 압력을 받은 Chrome이 캔버스 백킹 스토어를 회수). 조용히 빈 그림을 + * 내놓는 대신 실패시킨다. **일시적 조건이라 재시도가 유효하다** — FiguresTab이 이 이름을 분기해 + * 재시도 안내 문구를 띄운다. + * ⚠ `opts.renderPage`로 캔버스를 주입하면 불투명 배경 불변식이 없어 이 검사가 **적용되지 않는다** + * — Margin이 렌더 캐시를 주입하기 시작하면 이 보호도 함께 사라진다. */ extract(data: Uint8Array | null, opts?: ExtractOptions): Promise; + /** 엔진이 준 figure 객체를 그대로 넘길 것 (`cropPng_` 의존 — EngineFigure 주석 참조). + * `cropImages:false`로 추출한 figure에 대해서는 throw한다 (v2.19.1+). */ cropDataURL(fig: EngineFigure): string; cropBlob(fig: EngineFigure): Promise; } @@ -110,9 +146,57 @@ export function toPdfRect(b: EngineBox, pageHeightPt: number): PdfRect { /** * 엔진 결과 → FigureEntry 변환. * captionAnchor(S_p 오프셋)와 doc(fingerprint)은 호출 측(text-index를 가진 쪽)이 채운다: - * captionText를 해당 페이지 S_p에서 검색하면 오프셋을 얻을 수 있다. + * captionText를 **`captionPage`의** S_p에서 검색하면 오프셋을 얻을 수 있다. */ -export type FigureSeed = Omit; +export type FigureSeed = Omit & { + /** + * 캡션 텍스트가 실제로 있는 페이지 — `FigureEntry.captionAnchor.page`가 될 값이다. + * 그래서 `FigureEntry` 스키마는 넓히지 않는다: 목적지 필드가 이미 있고, seed는 그 값을 + * 계산하는 데 필요한 정보를 나르기만 한다. + * + * **optional이 아니라 항상 채운다** — 엔진의 `captionPage`는 `page`와 다를 때만 존재하는데, + * 그 optionality를 그대로 흘리면 호출 측이 `?? page` 보정을 잊어도 컴파일이 통과하고 + * captionAnchor 검색이 그림 페이지에서 조용히 실패한다(그게 이 항목이 벤더링 할 일에 + * 올라온 이유다). 경계에서 정규화해 그 실패 방식을 없앤다. + * + * ⚠ **영속 스키마(`FigureEntry`)에 넣지 말 것.** `captionAnchor.page`와 중복이고 storage는 + * 화이트리스트 없이 통째로 저장한다(`store.ts` `saveDoc`). TS strict도 스프레드 + * (`{ ...seed, doc, captionAnchor }`)에는 초과 속성 검사를 하지 않으므로 컴파일이 막아주지 + * 않는다 — 그래서 `toFigureEntry()`를 거치라는 것이다. + */ + captionPage: number; +}; + +/** + * `FigureSeed` + 호출 측이 계산한 `doc`·`captionAnchor` → 영속 스키마 `FigureEntry`. + * + * **이 함수의 존재 이유는 `captionPage`를 여기서 떨어뜨리는 것이다.** M3의 storage 저장 경로가 + * `{ ...seed, doc, captionAnchor }`를 쓰면 seed 전용 필드가 그대로 chrome.storage에 영속된다 + * (스프레드는 초과 속성 검사를 받지 않는다). 필드를 명시 나열하는 것도 의도적이다 — + * `FigureEntry`가 나중에 필드를 얻으면 여기서 컴파일 에러가 나 이 경계를 다시 보게 된다. + * + * `captionAnchor.page`가 `seed.captionPage`와 같아야 한다는 것은 **호출 측 책임**이다 + * (검색을 어느 페이지 S_p에서 했는지는 여기서 알 수 없다). + */ +export function toFigureEntry( + seed: FigureSeed, + doc: DocId, + captionAnchor: FigureEntry["captionAnchor"], +): FigureEntry { + return { + id: seed.id, + doc, + kind: seed.kind, + num: seed.num, + label: seed.label, + page: seed.page, + captionText: seed.captionText, + captionAnchor, + region: seed.region, + regionSource: seed.regionSource, + confidence: seed.confidence, + }; +} export function toFigureEntries( res: EngineResult, @@ -123,8 +207,10 @@ export function toFigureEntries( kind: "figure" as const, num: f.num, label: `Figure ${f.num}`, + /* 식별·점프·region은 모두 그림 페이지 기준이다 (식별 키 = (num, page)) */ page: f.page, captionText: f.caption, + captionPage: f.captionPage ?? f.page, region: { page: f.page, rect: toPdfRect(f.bboxPt, getPageHeightPt(f.page)) }, regionSource: "auto" as const, confidence: f.confidence, diff --git a/src/core/fig-extract.js b/src/core/fig-extract.js index 643f596..7788ada 100644 --- a/src/core/fig-extract.js +++ b/src/core/fig-extract.js @@ -15,14 +15,24 @@ * pdfDocument: doc, // 선택 — 이미 로드된 PDFDocumentProxy 재사용 (지정 시 data는 null 가능) * renderPage: async (pageNum, scale) => canvas, // 선택 — 호스트 렌더 캐시 주입 * signal: abortCtrl.signal, // 선택 — 협조 취소 (페이지 단위 체크, abort 시 AbortError throw) + * onDiagnostic: records => {}, // 선택 — PB-4A 구조화 관측 record 배열을 문서 끝에 1회 전달 + * cropImages: false, // 선택 — v2.19.1+ 진단 전용. 크롭 생성·PNG 직렬화를 통째로 생략한다. + * // 크롭은 감지 이후 단계라 figures 출력은 완전히 동일하고, PNG 인코딩· + * // 전송·저장 비용만 사라진다. 이 모드에서는 cropDataURL/cropBlob 사용 불가. * }); + * // throw: AbortError(취소) 외에 v2.19.1+ `FigRenderError` — 렌더 결과가 존재하지 않는 경우 + * // (메모리 부족으로 Chrome이 캔버스 백킹 스토어를 회수). 엔진이 직접 렌더한 페이지의 불투명 + * // 픽셀이 절반 미만이거나 크롭 캔버스가 투명할 때 발화하며, 불투명 배경 불변식이 없는 호스트 + * // 주입 캔버스(renderPage)에는 적용하지 않는다. 조용히 빈 그림을 내놓는 대신 실패시킨다 — + * // 일시적 조건이므로 호스트는 재시도를 제공하는 것이 적절하다. * // result = { title, numPages, engineVersion, figures: [...], suspectedMissing: ["1", ...] } - * // figure = { num, page, confidence, caption, bboxPt(그림만), captionBoxPt, bboxPx, cropCanvas } + * // figure = { num, page, confidence, caption, bboxPt(그림만), captionBoxPt, bboxPx } * // 식별 키 = (num, page). 같은 num이 다른 페이지에 복수 등장 가능 (v2.5.0+ — 합본·부록 번호 재시작) * // suspectedMissing = 감지된 정수 번호 1..최대 중 빠진 번호 (미탐지 의심 — 소비자가 무시해도 됨) - * // cropCanvas = 그림 영역만의 크롭 렌더 (scale 2.2). 페이지 전체 캔버스는 보관하지 않는다 (PDFViewer#12) - * // — 소비자는 프리뷰 생성 후 cropCanvas 참조를 버려 메모리를 회수할 수 있다 - * // 크롭 이미지: FigExtract.cropDataURL(fig) / FigExtract.cropBlob(fig) + * // 크롭 이미지: FigExtract.cropDataURL(fig) / FigExtract.cropBlob(fig) — 그림 영역만 (scale 2.2) + * // v2.19.1+: 크롭은 스캔 중 PNG로 직렬화된다. `figure.cropCanvas` 필드와 `cropCanvas()` 접근자는 + * // 제거됐다 ([BREAKING]) — 소비자가 캔버스 수명을 관리할 필요가 없어졌다 (이전에는 프리뷰 생성 후 + * // 참조를 버리라고 요구했다). 페이지 캔버스도 스캔 중 동시 상주 최대 1장이고 즉시 반환된다. * // 좌표: pt, 좌상단 원점. PDF user space 변환은 y' = pageHeight - y * * 알고리즘 설명과 각 규칙의 유래는 docs/ALGORITHM.md 참고. @@ -31,7 +41,58 @@ const FigExtract = (() => { -const VERSION = "2.14.0"; +const VERSION = "2.19.4"; +// 2.19.1: [BREAKING] 죽은 캔버스 내성 (백로그 B7 — 전수 배치 비결정성의 근본 원인). Chrome은 메모리 +// 압력을 받으면 캔버스 백킹 스토어를 예외 없이 회수하는데, 회수된 캔버스는 모든 그리기가 +// 무성과로 끝나고 읽으면 전면 투명이다. makeInk가 알파를 무시해 이걸 "잉크 100% 페이지"로 +// 읽어 유령 figure 2건·bbox 44.5pt 이동을 만들었고, 크롭은 전량 백지 PNG로 저장됐다 +// (v2.19.0 출하본에 23장 혼입). ① 잉크 판정을 흰 배경 합성으로 교정 — 불투명 픽셀은 합성식이 +// 항등이라 정상 렌더 출력은 완전 불변이다. ② 엔진이 직접 렌더한 페이지 캔버스의 불투명 픽셀이 +// 절반 미만이거나 크롭 캔버스가 투명하면 `FigRenderError`로 즉시 실패 (조용한 오염 방지). pdf.js가 +// 페이지를 불투명 흰색으로 채우고 시작하므로 정상 렌더의 기대 투명 비율은 0이고, 이 불변식이 없는 +// 호스트 주입 캔버스(`opts.renderPage`)에는 검사를 적용하지 않는다. ③ 크롭을 생성 즉시 PNG로 직렬화하고 +// 캔버스를 반환 — 문서 끝까지 캔버스를 쌓던 구조(논문당 최대 130MB)가 사라져 회수 자체가 +// 일어나지 않는다. `figure.cropCanvas` 필드와 `cropCanvas()` 접근자 제거([BREAKING]), +// `cropDataURL`/`cropBlob`은 시그니처 불변. ④ 진단 전용 `cropImages:false` 옵션(출력 불변). +// 2.19.4: [수정] v2.19.2/.3 적대 리뷰 후속 — ① capLineNorm도 글리프를 벗겨 비교(안 그러면 글리프 +// 앵커의 bareLabel이 항상 false가 되어 down/side/12-B 증거 분기가 통째로 죽는다) ② 선두 +// 글리프가 독립 조각일 때 임베디드 캡션 분해가 무발동이던 게이트 수정 ③ 글리프 열거를 관측된 +// 좌향 2자로 축소(우향은 양성 증거 0, 유일 사례가 비-캡션 불릿) ④ 12-B 방출이 emittedNums를 +// 갱신하도록 수정(같은 num 두 앵커 통과 시 F8 중단이 문서 전체를 실패시켰다) ⑤ 장식 띠로 +// 거부된 후보도 dedup 레코드를 남겨 살아남은 후보의 sole-identity-candidate 거짓 사유 제거. +// 2.19.3: [행동 변경] 지면 장식 띠 거부 — dedup **앞**에서 heightRatio < 0.05 ∧ widthRatio >= 0.80 +// 인 후보를 버린다(머리글 띠 오방출). dedup 뒤에 두면 띠가 우승한 뒤 죽고 진짜 후보는 이미 +// 버려져 복구 불가다. 12-B N−1 방출 경로에도 같은 바닥을 적용해 same-page에서 막은 띠가 +// adjacent로 새어나가지 않게 한다. +// 2.19.2: [행동 변경] 캡션 라벨 앞 삼각 글리프(◀◂◄▶▸►◁▹) 허용 — buildLines가 조판 장식을 캡션과 +// 같은 줄로 병합해 ^(Fig|Figure)가 깨지던 조판을 복구한다. hard·soft·자간분리 **세 판정 경로 +// 전부**에 같은 접두 제거를 적용하고, 길이 가드는 벗겨낸 문자열 기준으로 잰다. 원본 line.s는 +// 건드리지 않아 캡션 텍스트·박스 계산은 불변이다. +// 2.19.0: [행동 변경] PB-4B 12-B — 캡션이 다음 장 상단에 있고 직전 페이지에 주인 없는 합성 영역이 +// 있을 때 그 페이지에 figure를 **신규 방출**한다(page = 실제 그림 페이지, captionPage optional). +// 조건은 composition:confirmed ∧ unclaimed 3가드 ∧ 기존 방출 없음 ∧ 같은 num 미방출 ∧ +// textCoverage ≤ 0.50 ∧ selectionAreaRatio ≥ 0.30(잠정). 기존 상자 replace는 12-C로 계속 잠금이며 +// 같은 num이 이미 있으면 fail-closed로 중단한다. 불통과는 abstain = 이전 동작. +// 2.18.0: [계약 무변경] PB-4B 12-A strong 계측 — 선택 영역의 텍스트 점유율·면적·raster·잉크밀도를 +// adjacent-observation.strongMetrics로 방출. strong tri-state는 null 유지, 임계 없음. +// 2.17.0: [계약 무변경] PB-4B 12-A 관측 전용 기반. N−1 observer를 diagnostic callback과 +// 무관하게 항상 실행하고 기록만 optional로 분리해 graph off↔on이 같은 public 경로를 검증하게 +// 한다. 잔여 합집합(A)과 30pt seed 성장(B)의 합의는 새 composition tri-state로, target page의 +// owned 교차·다른 num anchor·다른 adjacent selection 세 가드는 unclaimed 관측 필드로만 기록한다. +// 기존 single은 region 정확히 1개라는 의미를 바꾸지 않고 null/deprecated로 동결한다. resolver, +// reassignment, public captionPage/page 변경은 없으며 suspectedMissing 계산만 observer 이후로 옮겼다. +// 2.16.0: [계약 무변경] 중첩 라벨 계열 경합 — 한 물리 캡션 줄이 "Extended Data Fig. N | Figure SN. …" +// 처럼 ED와 S 라벨을 함께 낳는 문서(저자 오제출 — Goldstein-2022)에서 임베디드 스플리터(v2.9.2)가 +// 같은 그림을 ED.N + 유령 S.N 둘로 방출하고, 형제 clamp가 ED.N 영역까지 축소했다. 줄의 identity +// (첫 라벨)가 ED면 같은 줄의 S 라벨은 형제 앵커로 만들지 않는다. 근거는 표기 관습이다 — 한 영역이 +// 동시에 Extended Data이면서 Supplementary일 수 없고, ED는 Nature 계열 전용이라 같은 문서가 같은 +// 그림에 S 번호를 겹쳐 붙이지 않는다. 겹침 임계 없이 "같은 물리 라인"이라는 사실만 쓴다. 형제가 +// 남지 않으면 분해 자체를 하지 않으므로 sibL/sibR clamp도 함께 사라진다(ED 영역 복원). +// 2.15.0: [필드 추가: optional opts.onDiagnostic] PB-4A observation infrastructure. 명시적으로 +// diagnostic callback을 준 실행에서만 anchor→candidate→selection→floor→dedup→emission의 scalar +// record를 문서 끝에 한 번 전달한다. canvas/PNG/raw pixel은 포함하지 않는다. callback 부재 시 recorder +// 객체·record를 만들지 않는다. 공개 result/figure 필드, 선택/score/crop/dedup 동작은 2.14.0과 동일하다. +// batch-runner `--graph`가 별도 paper-local JSONL/meta로 영속화하며 `--one`은 graph를 자동 활성화하지 않는다. // 2.14.0: [계약 무변경] soft 캡션 문서 게이트 강건화 — 혼합-관습 문서에서 hard 앵커 1개가 같은 문서 // soft 캡션을 전량 몰살하던 문제 해결. 두 갈래. **Part1**: soft 게이트의 hard 카운트를 "sep-증거"로 // 교정 — 구 hardCount(비-family 앵커를 sep·bare 무차별 1로 셈)를 hardBody = (비-family sep 앵커) @@ -334,16 +395,32 @@ function matchCaption(s, compact) { return null; } +/* 캡션 라벨 앞 삼각 글리프 (v2.19.2) — Nature Reviews "◀ Fig. 1 |", Springer "◂Fig. 5 …"처럼 + * 조판 장식이 라벨 앞에 붙는 조판이 있다. buildLines가 이 글리프를 캡션과 **같은 줄로 병합**하므로 + * (베이스라인 차 2.9pt < 허용치, x-갭 4.8pt < 8pt) `^(Fig|Figure)`가 깨져 앵커가 아예 생성되지 않는다. + * 판정 입력에서만 벗겨낸다 — line.s 원본은 그대로라 캡션 텍스트·박스 계산에 영향이 없다. + * ★ 코드포인트는 열거로 못 박는다: 전 코퍼스 330편에서 줄 시작 출현이 8건뿐이고 그중 figure 라벨을 + * 끄는 것은 대상 4건이다(나머지는 글리프 단독 줄과 "►Additional supplemental" 1건). 오탐 표면 0. + * ★ 길이 가드(12/14자)는 **벗겨낸 문자열 기준**으로 잰다 — 글리프는 캡션 텍스트가 아니므로 예산을 + * 먹지 않는다. 그 결과 "◀ Fig. 5"는 "Fig. 5"와 정확히 같게 판정된다. */ +/* v2.19.4: 열거를 **관측된 2자로 좁힌다**. 우향(▶▸►▹)은 캡션 접두로 관측된 적이 없고, + * 코퍼스의 유일한 우향 사례는 비-캡션 불릿("►Additional supplemental")이다 — SI 목록의 + * "► Figure S1. …" 같은 불릿을 앵커로 만들 위험만 있고 얻는 것이 없다. 새 사례가 관측되면 + * 그때 추가한다. */ +const CAPTION_LEAD_GLYPH_RE = /^[◀◂]\s*/; +const captionTestText = s => s.replace(CAPTION_LEAD_GLYPH_RE, ""); + function isCaption(line) { - let match = matchCaption(line.s, false); + const text = captionTestText(line.s); + let match = matchCaption(text, false); if (!match) { - const stripped = line.s.replace(/\s+/g, ""); + const stripped = text.replace(/\s+/g, ""); match = matchCaption(stripped, true); if (!match) return null; if (![".", ":", "|"].includes(match.sep) && stripped.length - match.lead > 12) return null; return match.num; } - if (![".", ":", "|"].includes(match.sep) && line.s.length - match.lead > 14) return null; + if (![".", ":", "|"].includes(match.sep) && text.length - match.lead > 14) return null; return match.num; } @@ -351,12 +428,15 @@ function isCaption(line) { * hard 구분자(. : |)는 항상, 무구분자(자간분리 "T A B L E N" 포함)는 짧을 때만 허용(isCaption 12/14자 가드). * 대문자 Table/TABLE만 매칭해 본문 "table 3. With longer ..." 소문자 상호참조를 배제한다. */ function isTableCaption(line) { - let m = TABLE_CAP_RE.exec(line.s); + /* v2.19.2: figure 라벨에만 글리프를 관용하면 같은 조판의 "◀ Table 3."이 경계로 인식되지 않아 + * 영역이 표를 넘어 자란다. 같은 규율을 적용한다. */ + const text = captionTestText(line.s); + let m = TABLE_CAP_RE.exec(text); if (m) { - if (![".", ":", "|"].includes(m[2]) && line.s.length > 14) return false; + if (![".", ":", "|"].includes(m[2]) && text.length > 14) return false; return true; } - const stripped = line.s.replace(/\s+/g, ""); + const stripped = text.replace(/\s+/g, ""); m = TABLE_CAP_RE2.exec(stripped); if (m) { if (![".", ":", "|"].includes(m[2]) && stripped.length > 12) return false; @@ -371,11 +451,15 @@ function isTableCaption(line) { * isCaption의 길이 가드(12/14자)는 soft 경로에 적용하지 않는다 — 그 자리를 * SOFT_BODY_RE(본문 대문자 시작)와 SOFT_SPACED_RE(자간 분리 한정)가 대신한다. */ function softCaptionOf(line) { - let m = SOFT_CAP_RE.exec(line.s); + /* v2.19.2: hard 경로(isCaption)와 **같은 접두 처리**를 여기에도 적용한다. soft는 matchCaption을 + * 거치지 않고 자기 정규식을 raw line에 직접 들이대므로, isCaption만 고치면 Springer "◂Fig. 5 …" + * 같은 무구분자 조판은 후보조차 생기지 않는다(자간분리 경로도 선두 글리프에서 즉시 실패한다). */ + const text = captionTestText(line.s); + let m = SOFT_CAP_RE.exec(text); if (m && SOFT_BODY_RE.test(m[3])) return { num: m[2].toUpperCase(), form: m[1].toLowerCase() }; - if (!SOFT_SPACED_RE.test(line.s)) return null; - m = SOFT_CAP_RE2.exec(line.s.replace(/\s+/g, "")); + if (!SOFT_SPACED_RE.test(text)) return null; + m = SOFT_CAP_RE2.exec(text.replace(/\s+/g, "")); if (m && SOFT_BODY_RE2.test(m[3])) return { num: m[2].toUpperCase(), form: "spaced" }; return null; @@ -411,14 +495,18 @@ function captionAnchors(lines, dbg) { }; const sameBaseline = (a, b) => Math.abs(bottom(b) - bottom(a)) < Math.max(2, b.h * 0.45); - const hardPrefix = u => /^(?:figure|fig)\.?$/i.test(u.s.replace(/\s+/g, "")); + /* v2.19.2: stitch 선두 조각도 글리프를 벗기고 본다 ("◀ Fig." + 8pt 초과 갭 + "1 | Title"). */ + const hardPrefix = u => /^(?:figure|fig)\.?$/i.test(captionTestText(u.s).replace(/\s+/g, "")); /* 기존 isCaption 결과는 clone하지 않고 동일 raw line 객체·순서로 보존한다. */ for (let i = 0; i < lines.length; i++) { const line = lines[i], num = isCaption(line); if (!num) continue; - let match = matchCaption(line.s, false); - if (!match) match = matchCaption(line.s.replace(/\s+/g, ""), true); + /* isCaption과 **같은 입력**으로 재조회해야 한다 — 여기서만 raw line을 쓰면 글리프 접두 라인의 + * match가 null이 되어 hard 판정이 조용히 soft로 강등된다 (v2.19.2). */ + const text = captionTestText(line.s); + let match = matchCaption(text, false); + if (!match) match = matchCaption(text.replace(/\s+/g, ""), true); slots[i] = line; ownerByPart.set(line, line); infoByAnchor.set(line, { @@ -440,7 +528,9 @@ function captionAnchors(lines, dbg) { * buildLines의 8pt 분리 규칙 자체는 건드리지 않는다 (Aslin 유래 앵커 보호). */ const embedded = new Map(); const hardLabelOf = frag => { - const m = matchCaption(frag.s.trim(), false); + /* v2.19.2: 첫 조각에 글리프가 붙으면 형제 분해가 통째로 무발동이 된다 + * ("◀Figure 14. … Figure 15. …"에서 Fig 15 이후가 조용히 소실). */ + const m = matchCaption(captionTestText(frag.s.trim()), false); return m && [".", ":", "|"].includes(m.sep) ? m.num.toUpperCase() : null; }; const joinFrags = fr => { @@ -456,10 +546,34 @@ function captionAnchors(lines, dbg) { const labelIdx = []; for (let k = 0; k < line.frags.length; k++) if (hardLabelOf(line.frags[k])) labelIdx.push(k); - if (labelIdx.length < 2 || labelIdx[0] !== 0) continue; + /* 선두 조판 글리프가 **독립 조각**이면 첫 라벨 인덱스가 0이 아니게 되어 형제 분해가 통째로 + * 무발동이 된다 (v2.19.4). 글리프만으로 이뤄진 선두 조각은 라벨 위치 계산에서 건너뛴다 — + * hardLabelOf에 글리프 관용을 넣는 것만으로는 이 게이트를 통과하지 못한다. */ + let firstContentIdx = 0; + while (firstContentIdx < line.frags.length && + captionTestText(line.frags[firstContentIdx].s).trim() === "") firstContentIdx++; + if (labelIdx.length < 2 || labelIdx[0] !== firstContentIdx) continue; + + /* ★ 중첩 라벨 계열 경합 (v2.16.0) — 줄의 identity(첫 라벨)가 ED인데 같은 줄에 S 라벨이 섞여 + * 있으면 그 S는 형제로 만들지 않는다. 한 영역이 동시에 Extended Data이면서 Supplementary일 수 + * 없고, ED는 Nature 계열 전용 표기라 같은 문서가 같은 그림에 S 번호를 겹쳐 붙이지 않는다 + * (Goldstein-2022은 저자가 옛 S 표기를 ED 캡션 본문에 남긴 오제출). 남는 라벨이 1개면 분해 + * 자체를 하지 않으므로 loop-1의 ED 앵커가 그대로 줄을 소유하고 sibL/sibR clamp도 생기지 않는다. + * 겹침 임계나 기하는 쓰지 않는다 — 근거는 "같은 물리 캡션 라인"이라는 사실뿐이다. */ + const famOf = k => { + const n = hardLabelOf(line.frags[k]); + return /^ED\./.test(n) ? "ED" : /^S\./.test(n) ? "S" : "other"; + }; + let keptIdx = labelIdx; + if (famOf(labelIdx[0]) === "ED" && labelIdx.some(k => famOf(k) === "S")) { + keptIdx = labelIdx.filter(k => famOf(k) !== "S"); + if (dbg) dbg(` [split] line T${line.top.toFixed(0)} ED>S suppress ` + + labelIdx.filter(k => famOf(k) === "S").map(k => hardLabelOf(line.frags[k])).join("/")); + } + if (keptIdx.length < 2) continue; - const segs = labelIdx.map((start, n) => - line.frags.slice(start, n + 1 < labelIdx.length ? labelIdx[n + 1] : line.frags.length)); + const segs = keptIdx.map((start, n) => + line.frags.slice(start, n + 1 < keptIdx.length ? keptIdx[n + 1] : line.frags.length)); const made = segs.map(fr => { const main = fr.reduce((a, b) => a.w >= b.w ? a : b); return { ...boxOf(fr), s: joinFrags(fr), font: main.font }; @@ -553,7 +667,10 @@ function captionAnchors(lines, dbg) { * ⚠ bareForms는 bare 전용 multiset이다 — softSlots.form(승격 formCount)과 물리적으로 분리한다. * 고립 bare "Figure 7"(Hao)의 form이 soft 8앵커 form과 같은 키라 공유 맵이면 반복 오분류된다. */ const bareFormOf = ln => { - const s = (ln.s || "").replace(/\s+/g, " ").trim(); + /* v2.19.2: 여기서 글리프를 안 벗기면 "◂Fig. 5 …"가 form "spaced"로 **오분류**되어 + * bareFormCount가 오염되고, 그 집계가 문서 전역 soft 체제(전량승격/애매역/전량기각)를 + * 뒤집을 수 있다 — 파급이 그 라인 하나에 국한되지 않는 유일한 누락 지점이었다. */ + const s = captionTestText((ln.s || "").replace(/\s+/g, " ").trim()); return /^figure\b/i.test(s) ? "figure" : (/^fig\b|^fig\.?\d/i.test(s) ? "fig" : "spaced"); }; const bareForms = []; @@ -581,7 +698,7 @@ function captionAnchors(lines, dbg) { * 경계 — 잠정승격 후 up-점수 floor로 진짜 캡션만 남긴다(구 게이트는 이 문서들의 soft를 전멸시켰다). * 승격 앵커는 hard:false·parts 1개로 기존 "구분자 없는 앵커"와 구조적으로 동일 → 후보 생성·채점·선택 * 경로 불변. 애매역 앵커만 provisional:true로 표시해 pass2의 floor 검증 대상이 됨. */ -function promoteSoftAnchors(pageData, dbg) { +function promoteSoftAnchors(pageData, dbg, diag) { let hardSepTotal = 0, softTotal = 0; const formCount = new Map(); // soft 라벨폼 (승격 form-rep 판정) — bare와 분리 const bareFormCount = new Map(); // ★ bare 전용 multiset (문서-레벨 반복 판정, formCount와 disjoint) @@ -603,13 +720,50 @@ function promoteSoftAnchors(pageData, dbg) { const bareInfo = [...bareFormCount].map(([f, n]) => `${f}:${n}`).join(",") || "-"; const regime = hardBody === 0 ? "promote-all" : hardBody < SOFT_GATE_K ? "ambiguous" : "reject-all"; + const recordRejectedSoft = diag ? ((pd, c, reason, repetitions) => { + const anchorId = `p${pd.num}/l${c.index}/e0`; + diag.add("anchor", { + anchorId, captionPage: pd.num, num: c.num, + numFamily: /^ED\./.test(String(c.num)) ? "extended-data" + : /^S\./.test(String(c.num)) ? "supplementary" + : /^[A-D]\./.test(String(c.num)) ? "appendix" + : /^\d/.test(String(c.num)) ? "main" : "roman-or-other", + sourceLineIndex: c.index, embeddedOrdinal: 0, + sourceText: String(c.line.s || "").replace(/\s+/g, " ").trim().slice(0, 180), + sourceTextHash: diag.textHash(c.line.s), + sourceParts: [{ + index: 0, lineIndex: c.index, bboxPt: diag.boxPt(c.line), + textHash: diag.textHash(c.line.s), + }], + anchorBoxPt: diag.boxPt(c.line), labelBoxPt: diag.boxPt(c.line), + hard: false, soft: true, provisional: false, stitched: false, embedded: false, + active: false, form: c.form, + }); + diag.add("anchor-gate", { + anchorId, captionPage: pd.num, num: c.num, + decision: "rejected", reasons: [reason], form: c.form, repetitions, + }); + }) : null; + if (diag) diag.add("soft-gate", { + decision: regime, + hardBody, + hardSep: hardSepTotal, + bareRepeat: bareRep, + softTotal, + formCount: Object.fromEntries(formCount), + bareFormCount: Object.fromEntries(bareFormCount), + }); dbg(`[doc] SOFT gate hardBody=${hardBody} (sep=${hardSepTotal} bareRep=${bareRep} bare=[${bareInfo}])` + ` soft=${softTotal} forms=${forms} regime=${regime}`); /* {2,3} 저-hardBody 기각은 현 코퍼스 공witness(공집합) — 혼합-관습 soft 문서가 여기 걸리면 침묵 * 표적 손실이다. 전수 diff에서 표면화되도록 NOTE 방출(현재 미발동, 미래 대비 계측). */ if (regime === "reject-all" && hardBody <= 3) dbg(`[doc] SOFT gate NOTE reject-all with low hardBody=${hardBody} — verify not a soft-target doc`); - if (regime === "reject-all") return; + if (regime === "reject-all") { + if (diag) for (const pd of pageData) for (const c of pd.captionData.softSlots) + recordRejectedSoft(pd, c, "soft-document-gate", formCount.get(c.form) || 0); + return; + } const provisional = regime === "ambiguous"; for (const pd of pageData) { @@ -619,6 +773,7 @@ function promoteSoftAnchors(pageData, dbg) { const reps = formCount.get(c.form); if (reps < SOFT_FORM_MIN) { dbg(`[doc] SOFT reject p${pd.num} num=${c.num} form=${c.form} reason=form-rep(${reps})`); + if (diag) recordRejectedSoft(pd, c, "soft-form-repetition", reps); continue; } cd.slots[c.index] = c.line; @@ -634,6 +789,14 @@ function promoteSoftAnchors(pageData, dbg) { labelBox: { left: c.line.left, w: c.line.w, top: c.line.top, h: c.line.h } }); promoted++; + if (diag) { + const anchorId = diag.registerAnchor(pd, c.line); + diag.add("anchor-gate", { + anchorId, captionPage: pd.num, num: c.num, + decision: provisional ? "provisional" : "promoted", + reasons: [regime], form: c.form, repetitions: reps, + }); + } dbg(`[doc] SOFT ${provisional ? "provisional" : "promote"} p${pd.num} num=${c.num} form=${c.form}` + ` s=${JSON.stringify(c.line.s.slice(0, 50))}`); } @@ -725,10 +888,14 @@ function subpanelStoppers(lines, stoppers, dom) { } /* ===================== 이미지 XObject bbox (CTM 추적) ===================== */ -async function getImageBoxes(page, pageH) { +async function getImageBoxes(page, pageH, onError) { let opsList; try { opsList = await page.getOperatorList(); } - catch (e) { console.error("getOperatorList 실패:", e); return []; } + catch (e) { + console.error("getOperatorList 실패:", e); + if (onError) onError(e); + return []; + } const O = pdfjsLib.OPS, stack = [], boxes = []; let ctm = [1, 0, 0, 1, 0, 0]; for (let i = 0; i < opsList.fnArray.length; i++) { @@ -750,20 +917,328 @@ async function getImageBoxes(page, pageH) { return boxes; } +/* 렌더 실패(죽은 캔버스) 전용 오류 — 소비자가 name으로 분기할 수 있게 별도 이름을 준다 (B7). + * Chrome은 메모리 압력을 받으면 캔버스 백킹 스토어를 예외 없이 회수한다. 회수된 캔버스는 모든 + * 그리기 연산이 무성과로 끝나고 읽으면 전부 투명 검정이라, 아래 잉크 판정에 "잉크로 꽉 찬 페이지"로 + * 보였다 — 유령 figure와 44.5pt bbox 이동의 원인이다. 조용한 오염보다 즉시 실패가 낫다. */ +function figRenderError(message) { + const error = new Error(message); + error.name = "FigRenderError"; + return error; +} +const isFigRenderError = error => !!error && error.name === "FigRenderError"; + /* ===================== 잉크 그리드 (렌더 픽셀의 명암 이진화) ===================== */ -function makeInk(canvas) { - const ctx = canvas.getContext("2d"); - const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); +/* 알파는 흰 배경 위 합성으로 처리한다 (B7) — 불투명 픽셀(α=255)은 합성식이 항등이라 판정이 + * 완전히 불변이고, 칠해지지 않은 픽셀(α=0)만 흰색(= 여백)이 된다. */ +function makeInk(canvas, canary = true) { const W = canvas.width, H = canvas.height; + /* 0×0 캔버스는 getImageData가 IndexSizeError를 던진다. `canvas.width = 0`은 이 파일이 쓰는 + * 백킹 스토어 반환 관용구이므로, 이미 해제된 캔버스를 넘겨받은 것으로 보고 죽은 캔버스와 + * 같게 취급한다 — 안 그러면 일반 오류로 새어나가 adjacent 경로에서 조용히 삼켜진다. + * **카나리아와 같은 게이트를 쓴다**: 호스트 주입 캔버스까지 여기서 fail-fast시키면 관측 + * 계층의 graceful degradation을 없애 문서 전체를 실패시킨다 (canary=false의 취지에 반한다). */ + if (canary && (W <= 0 || H <= 0)) + throw figRenderError(`페이지 캔버스가 해제돼 있습니다 (${W}×${H})`); + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(0, 0, W, H); const ink = new Uint8Array(W * H); + let opaquePx = 0; for (let i = 0, p = 0; i < data.length; i += 4, p++) { - const l = 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]; + const a = data[i + 3]; + let l = 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]; + if (a !== 255) { const k = a / 255; l = l * k + 255 * (1 - k); } if (l < 235) ink[p] = 1; + if (a !== 0) opaquePx++; } + /* 카나리아 — **엔진이 직접 렌더한 캔버스에서만** 켠다. pdf.js는 페이지 내용을 그리기 전에 + * 캔버스를 불투명 흰색으로 채우므로 정상 렌더는 100% α=255다. 즉 투명 픽셀이 많다는 것은 + * 백킹 스토어가 회수됐다는 뜻이다. 호스트 주입 캔버스(opts.renderPage)는 이 불변식이 없어 + * (투명 배경으로 렌더하는 호스트가 정상일 수 있다) 호출부가 canary=false로 끈다. + * + * 전면(=0)이 아니라 비율로 보는 이유: Chromium은 렌더 도중에도 백킹 스토어를 버리고 다음 + * 그리기에서 빈 버퍼를 재할당할 수 있어, 앞부분만 그려진 페이지가 나올 수 있다. 그 경우 + * 위 합성이 투명 영역을 "흰 종이"로 읽어 **조용히** 틀린 검출을 낸다(알파 무시 시절에는 + * 최소한 요란하게 틀렸다). 0.5는 잠정 문턱 — 정상 렌더의 기대 투명 비율이 0이라 여유가 + * 극단적으로 크고, 전수 실행에서 발화 0건이면 그 자체가 문턱 검증이다. */ + if (canary && opaquePx < 0.5 * W * H) + throw figRenderError(`페이지 렌더 결과가 비어 있습니다 (${W}×${H} 중 불투명 ${opaquePx}px — 메모리 부족으로 캔버스가 회수됐을 수 있습니다)`); return { ink, W, H }; } const inkAt = (g, x, y) => (x >= 0 && y >= 0 && x < g.W && y < g.H) ? g.ink[y * g.W + x] : 0; +/* PB-4A N−1 관측 primitive. 기존 up/down scan의 기계적 row noise floor와 4.8pt + * whitespace separator만 재사용한다. 이 함수들은 후보를 만들거나 점수/선택에 관여하지 않는다. */ +function adjacentInkBands(grid) { + const rowInk = new Int32Array(grid.H); + const threshold = Math.max(2, Math.floor(0.002 * grid.W)); + for (let y = 0; y < grid.H; y++) { + let count = 0; + for (let x = 0; x < grid.W; x++) count += inkAt(grid, x, y); + rowInk[y] = count; + } + const separatorPx = Math.round(4.8 * S); + const bands = []; + let y = 0; + while (y < grid.H) { + while (y < grid.H && rowInk[y] <= threshold) y++; + if (y >= grid.H) break; + let y0 = y, y1 = y + 1, lastInk = y, gap = 0; + while (y < grid.H) { + if (rowInk[y] > threshold) { + lastInk = y; + gap = 0; + } else if (++gap >= separatorPx) { + break; + } + y++; + } + y1 = lastInk + 1; + /* 같은 vertical band 안에서도 separator 너비 이상의 실제 수평 whitespace로 분할한다. + * minX/maxX 하나로 축약하면 좌우 disjoint XObject를 빈 중앙 bbox가 bridge하는 오류가 난다. */ + const colInk = new Int32Array(grid.W); + for (let yy = y0; yy < y1; yy++) + for (let x = 0; x < grid.W; x++) colInk[x] += inkAt(grid, x, yy); + let x = 0; + while (x < grid.W) { + while (x < grid.W && colInk[x] === 0) x++; + if (x >= grid.W) break; + let x0 = x, lastInkX = x, horizontalGap = 0; + while (x < grid.W) { + if (colInk[x] > 0) { + lastInkX = x; + horizontalGap = 0; + } else if (++horizontalGap >= separatorPx) { + break; + } + x++; + } + const x1 = lastInkX + 1; + let tightY0 = y1, tightY1 = y0, inkPixels = 0; + for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) { + if (!inkAt(grid, xx, yy)) continue; + tightY0 = Math.min(tightY0, yy); + tightY1 = Math.max(tightY1, yy + 1); + inkPixels++; + } + if (tightY1 > tightY0) bands.push({ + kind: "ink-band", bboxPx: { x0, y0: tightY0, x1, y1: tightY1 }, + rowStart: y0, rowEnd: y1, rowInkMax: Math.max(...rowInk.slice(y0, y1)), + inkPixels, inkDensity: inkPixels / Math.max(1, (x1 - x0) * (tightY1 - tightY0)), + threshold, separatorPx, + }); + x = Math.max(x + 1, x1); + } + y = Math.max(y + 1, y1); + } + return bands; +} + +const adjacentIntersection = (a, b) => { + const w = Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)); + const h = Math.max(0, Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0)); + return w * h; +}; +const adjacentArea = box => Math.max(0, box.x1 - box.x0) * Math.max(0, box.y1 - box.y0); +const adjacentPxBoxToPt = box => ({ + x0: box.x0 / S, y0: box.y0 / S, x1: box.x1 / S, y1: box.y1 / S, +}); + +function adjacentRegions(seeds) { + const parent = seeds.map((_, i) => i); + const find = i => parent[i] === i ? i : (parent[i] = find(parent[i])); + const join = (a, b) => { + a = find(a); b = find(b); + if (a !== b) parent[Math.max(a, b)] = Math.min(a, b); + }; + for (let i = 0; i < seeds.length; i++) + for (let j = i + 1; j < seeds.length; j++) + if (adjacentIntersection(seeds[i].bboxPx, seeds[j].bboxPx) > 0) join(i, j); + const groups = new Map(); + for (let i = 0; i < seeds.length; i++) { + const root = find(i); + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(seeds[i]); + } + return [...groups.values()].map(group => { + const bboxPx = { + x0: Math.min(...group.map(seed => seed.bboxPx.x0)), + y0: Math.min(...group.map(seed => seed.bboxPx.y0)), + x1: Math.max(...group.map(seed => seed.bboxPx.x1)), + y1: Math.max(...group.map(seed => seed.bboxPx.y1)), + }; + return { + seeds: group, + bboxPx, + sourceKinds: [...new Set(group.map(seed => seed.kind))].sort(), + }; + }).sort((a, b) => a.bboxPx.y0 - b.bboxPx.y0 || a.bboxPx.x0 - b.bboxPx.x0 || + a.bboxPx.y1 - b.bboxPx.y1 || a.bboxPx.x1 - b.bboxPx.x1); +} + +/* ===================== PB-4A 구조화 관측 (output-neutral) ===================== + * callback이 없으면 null을 반환하고 아래 모든 callsite가 건너뛴다. ID는 물리 source slot/pass/direction/ + * seed ordinal만 사용해 bbox·raster AA·worker/runtime이 topology identity를 바꾸지 않게 한다. */ +const diagnosticTextHash = text => { + let h = 0x811c9dc5; + for (const ch of String(text || "").replace(/\s+/g, " ").trim()) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); +}; + +function makeDiagnosticRecorder(callback) { + if (typeof callback !== "function") return null; + const records = []; + const anchorIds = new WeakMap(), candidateMeta = new WeakMap(), figCandidateIds = new WeakMap(); + const candidateAnchorById = new Map(), selectionByAnchor = new Map(); + const emissionByCandidate = new Map(), claimByCandidate = new Map(); + const finite = value => { + if (typeof value !== "number") return value; + if (Number.isNaN(value)) return "nan"; + if (value === Infinity) return "inf"; + if (value === -Infinity) return "-inf"; + if (Object.is(value, -0)) return 0; + return Math.round(value * 1e6) / 1e6; + }; + const scalar = value => { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") return finite(value); + if (value === undefined) return undefined; + if (Array.isArray(value)) return value.map(v => scalar(v)); + const out = {}; + for (const key of Object.keys(value).sort()) { + const v = scalar(value[key]); + if (v !== undefined) out[key] = v; + } + return out; + }; + const textHash = diagnosticTextHash; + const boxPt = box => box ? { + x0: box.x0 ?? box.left, + y0: box.y0 ?? box.top, + x1: box.x1 ?? ((box.left ?? 0) + (box.w ?? 0)), + y1: box.y1 ?? ((box.top ?? 0) + (box.h ?? 0)), + } : null; + const pxBoxToPt = box => box ? { + x0: box.x0 / S, y0: box.y0 / S, x1: box.x1 / S, y1: box.y1 / S, + } : null; + const add = (record, data = {}) => { + const normalized = scalar({ seq: records.length, record, ...data }); + records.push(normalized); + if (record === "candidate" && normalized.candidateId && normalized.anchorId) + candidateAnchorById.set(normalized.candidateId, normalized.anchorId); + else if (record === "selection" && normalized.anchorId) + selectionByAnchor.set(normalized.anchorId, normalized); + else if (record === "emission" && normalized.candidateId) + emissionByCandidate.set(normalized.candidateId, normalized); + else if (record === "claim" && normalized.candidateId) + claimByCandidate.set(normalized.candidateId, normalized); + }; + const family = num => /^ED\./.test(String(num)) ? "extended-data" + : /^S\./.test(String(num)) ? "supplementary" + : /^[A-D]\./.test(String(num)) ? "appendix" + : /^\d/.test(String(num)) ? "main" : "roman-or-other"; + const registerAnchor = (pd, cap) => { + if (anchorIds.has(cap)) return anchorIds.get(cap); + const cd = pd.captionData, info = cd.infoByAnchor.get(cap); + let slot = -1, embeddedOrdinal = 0; + for (let i = 0; i < cd.slots.length; i++) { + if (cd.slots[i] === cap) { slot = i; break; } + const embedded = cd.embedded.get(i) || []; + const ei = embedded.indexOf(cap); + if (ei >= 0) { slot = i; embeddedOrdinal = ei + 1; break; } + } + if (slot < 0 && info && info.parts && info.parts.length) + slot = pd.lines.indexOf(info.parts[0]); + if (slot < 0) + throw new Error(`diagnostic anchor source slot을 찾지 못했습니다 (p${pd.num}).`); + const id = `p${pd.num}/l${slot}/e${embeddedOrdinal}`; + anchorIds.set(cap, id); + const sourceText = (info && info.parts ? info.parts : [cap]) + .map(line => line.s || "").join(" ").replace(/\s+/g, " ").trim(); + add("anchor", { + anchorId: id, + captionPage: pd.num, + num: info && info.num, + numFamily: family(info && info.num), + sourceLineIndex: slot, + embeddedOrdinal, + sourceText: sourceText.slice(0, 180), + sourceTextHash: textHash(sourceText), + sourceParts: (info && info.parts ? info.parts : [cap]).map((line, index) => ({ + index, lineIndex: pd.lines.indexOf(line), bboxPt: boxPt(line), textHash: textHash(line.s), + })), + anchorBoxPt: boxPt(cap), + labelBoxPt: boxPt(info && info.labelBox), + hard: !!(info && info.hard), + soft: !!(info && info.soft), + provisional: !!(info && info.provisional), + stitched: !!(info && info.stitched), + embedded: !!(info && info.embedded), + form: info && info.form, + }); + return id; + }; + const registerCandidate = (candidate, meta) => { + const candidateId = `${meta.anchorId}/pass${meta.pass}/${meta.direction}/` + + `${meta.seedSource || "none"}/${meta.seedOrdinal || 0}`; + const full = { ...meta, candidateId }; + candidateMeta.set(candidate, full); + if (candidate && candidate.fig) figCandidateIds.set(candidate.fig, candidateId); + return candidateId; + }; + const candidateId = candidate => candidate && candidateMeta.get(candidate)?.candidateId || null; + const figCandidateId = fig => fig && figCandidateIds.get(fig) || null; + const emitCandidate = (candidate, extra = {}) => { + const meta = candidateMeta.get(candidate); + if (!meta) throw new Error("diagnostic candidate metadata가 없습니다."); + const fig = candidate.fig || null; + add("candidate", { + ...meta, + valid: !!candidate.valid, + rejectReason: candidate.rejectReason || null, + reasons: candidate.rejectReason ? candidate.rejectReason.split(",").filter(Boolean) : [], + regionBoxPx: meta.regionBoxPx || null, + regionBoxPt: pxBoxToPt(meta.regionBoxPx), + outputBoxPx: fig ? { x0: fig.x0, y0: fig.y0, x1: fig.x1, y1: fig.y1 } : null, + outputBoxPt: fig ? pxBoxToPt({ x0: fig.x0, y0: fig.y0, x1: fig.x1, y1: fig.y1 }) : null, + metrics: candidate.metrics || null, + score: candidate.score || null, + ...extra, + }); + }; + const anchorState = anchorId => { + const selection = selectionByAnchor.get(anchorId) || null; + const candidateId = selection && selection.chosenCandidateId || null; + const emission = candidateId ? emissionByCandidate.get(candidateId) || null : null; + const claim = candidateId ? claimByCandidate.get(candidateId) || null : null; + return scalar({ + chosenCandidateId: candidateId, + chosenDirection: selection && selection.chosenDirection || null, + selection: selection && selection.decision || "none", + emission: emission && emission.decision || "none", + claim: claim && claim.decision || "none", + }); + }; + const ownedClaims = () => [...claimByCandidate.entries()] + .filter(([, claim]) => claim.decision === "owned") + .map(([candidateId, claim]) => scalar({ + candidateId, + anchorId: candidateAnchorById.get(candidateId) || null, + num: claim.num, + page: claim.page, + outputBoxPx: claim.outputBoxPx, + outputBoxPt: claim.outputBoxPt, + })); + return { + add, boxPt, pxBoxToPt, scalar, textHash, registerAnchor, registerCandidate, emitCandidate, + candidateId, figCandidateId, anchorState, ownedClaims, + finish: () => callback(records), + }; +} + /* 방향별 후보가 공유하는 순수 채점기. 후보 생성기(up/down, Phase 2: left/right)는 평범한 수치 지표만 이 함수에 넘기고, 선택기는 동일 점수축으로 비교한다. */ const clamp01 = v => Math.max(0, Math.min(1, v)); @@ -825,9 +1300,12 @@ function chooseCandidate(up, alternatives, policy) { } /* ===================== 페이지 단위 감지 (핵심) ===================== */ -function detectPage(pg, lines, dom, grid, dbg, captionData) { +function detectPage(pg, lines, dom, grid, dbg, captionData, diag, pass = 0) { const figs = []; const { anchors: caps, ownerByPart, infoByAnchor } = captionData; + if (diag) diag.add("detection-pass", { + page: pg.num, pass, decision: "started", anchorCount: caps.length, + }); const otherCaps = (blockLines, cap) => [...new Set(blockLines .map(u => ownerByPart.get(u)).filter(owner => owner && owner !== cap))]; // stopper: 도달하면 figure 영역 상한으로 간주하는 "본문 줄" @@ -848,6 +1326,13 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { dbg(` tables=${tableStop.size} ${[...tableStop].slice(0, 4).map(u => JSON.stringify(u.s.slice(0, 22))).join(" ")}`); for (const cap of caps) { const capInfo = infoByAnchor.get(cap); + const anchorId = diag ? diag.registerAnchor(pg, cap) : null; + /* PB-4B observer가 graph 유무와 무관하게 같은 lifecycle을 보도록 public output과 분리한 내부 + * scalar 상태다. detectPage 재실행 때마다 초기화되어 active floor pass만 남는다. */ + capInfo.adjacentState_ = { + selection: "none", chosenDirection: null, selectedFig: null, + emission: "none", claim: "none", + }; capInfo.upScore_ = undefined; // 매 앵커 초기화 (v2.14.0) — legacyBelow continue가 아래 갱신을 건너뛰어도 // 이전 detectPage 재실행의 값이 남지 않게 (soft floor 판정 오염 방지) const num = capInfo.num; @@ -864,7 +1349,9 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { `${capInfo.sibR == null ? "-" : capInfo.sibR.toFixed(0)}] text=${JSON.stringify(cap.s.slice(0, 50))}`); /* 1) 캡션 블록 확장 (여러 줄 캡션 흡수) + 캡션 전체 텍스트 수집 */ let capBottom = cap.top + cap.h, colL = cap.left, colR = cap.left + cap.w; - let capText = cap.s; + /* 방출 caption 문자열에서도 선두 글리프를 벗긴다 (v2.19.2) — 조판 장식이지 캡션 텍스트가 + * 아니다. 상자(captionBox)는 원본 라인 기하 그대로라 영향이 없다. */ + let capText = captionTestText(cap.s); const ownCaptionLines = new Set(capInfo.parts); for (const u of [...lines].sort((a, b) => a.top - b.top)) { if (ownerByPart.has(u)) continue; @@ -880,6 +1367,75 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { } const capbox = { left: colL, w: colR - colL, top: cap.top }; const captionBox = { x0: colL, y0: cap.top, x1: colR, y1: capBottom }; // pt, 좌상단 원점 + /* 12-B N−1 방출이 caption page 좌표계의 캡션 텍스트·박스를 그대로 재사용하도록 보존한다. + * public output에는 나가지 않는 내부 필드다 (v2.19.0). */ + capInfo.captionTextObserved_ = capText; + capInfo.captionBoxObserved_ = captionBox; + if (diag) diag.add("anchor-evaluation", { + anchorId, captionPage: pg.num, candidatePage: pg.num, pass, num, + decision: "evaluated", anchorBoxPt: diag.boxPt(cap), + captionBoxPt: captionBox, + captionTextHash: diag.textHash(capText), + captionParts: [...ownCaptionLines] + .map(line => ({ + lineIndex: lines.indexOf(line), bboxPt: diag.boxPt(line), + textHash: diag.textHash(line.s), + })) + .sort((a, b) => a.lineIndex - b.lineIndex), + edgeDistancePt: { + top: captionBox.y0, + right: pg.w - captionBox.x1, + bottom: pg.h - captionBox.y1, + left: captionBox.x0, + }, + }); + const diagnosticAttempt = (direction, seedSource, seedOrdinal, decision, reasons, extra = {}) => { + if (!diag) return; + diag.add("candidate-attempt", { + anchorId, captionPage: pg.num, candidatePage: pg.num, pass, num, + direction, seedSource: seedSource || "none", seedOrdinal: seedOrdinal || 0, + decision, reasons, ...extra, + }); + }; + const registerDiagnosticCandidate = (candidate, direction, seedSource, seedOrdinal, regionBoxPx, + extra = {}) => { + if (!diag) return null; + const regionArea = regionBoxPx + ? Math.max(0, regionBoxPx.x1 - regionBoxPx.x0) * Math.max(0, regionBoxPx.y1 - regionBoxPx.y0) + : 0; + let imageIntersectionPx2 = 0; + const imageRefs = []; + if (regionBoxPx) for (let imageIndex = 0; imageIndex < pg.images.length; imageIndex++) { + const im = pg.images[imageIndex]; + const imageBoxPx = { + x0: im.left * S, y0: im.top * S, + x1: (im.left + im.w) * S, y1: (im.top + im.h) * S, + }; + const ix = Math.max(0, Math.min(regionBoxPx.x1, imageBoxPx.x1) + - Math.max(regionBoxPx.x0, imageBoxPx.x0)); + const iy = Math.max(0, Math.min(regionBoxPx.y1, imageBoxPx.y1) + - Math.max(regionBoxPx.y0, imageBoxPx.y0)); + const intersectionPx2 = ix * iy; + if (!intersectionPx2) continue; + const imageArea = Math.max(0, imageBoxPx.x1 - imageBoxPx.x0) + * Math.max(0, imageBoxPx.y1 - imageBoxPx.y0); + imageIntersectionPx2 += intersectionPx2; + imageRefs.push({ + imageId: `p${pg.num}/image${imageIndex}`, + bboxPt: { x0: im.left, y0: im.top, x1: im.left + im.w, y1: im.top + im.h }, + intersectionPx2, + regionCoverage: regionArea > 0 ? intersectionPx2 / regionArea : 0, + imageContainment: imageArea > 0 ? intersectionPx2 / imageArea : 0, + }); + } + return diag.registerCandidate(candidate, { + anchorId, captionPage: pg.num, candidatePage: pg.num, pass, num, + direction, seedSource: seedSource || "none", seedOrdinal: seedOrdinal || 0, + regionBoxPx, imageRefs, + imageCoverage: regionArea > 0 ? Math.min(1, imageIntersectionPx2 / regionArea) : 0, + ...extra, + }); + }; /* 2) 예비 상한: stopper 스캔 (x-확장 판단용) */ let yPre = 40; @@ -1153,7 +1709,11 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { const downStart = Math.max(0, Math.round(capBottom * S) + 2); const capNorm = capText.replace(/\s+/g, " ").trim(); - const capLineNorm = cap.s.replace(/\s+/g, " ").trim(); + /* ★ 양쪽 모두 글리프를 벗긴 값으로 비교해야 한다 (v2.19.4). capText만 벗기고 cap.s를 raw로 + * 두면 글리프 앵커는 두 문자열이 정의상 달라 bareLabel이 **항상 false**가 되고, 거기 매달린 + * empty·bareDown·huge·otherCap·suspect·healthyBareUp·adjacentEvidence 분기가 통째로 죽는다 + * — "글리프 유무와 무관하게 같게 판정된다"는 v2.19.2의 계약이 여기서 깨졌었다. */ + const capLineNorm = captionTestText(cap.s).replace(/\s+/g, " ").trim(); const bareLabel = capNorm === capLineNorm && capNorm.length <= 16 && capBottom - cap.top <= Math.max(cap.h * 1.4, cap.h + 2); @@ -1445,7 +2005,13 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { ` x=[${seed.bx0.toFixed(0)},${seed.bx1.toFixed(0)}]` + ` start=${downStart} capBottom=${capBottom.toFixed(1)}pt`); const down = scanDown(seed.bx0, seed.bx1, strictProseTail); - if (!down.incl.length) return null; + if (!down.incl.length) { + diagnosticAttempt("down", seed.source, index, "not-generated", ["no-block"], { + seedBoxPt: { x0: seed.bx0, y0: capBottom, x1: seed.bx1, y1: capBottom }, + stopReason: down.stopReason, + }); + return null; + } const ry0 = down.incl[0][0], ry1 = down.incl[down.incl.length - 1][1]; const raster = down.incl.some(([a, b]) => hasImageIn(a, b, seed.bx0, seed.bx1)); /* 방향별 후보 생성기는 달라도 공통 figureScore/선택기로 합류한다. 좌/우는 Phase 2. */ @@ -1516,7 +2082,7 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { const valid = seedGapPt <= 14 && metrics.widthRatio >= 0.15 && metrics.heightPt >= 24 && metrics.areaRatio >= 0.008 && metrics.inkDensity >= 0.002 && metrics.areaRatio <= 0.65 && metrics.heightRatio <= 0.82 && metrics.bodyStops <= 1; - return { + const candidate = { direction: "down", seedSource: seed.source, valid, @@ -1527,6 +2093,25 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { metrics, score: figureScore(metrics) }; + if (diag) { + const reasons = []; + if (seedGapPt > 14) reasons.push("gap"); + if (metrics.widthRatio < 0.15) reasons.push("thin-width"); + if (metrics.heightPt < 24) reasons.push("short-height"); + if (metrics.areaRatio < 0.008) reasons.push("small-area"); + if (metrics.inkDensity < 0.002) reasons.push("sparse"); + if (metrics.areaRatio > 0.65 || metrics.heightRatio > 0.82) reasons.push("huge"); + if (metrics.bodyStops > 1) reasons.push("body"); + candidate.diagReasons_ = reasons; + registerDiagnosticCandidate(candidate, "down", seed.source, index, + { x0: fx0, y0: ry0, x1: fx1, y1: ry1 }, { + seedBoxPt: { + x0: seed.bx0, y0: capBottom, + x1: seed.bx1, y1: Math.min(pg.h, capBottom + 48), + }, + }); + } + return candidate; }; const SIDE_MAX_GAP_PT = 36; @@ -1564,6 +2149,12 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { const sideSeedBounds = direction => { if (!sideDirectionAllowed(direction)) { dbg(` Fig${num}: ${direction.toUpperCase()} REJECT side-facing`); + diagnosticAttempt(direction, "none", 0, "not-generated", ["side-facing"], { + sideAnchor: { + leftPt: sideColumnLeftPt, rightPt: sideColumnRightPt, + widthPt: sideColumnWidthPt, centerRatio: sideColumnCenterPt / pg.w, + }, + }); return []; } const leftward = direction === "left"; @@ -1822,6 +2413,11 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { ` ${seed.source} y=[${seed.by0.toFixed(0)},${seed.by1.toFixed(0)}]` + ` gap=${gapText}pt`); if (side.gapPt > sideGapLimitPt || !side.incl.length) { + diagnosticAttempt(direction, seed.source, index, "not-generated", + [!side.incl.length ? "no-block" : "gap"], { + gapPt: side.gapPt, stopReason: side.stopReason, + seedBoxPt: { x0: seed.edgePt, y0: seed.by0, x1: seed.edgePt, y1: seed.by1 }, + }); dbg(` Fig${num}: ${direction.toUpperCase()} REJECT ` + (!side.incl.length ? "no-block" : "gap")); return null; @@ -1831,7 +2427,10 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { let fx1 = Math.max(...side.incl.map(b => b.x1)); if (direction === "left") fx1 = Math.min(fx1, side.edgePx - 1); else fx0 = Math.max(fx0, side.edgePx + 1); - if (fx1 <= fx0) return null; + if (fx1 <= fx0) { + diagnosticAttempt(direction, seed.source, index, "not-generated", ["empty-width"]); + return null; + } const rowHasInk = y => { for (let x = Math.max(0, Math.ceil(fx0)); @@ -1846,7 +2445,10 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { const seedInkRows = []; for (let y = verticalSeedY0; y <= verticalSeedY1; y++) if (rowHasInk(y)) seedInkRows.push(y); - if (!seedInkRows.length) return null; + if (!seedInkRows.length) { + diagnosticAttempt(direction, seed.source, index, "not-generated", ["seed-no-ink"]); + return null; + } const sideBoxPt = { left: fx0 / S, w: (fx1 - fx0) / S }; const xobjectProtects = u => seed.source === "image" && pg.images.some(im => { @@ -1887,7 +2489,10 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { y++; } fy1 = lastInk; - if (fy1 <= fy0) return null; + if (fy1 <= fy0) { + diagnosticAttempt(direction, seed.source, index, "not-generated", ["empty-height"]); + return null; + } const outsideSeedStops = lines.filter(u => { if (!stoppers.has(u) || ownerByPart.get(u) === cap || ownCaptionLines.has(u) || @@ -1924,18 +2529,28 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { let outX0 = Math.max(0, fx0 - 10), outX1 = Math.min(grid.W, fx1 + 10); if (direction === "left") outX1 = Math.min(outX1, side.edgePx - 1); else outX0 = Math.max(outX0, side.edgePx + 1); - if (outX1 <= outX0) return null; + if (outX1 <= outX0) { + diagnosticAttempt(direction, seed.source, index, "not-generated", ["clamped-empty"]); + return null; + } const outY0 = Math.max(0, fy0 - 8), outY1 = Math.min(grid.H, fy1 + 4); - return { direction, seedSource: seed.source, anchorOverlap, + const candidate = { direction, seedSource: seed.source, anchorOverlap, rejectReason: reject.join(","), valid, fig: { num, raster_: raster, page: pg.num, x0: outX0, x1: outX1, y0: outY0, y1: outY1, h_: Math.round(outY1 - outY0), caption: capText, captionBox }, metrics, score: figureScore(metrics) }; + registerDiagnosticCandidate(candidate, direction, seed.source, index, + { x0: fx0, y0: fy0, x1: fx1, y1: fy1 }, { + seedBoxPt: direction === "left" + ? { x0: 0, y0: seed.by0, x1: seed.edgePt, y1: seed.by1 } + : { x0: seed.edgePt, y0: seed.by0, x1: pg.w, y1: seed.by1 }, + }); + return candidate; }; - let upCandidate = null, legacyBelowCandidate = null; + let upCandidate = null, legacyBelowCandidate = null, legacyDiagnosticCandidate = null; if (incl.length) { const ry0 = incl[incl.length - 1][0], ry1 = incl[0][1]; @@ -2000,14 +2615,24 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { * 분리돼 fx가 형제 캡션을 안 넘어 crossing guard에서 자연 배제된다. pdf2.0 6/7·Structural 5/10. * (baseline이 어긋난 나란한 형제(Dong 3@p6)는 여기서 안 잡히고 페이지 말미 offset 후처리가 담당.) */ const capBaseY = cap.top + cap.h / 2, capRpt = capbox.left + capbox.w; + const sameBaselineClamps = diag ? [] : null; for (const oc of caps) { if (oc === cap || Math.abs((oc.top + oc.h / 2) - capBaseY) > SAME_BASELINE_PT) continue; const ocL = oc.left, ocR = oc.left + oc.w; + const before = diag ? { x0: fx0, y0: ry0, x1: fx1, y1: ry1 } : null; if (ocL > capRpt) { // 형제가 우측 컬럼 if (fx1 > ocL * S) fx1 = Math.min(fx1, Math.round((capRpt + SIBLING_COL_MARGIN) * S)); } else if (ocR < capbox.left) { // 형제가 좌측 컬럼 if (fx0 < ocR * S) fx0 = Math.max(fx0, Math.round((capbox.left - SIBLING_COL_MARGIN) * S)); } + if (diag && (before.x0 !== fx0 || before.x1 !== fx1)) { + sameBaselineClamps.push({ + counterpartAnchorId: diag.registerAnchor(pg, oc), + counterpartNum: infoByAnchor.get(oc)?.num, + beforeBoxPx: before, + afterBoxPx: { x0: fx0, y0: ry0, x1: fx1, y1: ry1 }, + }); + } } dbg(` Fig${num}: REGION y[${ry0}-${ry1}] x[${fx0}-${fx1}]${raster ? " raster" : ""}`); /* region은 그림 영역만 (캡션 제외). 캡션은 captionBox/caption 텍스트로 별도 반환 */ @@ -2022,7 +2647,21 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { metrics, score: figureScore(metrics) }; + const upCandidateId = registerDiagnosticCandidate(upCandidate, "up", "scan", 0, + { x0: fx0, y0: ry0, x1: fx1, y1: ry1 }, { seedBoxPt: null }); + if (diag) for (const clamp of sameBaselineClamps) diag.add("relation", { + page: pg.num, pass, decision: "clamp", + claimantCandidateId: upCandidateId, + counterpartAnchorId: clamp.counterpartAnchorId, + claimantNum: num, counterpartNum: clamp.counterpartNum, + nums: [num, clamp.counterpartNum], + beforeBoxPx: clamp.beforeBoxPx, afterBoxPx: clamp.afterBoxPx, + reasons: ["same-baseline-sibling-column"], + }); } else { + diagnosticAttempt("up", "scan", 0, "not-generated", ["no-block"], { + stopReason: upStopReason, + }); /* caption-above 레이아웃: 아래쪽 이미지 */ const below = pg.images.filter(im => im.top >= capBottom - 4 && im.top - capBottom < 40 && @@ -2035,6 +2674,19 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { x0: Math.round(bx0*S) - 10, x1: Math.round(bx1*S) + 10, y0: Math.round(capBottom*S) + 2, y1: Math.round(yb*S) + 4, h_: Math.round((yb - cap.top)*S), caption: capText, captionBox }; + if (diag) { + legacyDiagnosticCandidate = { + direction: "down", seedSource: "legacy-image", valid: true, + fig: legacyBelowCandidate, metrics: null, score: null, + }; + registerDiagnosticCandidate(legacyDiagnosticCandidate, "down", "legacy-image", 0, + { x0: Math.round(bx0 * S), y0: Math.round(capBottom * S), + x1: Math.round(bx1 * S), y1: Math.round(yb * S) }, { + seedBoxPt: { x0: bx0, y0: capBottom, x1: bx1, y1: yb }, + }); + } + } else { + diagnosticAttempt("down", "legacy-image", 0, "not-generated", ["no-nearby-image"]); } } @@ -2071,10 +2723,27 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { if (legacyBelowCandidate) { /* 기존 이미지 전용 폴백은 산출을 byte-for-byte 보존한다. */ + if (diag && legacyDiagnosticCandidate) { + diagnosticAttempt("left", "none", 0, "not-evaluated", ["legacy-image-bypass"]); + diagnosticAttempt("right", "none", 0, "not-evaluated", ["legacy-image-bypass"]); + diag.emitCandidate(legacyDiagnosticCandidate, { + decision: "chosen", reasons: ["legacy-image-bypass"], + }); + diag.add("selection", { + anchorId, captionPage: pg.num, candidatePage: pg.num, pass, num, + decision: "legacy", consideredCandidateIds: [diag.candidateId(legacyDiagnosticCandidate)], + chosenCandidateId: diag.candidateId(legacyDiagnosticCandidate), + reasons: ["legacy-image-bypass"], + }); + } dbg(scoreText("up", upCandidate)); dbg(` Fig${num}: SCORE down legacy-image`); dbg(` Fig${num}: CHOSE down legacy-image`); legacyBelowCandidate._anchor = cap; // soft floor 판정이 이 앵커의 자기 fig를 식별 (v2.14.0) + capInfo.adjacentState_ = { + selection: "legacy", chosenDirection: "down", selectedFig: legacyBelowCandidate, + emission: "none", claim: "none", + }; figs.push(legacyBelowCandidate); continue; } @@ -2082,9 +2751,26 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { const downCandidates = suspicious ? downSeeds.map((seed, i) => buildDownCandidate(seed, i, downSeeds.length, hardLong)).filter(Boolean) : []; + if (!suspicious) for (let i = 0; i < downSeeds.length; i++) + diagnosticAttempt("down", downSeeds[i].source, i, "not-evaluated", + ["down-route-not-suspicious"], { + downGapPt, bareLabel, hardLong, activeSignals, + seedBoxPt: { + x0: downSeeds[i].bx0, y0: capBottom, + x1: downSeeds[i].bx1, y1: Math.min(pg.h, capBottom + 48), + }, + }); const healthyBareUp = bareLabel && upCandidate && upCandidate.valid && Number.isFinite(upCandidate.score.total) && upCandidate.score.total >= 8; if (healthyBareUp) dbg(` Fig${num}: SIDE REJECT healthy-bare-up`); + if (healthyBareUp) { + diagnosticAttempt("left", "none", 0, "not-evaluated", ["healthy-bare-up"], { + upScore: upCandidate.score.total, + }); + diagnosticAttempt("right", "none", 0, "not-evaluated", ["healthy-bare-up"], { + upScore: upCandidate.score.total, + }); + } const leftSeeds = healthyBareUp ? [] : sideSeedBounds("left"); const rightSeeds = healthyBareUp ? [] : sideSeedBounds("right"); const leftCandidates = leftSeeds @@ -2124,6 +2810,16 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { suppressDominatedImageTail(leftCandidates); suppressDominatedImageTail(rightCandidates); const alternatives = [...downCandidates, ...leftCandidates, ...rightCandidates]; + if (diag) { + if (upCandidate) diag.emitCandidate(upCandidate, { + decision: "considered", reasons: [], + }); + for (const candidate of alternatives) diag.emitCandidate(candidate, { + decision: "considered", + reasons: candidate.diagReasons_ || (candidate.rejectReason + ? candidate.rejectReason.split(",").filter(Boolean) : []), + }); + } /* soft 애매역 floor 검증용 up-점수 노출 (v2.14.0) — ★ chosen 후보 점수가 아니라 up 후보 점수다. * floor 정의가 "up-score < FLOOR"이므로 chose가 down/side여도 up 점수를 봐야 한다. up 후보가 * 없으면(캡션-위 legacyBelow 등) undefined로 남겨 pass2가 "fig 있으면 유지"(진짜 캡션-위 도형 @@ -2166,7 +2862,28 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { dbg(` Fig${num}: CHOSE ${chose} margin=${decision.margin.toFixed(1)}` + ` minAlt=${policy.minScore.toFixed(1)} delta=${delta}` + ` seed=${chosen && chosen.seedSource ? chosen.seedSource : "-"}`); - if (chosen) { chosen.fig.dir_ = chose; chosen.fig._anchor = cap; figs.push(chosen.fig); } + if (diag) diag.add("selection", { + anchorId, captionPage: pg.num, candidatePage: pg.num, pass, num, + decision: chosen ? "chosen" : "none", + consideredCandidateIds: [upCandidate, ...alternatives] + .filter(Boolean).map(candidate => diag.candidateId(candidate)), + chosenCandidateId: diag.candidateId(chosen), + chosenDirection: chose, + minScore, + margin: decision.margin, + adjacentEvidence, + delta: delta === "n/a" ? null : Number(delta), + reasons: chosen ? ["hysteresis-selection"] : ["no-eligible-candidate"], + }); + if (chosen) { + chosen.fig.dir_ = chose; + chosen.fig._anchor = cap; + capInfo.adjacentState_ = { + selection: "chosen", chosenDirection: chose, selectedFig: chosen.fig, + emission: "none", claim: "none", + }; + figs.push(chosen.fig); + } } /* offset side-by-side 컬럼 분리 (v2.10.2) — 같은 baseline 형제는 위 per-candidate clamp(v2.10.1)가 * 이미 처리한다. 여기서는 baseline이 어긋난 나란한 형제(키 큰/작은 이웃 — Dong Fig4는 Fig3보다 @@ -2190,13 +2907,30 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { if (Math.abs(gBaseY - fBaseY) <= SAME_BASELINE_PT) continue; // same-baseline → v2.10.1 per-candidate if (Math.min(F.y1, G.y1) - Math.max(F.y0, G.y0) <= 0) continue; // 영역 세로 겹침 없으면 다른 행(stacked) const gCapL = G.captionBox.x0, gCapR = G.captionBox.x1; + const before = diag ? { x0: F.x0, y0: F.y0, x1: F.x1, y1: F.y1 } : null; if (gCapL > fCapR) { // G가 우측 컬럼 if (F.x1 > gCapL * S) F.x1 = Math.min(F.x1, Math.round((fCapR + SIBLING_COL_MARGIN) * S) + 10); } else if (gCapR < fCapL) { // G가 좌측 컬럼 if (F.x0 < gCapR * S) F.x0 = Math.max(F.x0, Math.round((fCapL - SIBLING_COL_MARGIN) * S) - 10); } + if (diag && (before.x0 !== F.x0 || before.x1 !== F.x1)) { + diag.add("relation", { + page: pg.num, pass, decision: "clamp", + claimantCandidateId: diag.figCandidateId(F), + counterpartCandidateId: diag.figCandidateId(G), + claimantNum: F.num, counterpartNum: G.num, + nums: [F.num, G.num], + beforeBoxPx: before, + afterBoxPx: { x0: F.x0, y0: F.y0, x1: F.x1, y1: F.y1 }, + reasons: ["offset-sibling-column"], + }); + } } } + if (diag) diag.add("detection-pass", { + page: pg.num, pass, decision: "completed", + outputCandidateIds: figs.map(fig => diag.figCandidateId(fig)), + }); return figs; } @@ -2210,16 +2944,24 @@ function detectPage(pg, lines, dom, grid, dbg, captionData) { * 강등 0이면 재실행 없음 → 전량승격·전량기각 문서와 byte-identical(provisional 앵커가 없으므로). * ★ 강등 되돌림은 승격의 완전 역연산 + assembleAnchors 재조립(promoteSoftAnchors 미러) — 누락 시 * stale anchors가 강등 라인을 물고 있어 재실행이 오염 상태를 본다. */ -function detectPageWithFloor(pd, dom, grid, dbg) { +function detectPageWithFloor(pd, dom, grid, dbg, diag) { const cd = pd.captionData; const provisionalSoft = () => cd.softSlots.filter(c => { const info = cd.infoByAnchor.get(c.line); return info && info.soft && info.provisional; }); - let figs = detectPage(pd, pd.lines, dom, grid, dbg, cd); - if (!provisionalSoft().length) return figs; // 애매역 아님 → 단일 실행(현행 경로) + let detectPass = 0; + let figs = detectPage(pd, pd.lines, dom, grid, dbg, cd, diag, detectPass); + if (!provisionalSoft().length) { // 애매역 아님 → 단일 실행(현행 경로) + if (diag) diag.add("floor-pass", { + page: pd.num, pass: detectPass, decision: "active", + outputCandidateIds: figs.map(fig => diag.figCandidateId(fig)), + reasons: ["no-provisional-soft-anchor"], + }); + return figs; + } const bound = cd.softSlots.length + 1; // 매 회 ≥1 강등 → 앵커 수 상한서 종료 - for (let pass = 0; pass < bound; pass++) { + for (let floorPass = 0; floorPass < bound; floorPass++) { const demote = []; for (const c of provisionalSoft()) { const info = cd.infoByAnchor.get(c.line); @@ -2233,23 +2975,563 @@ function detectPageWithFloor(pd, dom, grid, dbg) { } if (!demote.length) break; for (const { c, up, hasFig } of demote) { + if (diag) diag.add("floor-decision", { + page: pd.num, pass: detectPass, + anchorId: diag.registerAnchor(pd, c.line), + candidateId: diag.figCandidateId(figs.find(f => f._anchor === c.line)), + num: c.num, upScore: up, hasFig, floor: SOFT_UP_FLOOR, + decision: "demoted", + reasons: [up === undefined ? "no-up-and-no-output" : "up-below-soft-floor"], + }); cd.slots[c.index] = undefined; // 승격 역연산 (희소 배열 구멍) cd.ownerByPart.delete(c.line); cd.infoByAnchor.delete(c.line); dbg(` [floor] DEMOTE p${pd.num} num=${c.num} up=${up === undefined ? "-" : up.toFixed(2)}` + ` fig=${hasFig ? 1 : 0} < ${SOFT_UP_FLOOR}`); } + if (diag) diag.add("floor-pass", { + page: pd.num, pass: detectPass, decision: "superseded", + outputCandidateIds: figs.map(fig => diag.figCandidateId(fig)), + demotedAnchorIds: demote.map(({ c }) => diag.registerAnchor(pd, c.line)), + reasons: ["soft-floor-retry"], + }); cd.anchors = assembleAnchors(cd.slots, cd.embedded); // ★ 재조립 필수 (line ~604 미러) - figs = detectPage(pd, pd.lines, dom, grid, dbg, cd); // 이웃 오염 복구 재실행 + detectPass++; + figs = detectPage(pd, pd.lines, dom, grid, dbg, cd, diag, detectPass); // 이웃 오염 복구 재실행 } + if (diag) diag.add("floor-pass", { + page: pd.num, pass: detectPass, decision: "active", + outputCandidateIds: figs.map(fig => diag.figCandidateId(fig)), + reasons: ["soft-floor-fixpoint"], + }); return figs; } +const adjacentBoxGapPt = (a, b) => { + const dx = Math.max(0, Math.max(a.x0, b.x0) - Math.min(a.x1, b.x1)); + const dy = Math.max(0, Math.max(a.y0, b.y0) - Math.min(a.y1, b.y1)); + return dx === 0 && dy === 0 ? 0 : Math.hypot(dx, dy); +}; + +/* PB-4B 12-A composition 관측. 아래 값은 25행 fit에서 A/B가 일치한 관측 파라미터일 뿐 public + * resolver threshold가 아니다. decision은 계속 abstain이며 반례가 생기면 ambiguous로 보존한다. */ +function observeAdjacentComposition(regions, pageHeightPt, repeatedLineKeys) { + const furniture = new Map(); + for (const region of regions) { + const reasons = []; + const box = region.bboxPt; + const lineRefs = region.lineRefs; + if (lineRefs.some(line => line.kind === "header") && box.y1 <= 56) + reasons.push("L-header"); + if (lineRefs.some(line => line.kind === "footer") && box.y0 >= pageHeightPt - 56) + reasons.push("L-footer"); + if (lineRefs.some(line => line.kind === "body")) reasons.push("L-body"); + if (repeatedLineKeys && lineRefs.length && + lineRefs.every(line => repeatedLineKeys.has( + `${line.textHash}|${Math.round(line.bboxPt.y0)}`))) + reasons.push("R-repeat"); + const width = box.x1 - box.x0, height = box.y1 - box.y0; + const minDim = Math.min(width, height); + const elong = Math.max(width, height) / Math.max(minDim, 1e-9); + const edge = region.edgeDistancePt; + const minEdge = Math.min(edge.top, edge.right, edge.bottom, edge.left); + if (minDim <= 2.5) reasons.push("S-hairline"); + if (elong >= 25 && minEdge <= 25) reasons.push("S-marginstrip"); + const touch = region.pageEdgeTouch; + if (!lineRefs.length && (touch.top || touch.right || touch.bottom || touch.left)) + reasons.push("S-edgeblock"); + furniture.set(region.regionId, reasons); + } + const pool = regions.filter(region => !(furniture.get(region.regionId) || []).length); + const subtractive = pool.map(region => region.regionId); + const accreted = []; + if (pool.length) { + const seed = pool.reduce((best, region) => { + if (!best) return region; + const signal = region.imageAreaSumRatio * adjacentArea(region.bboxPx); + const bestSignal = best.imageAreaSumRatio * adjacentArea(best.bboxPx); + if (signal !== bestSignal) return signal > bestSignal ? region : best; + return adjacentArea(region.bboxPx) > adjacentArea(best.bboxPx) ? region : best; + }, null); + const selected = [seed], left = pool.filter(region => region !== seed); + for (let grew = true; grew;) { + grew = false; + for (let index = left.length - 1; index >= 0; index--) { + if (!selected.some(region => + adjacentBoxGapPt(region.bboxPt, left[index].bboxPt) <= 30)) continue; + selected.push(left[index]); + left.splice(index, 1); + grew = true; + } + } + accreted.push(...selected.map(region => region.regionId).sort()); + } + const a = [...subtractive].sort(); + const confirmed = a.length > 0 && JSON.stringify(a) === JSON.stringify(accreted); + return { + composition: confirmed ? "confirmed" : "ambiguous", + regionIds: confirmed ? a : [], + regionCount: confirmed ? a.length : null, + subtractiveRegionIds: a, + accretionRegionIds: accreted, + furniture: [...furniture.entries()] + .filter(([, reasons]) => reasons.length) + .map(([regionId, reasons]) => ({ regionId, reasons })), + }; +} + +/* PB-4B 12-A `strong` 계측 (v2.18.0). Q6 전수 라운드에서 12-B 발화 28행 중 오발 2행이 + * composition·unclaimed 세 가드를 모두 통과한 원인은 "N−1 선택이 애초에 그림인가"를 보는 항이 + * 없다는 것이었다(Weiss p1 = 순수 본문 텍스트, science-1247125 p2 = Box 내부 소형 그림). + * 여기서는 그 두 축을 **관측값으로만** 방출한다. `strong` tri-state는 계속 null이고 임계는 없다 + * (§13 threshold 잠금). 원자료는 이미 region record에 있어 새 렌더·새 계측이 없다. + * raster 점유(rasterCoverage)는 정답 4행이 0이라 판별에 쓸 수 없다는 실측을 같이 남긴다. */ +/* 지면 장식 띠 (v2.19.3) — 머리글/꼬리말 띠가 figure로 방출되던 결함(Sanesi 10@p13 = 폭 전면· + * 높이 31pt의 러닝헤더)을 막는다. 같은 취지의 가드가 side 후보에는 있고(heightRatio < 0.09) + * up 후보에는 아예 없었다 — up은 valid:true 하드코딩이고 chooseCandidate가 up.valid를 읽지도 + * 않으므로 후보 단계에서 막으면 무발동이거나 healthyBareUp·upScore_ 경로가 함께 뒤집힌다. + * 두 조건을 AND로 쓴다: 현상이 "폭 전면 얇은 띠"이므로 폭 조건이 빠지면 규칙이 현상보다 넓어진다 + * (전수 실측: 폭 조건 없이 FP 11 / 부수 7, 폭 0.8 이상이면 FP 4 / 부수 2, 정답 손실은 둘 다 0). + * 높이 0.05는 가장 가까운 정답행(Penn 1@p29 = 0.0557)과 4.5pt 여유를 둔 값이다 — 0.055는 여유가 + * 0.55pt로 알려진 헤드리스↔GPU bbox 변동폭(~0.9pt)보다 작아 환경에 따라 판정이 뒤집힌다. + * 비율 단위인 이유: 같은 취지의 기존 side 가드가 heightRatio 기준이라 그 선례를 따른다. */ +const FURNITURE_STRIP_MAX_HEIGHT = 0.05; +const FURNITURE_STRIP_MIN_WIDTH = 0.80; + +/* PB-4B 12-B 잠정 문턱 (v2.19.0). Q6 전수 라운드에서 정답 26행과 오발 2행 사이에 빈 구간이 + * 있었고(textCoverage 0.29↔0.86, selectionAreaRatio 0.42↔0.014) 캡션이 다음 장으로 밀리는 원인 + * 자체가 "그림이 페이지를 거의 채웠다"라서 면적 하한은 현상의 정의에서 나온다. 불통과는 abstain + * (=현재 동작)이므로 조여서 틀리면 현상 유지, 기존 상자를 망가뜨리는 방향으로는 실패하지 않는다. */ +const ADJ_TEXT_COVERAGE_MAX = 0.50; // 정답 최대 0.2895 ↔ 오발 0.8551의 기하 중간 +const ADJ_SELECTION_AREA_MIN = 0.30; // 정답 최소 0.4241의 71%, 오발 0.0142의 21배 +const ADJ_OUTSET_PX = 10; // same-page legacy-image 후보의 x 여백과 동일한 값 (새 상수 아님) + +function observeAdjacentStrength(regions, selectedIds, pageAreaPt) { + const selected = regions.filter(region => selectedIds.includes(region.regionId)); + if (!selected.length) return null; + let areaSum = 0, lineAreaSum = 0, inkWeighted = 0, rasterWeighted = 0, lineCount = 0; + for (const region of selected) { + const area = adjacentArea(region.bboxPt); + areaSum += area; + lineCount += region.lineRefs.length; + for (const line of region.lineRefs) lineAreaSum += adjacentArea(line.bboxPt); + inkWeighted += (region.inkDensityObserved_ || 0) * area; + rasterWeighted += region.imageAreaSumRatio * area; + } + const denom = Math.max(areaSum, 1e-9); + return { + selectionAreaRatio: areaSum / Math.max(pageAreaPt, 1e-9), + textCoverage: lineAreaSum / denom, + rasterCoverage: rasterWeighted / denom, + inkDensity: inkWeighted / denom, + lineCount, + }; +} + +/* ===================== PB-4A/B N−1 association observer ===================== + * same-page public 결과와 active claim을 scalar snapshot으로 받은 뒤에만 실행한다. 별도 adjacent + * namespace에 raw XObject/ink/line/claim 관계를 기록한다. PB-4B 12-A부터 렌더·합성·guard 계산은 + * graph 유무와 무관하게 동일하고 diag는 기록에만 쓰며, 선택·방출·crop은 여전히 절대 수정하지 않는다. */ +async function observeAdjacentPages(pageData, dom, diag, opts, checkAborted, snapshots, ownedClaims, + captionBySnapshot) { + const resolved = []; // 12-B가 새로 방출할 figure (없으면 빈 배열) + if (!snapshots.length) return resolved; + const emittedNums = new Set(ownedClaims.map(claim => String(claim.num))); + const byTarget = new Map(); + for (const snapshot of snapshots) { + if (!byTarget.has(snapshot.candidatePage)) byTarget.set(snapshot.candidatePage, []); + byTarget.get(snapshot.candidatePage).push(snapshot); + } + const targetPages = new Set(byTarget.keys()); + const lineKeysByTarget = new Map([...targetPages].map(page => [page, + new Set(pageData[page - 1].lines.map(line => + `${diagnosticTextHash(line.s)}|${Math.round(line.top)}`))])); + for (const [targetPage, anchors] of [...byTarget.entries()].sort((a, b) => a[0] - b[0])) { + checkAborted(); + const pd = pageData[targetPage - 1]; + const anchorIds = anchors.map(anchor => anchor.anchorId).sort(); + let canvas = null, grid = null, imageBoxes = [], operatorError = null; + const releaseOwnedCanvas = () => { + if (canvas && !opts.renderPage) { + canvas.width = 0; + canvas.height = 0; + } + }; + try { + imageBoxes = await getImageBoxes(pd.page, pd.h, error => { operatorError = error; }); + checkAborted(); + if (operatorError) throw operatorError; + if (opts.renderPage) { + checkAborted(); + canvas = await opts.renderPage(targetPage, S); + checkAborted(); + } else { + const vp = pd.page.getViewport({ scale: S }); + canvas = document.createElement("canvas"); + canvas.width = Math.ceil(vp.width); canvas.height = Math.ceil(vp.height); + 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; + } finally { + if (opts.signal) opts.signal.removeEventListener("abort", onAbort); + } + checkAborted(); + } + grid = makeInk(canvas, !opts.renderPage); // 카나리아는 엔진 소유 캔버스에서만 (B7) + } catch (error) { + releaseOwnedCanvas(); + if (opts.signal && opts.signal.aborted) + throw new DOMException("figure 추출이 취소됨", "AbortError"); + /* 죽은 캔버스는 "이 PDF가 특이하다"가 아니라 실행 환경 실패다 (B7). unobservable로 삼키면 + * 12-B 방출이 조용히 사라져 실제 회귀와 구별되지 않는다 — 관측 계층이라도 그대로 올린다. */ + if (isFigRenderError(error)) throw error; + diag?.add("adjacent-page-render", { + page: targetPage, observation: "n-1", captionPages: [...new Set(anchors.map(a => a.captionPage))], + anchorIds, decision: "unobservable", reasons: ["page-render-or-operator-failure"], + errorName: error && error.name || "Error", + }); + for (const anchor of anchors) diag?.add("adjacent-observation", { + ...anchor, rawRegionIds: [], rawRegionCount: null, anchorRegionRelationIds: [], + regionIds: [], regionCount: null, + single: null, composition: null, strong: null, strongMetrics: null, + unclaimed: null, multiPage: null, + captionCompetition: null, captionCompetitionAnchorIds: [], + competingSelectionAnchorIds: [], ownedIntersectionRegionIds: [], + replacementClass: "unobservable", decision: "abstain", + reasons: ["adjacent-page-unobservable"], + }); + continue; + } + + try { + diag?.add("adjacent-page-render", { + page: targetPage, observation: "n-1", captionPages: [...new Set(anchors.map(a => a.captionPage))], + anchorIds, widthPt: pd.w, heightPt: pd.h, + widthPx: grid.W, heightPx: grid.H, scale: S, imageCount: imageBoxes.length, + decision: "observed", reasons: ["graph-only-adjacent-render"], + }); + + const xobjects = imageBoxes + .map(box => ({ + kind: "filtered-xobject", + bboxPx: { + x0: Math.max(0, Math.round(box.left * S)), + y0: Math.max(0, Math.round(box.top * S)), + x1: Math.min(grid.W, Math.round((box.left + box.w) * S)), + y1: Math.min(grid.H, Math.round((box.top + box.h) * S)), + }, + })) + .filter(seed => adjacentArea(seed.bboxPx) > 0) + .sort((a, b) => a.bboxPx.y0 - b.bboxPx.y0 || a.bboxPx.x0 - b.bboxPx.x0 || + a.bboxPx.y1 - b.bboxPx.y1 || a.bboxPx.x1 - b.bboxPx.x1); + const inkBands = adjacentInkBands(grid); + const seeds = [ + ...xobjects.map((seed, index) => ({ + ...seed, seedId: `adj/p${targetPage}/xobject/${index}`, + imageId: `xobject/${index}`, + })), + ...inkBands.map((seed, index) => ({ + ...seed, seedId: `adj/p${targetPage}/ink-band/${index}`, + })), + ]; + for (const seed of seeds) { + const box = seed.bboxPx; + diag?.add("adjacent-seed", { + seedId: seed.seedId, page: targetPage, kind: seed.kind, + bboxPx: box, bboxPt: adjacentPxBoxToPt(box), + edgeDistancePt: Math.min(box.x0, box.y0, grid.W - box.x1, grid.H - box.y1) / S, + imageId: seed.imageId || null, + imageFilterMinWidthPt: seed.kind === "filtered-xobject" ? 10 : null, + imageFilterMinHeightPt: seed.kind === "filtered-xobject" ? 10 : null, + rowStart: seed.rowStart ?? null, rowEnd: seed.rowEnd ?? null, + rowInkMax: seed.rowInkMax ?? null, inkPixels: seed.inkPixels ?? null, + inkDensity: seed.inkDensity ?? null, + threshold: seed.threshold ?? null, separatorPx: seed.separatorPx ?? null, + decision: "observed", reasons: ["raw-adjacent-seed"], + }); + } + + const regions = adjacentRegions(seeds).map((region, index) => ({ + ...region, regionId: `adj/p${targetPage}/region/${index}`, + })); + const pageClaims = ownedClaims.filter(claim => claim.page === targetPage); + const claimRelationsByRegion = new Map(regions.map(region => [region.regionId, []])); + for (const region of regions) for (const claim of pageClaims) { + const intersection = adjacentIntersection(region.bboxPx, claim.outputBoxPx); + if (!intersection) continue; + const regionArea = adjacentArea(region.bboxPx), claimArea = adjacentArea(claim.outputBoxPx); + claimRelationsByRegion.get(region.regionId).push({ + claim, intersection, regionArea, claimArea, + }); + } + const stoppers = new Set(pd.lines.filter(line => { + if (line.font !== dom) return false; + const nb = neighborsOf(line, pd.lines); + return (line.w >= 190 && (nb.above || nb.below)) || + (line.w >= 100 && nb.above && nb.below); + })); + for (const region of regions) { + const box = region.bboxPx, boxPt = adjacentPxBoxToPt(box); + const lineRefs = []; + for (let index = 0; index < pd.lines.length; index++) { + const line = pd.lines[index]; + const lineBox = { + x0: line.left, y0: line.top, x1: line.left + line.w, y1: line.top + line.h, + }; + if (adjacentIntersection(boxPt, lineBox) <= 0) continue; + let kind = "other"; + if (pd.captionData.ownerByPart.has(line) || isCaption(line) != null) kind = "figure-caption"; + else if (isTableCaption(line)) kind = "table-caption"; + else if (line.top + line.h <= 56) kind = "header"; + else if (line.top >= pd.h - 56) kind = "footer"; + else if (stoppers.has(line)) kind = "body"; + lineRefs.push({ + lineIndex: index, kind, bboxPt: { + x0: line.left, y0: line.top, x1: line.left + line.w, y1: line.top + line.h, + }, textHash: diagnosticTextHash(line.s), + }); + } + const area = adjacentArea(box); + const imageRefs = region.seeds.filter(seed => seed.kind === "filtered-xobject") + .map(seed => seed.imageId); + const inkPixels = region.seeds.reduce((sum, seed) => sum + (seed.inkPixels || 0), 0); + const edgeDistancePt = { + top: box.y0 / S, right: (grid.W - box.x1) / S, + bottom: (grid.H - box.y1) / S, left: box.x0 / S, + }; + const pageEdgeTouch = { + top: box.y0 <= 0, bottom: box.y1 >= grid.H, + left: box.x0 <= 0, right: box.x1 >= grid.W, + }; + region.bboxPt = boxPt; + region.lineRefs = lineRefs; + region.inkDensityObserved_ = inkPixels / Math.max(1, area); // strong 관측용 (v2.18.0) + region.edgeDistancePt = edgeDistancePt; + region.pageEdgeTouch = pageEdgeTouch; + region.imageAreaSumRatio = region.seeds + .filter(seed => seed.kind === "filtered-xobject") + .reduce((sum, seed) => sum + adjacentArea(seed.bboxPx), 0) / Math.max(1, area); + diag?.add("adjacent-region", { + regionId: region.regionId, page: targetPage, + seedIds: region.seeds.map(seed => seed.seedId), + sourceKinds: region.sourceKinds, bboxPx: box, bboxPt: boxPt, + widthRatio: (box.x1 - box.x0) / grid.W, + heightRatio: (box.y1 - box.y0) / grid.H, + areaRatio: area / Math.max(1, grid.W * grid.H), + inkDensity: inkPixels / Math.max(1, area), + imageRefs, imageAreaSumRatio: region.imageAreaSumRatio, + lineRefs, + tableBodyContext: "unobservable", + edgeDistancePt, + pageEdgeTouch, + rawClaimState: claimRelationsByRegion.get(region.regionId).length + ? "owned-intersection" : "no-owned-intersection", + strong: null, strongReason: "unclassified-pb4a", + decision: "observed", reasons: ["raw-overlap-cluster"], + }); + } + for (let i = 0; i < regions.length; i++) for (let j = i + 1; j < regions.length; j++) { + const a = regions[i], b = regions[j]; + const intersection = adjacentIntersection(a.bboxPx, b.bboxPx); + if (!intersection) continue; + const areaA = adjacentArea(a.bboxPx), areaB = adjacentArea(b.bboxPx); + diag?.add("adjacent-region-relation", { + regionIds: [a.regionId, b.regionId], page: targetPage, + intersectionPx2: intersection, + iou: intersection / Math.max(1, areaA + areaB - intersection), + aContainment: intersection / Math.max(1, areaA), + bContainment: intersection / Math.max(1, areaB), + decision: "observed", reasons: ["positive-intersection"], + }); + } + + for (const region of regions) for (const relation of claimRelationsByRegion.get(region.regionId)) { + const { claim, intersection, regionArea, claimArea } = relation; + diag?.add("adjacent-claim-relation", { + regionId: region.regionId, claimCandidateId: claim.candidateId, + claimAnchorId: claim.anchorId, claimNum: claim.num, + nums: [...new Set([...anchors.map(anchor => anchor.num), claim.num])], page: targetPage, + intersectionPx2: intersection, + iou: intersection / Math.max(1, regionArea + claimArea - intersection), + regionContainment: intersection / Math.max(1, regionArea), + claimContainment: intersection / Math.max(1, claimArea), + decision: "observed", reasons: ["positive-owned-claim-intersection"], + }); + } + const repeatedLineKeys = new Set(); + for (const [page, keys] of lineKeysByTarget) + if (page !== targetPage) for (const key of keys) repeatedLineKeys.add(key); + const composition = observeAdjacentComposition( + regions, pd.h, targetPages.size > 1 ? repeatedLineKeys : null); + const strengthMetrics = observeAdjacentStrength( + regions, composition.regionIds, (grid.W / S) * (grid.H / S)); + const targetAnchors = (pd.captionData.anchors || []).map(cap => { + const info = pd.captionData.infoByAnchor.get(cap); + return { + anchorId: diag ? diag.registerAnchor(pd, cap) : null, + num: info && info.num, + }; + }); + for (const anchor of anchors) { + const relationIds = []; + for (const region of regions) { + const relationId = `${anchor.anchorId}/adjacent/${region.regionId}`; + relationIds.push(relationId); + diag?.add("adjacent-anchor-region", { + relationId, anchorId: anchor.anchorId, num: anchor.num, + captionPage: anchor.captionPage, candidatePage: anchor.candidatePage, + regionId: region.regionId, page: targetPage, + edgeDistancePt: { + top: region.bboxPx.y0 / S, right: (grid.W - region.bboxPx.x1) / S, + bottom: (grid.H - region.bboxPx.y1) / S, left: region.bboxPx.x0 / S, + }, + rawClaimState: claimRelationsByRegion.get(region.regionId).length + ? "owned-intersection" : "no-owned-intersection", + decision: "observed", reasons: ["threshold-free-page-edge-association"], + }); + } + const selectedRegionSet = new Set(composition.regionIds); + const ownedIntersectionRegionIds = regions + .filter(region => selectedRegionSet.has(region.regionId) && + claimRelationsByRegion.get(region.regionId).length) + .map(region => region.regionId); + const captionCompetitionAnchorIds = targetAnchors + .filter(other => String(other.num) !== String(anchor.num)) + .map(other => other.anchorId).filter(Boolean).sort(); + /* 12-A에는 adjacent selection 자체가 없으므로 세 번째 guard의 현재 관측값은 0이다. + * resolver가 생기면 같은 target page에서 먼저 확정된 다른 selection ID가 여기에 들어간다. */ + const competingSelectionAnchorIds = []; + const captionCompetition = captionCompetitionAnchorIds.length > 0; + const unclaimed = ownedIntersectionRegionIds.length > 0 || captionCompetition || + competingSelectionAnchorIds.length > 0 + ? false + : composition.composition === "confirmed" ? true : null; + diag?.add("adjacent-observation", { + ...anchor, + rawRegionIds: regions.map(region => region.regionId), + rawRegionCount: regions.length, anchorRegionRelationIds: relationIds, + regionIds: composition.regionIds, regionCount: composition.regionCount, + single: null, composition: composition.composition, + compositionSubtractiveRegionIds: composition.subtractiveRegionIds, + compositionAccretionRegionIds: composition.accretionRegionIds, + compositionFurniture: composition.furniture, + strong: null, strongMetrics: strengthMetrics, unclaimed, multiPage: null, + captionCompetition, captionCompetitionAnchorIds, + competingSelectionAnchorIds, ownedIntersectionRegionIds, + replacementClass: "observation-only-pb4b-12a", + decision: "abstain", + reasons: [ + "observation-only", "single-deprecated", "strong-unclassified", + composition.composition === "confirmed" + ? "composition-consensus" : "composition-ambiguous", + unclaimed === true ? "unclaimed-three-guards-clear" + : unclaimed === false ? "unclaimed-guard-blocked" : "unclaimed-unclassified", + "multi-page-unobserved", + ], + }); + /* ---- 12-B 최소 안전 행동: A/B 상태 한정 신규 방출. replace는 12-C로 계속 잠금 ---- */ + const caption = captionBySnapshot && captionBySnapshot.get(anchor) || null; + const gate = { + unclaimed: unclaimed === true, + composition: composition.composition === "confirmed", + emission: ["none", "dropped", "inactive"].includes(String(anchor.currentEmission)), + sameNumFree: !emittedNums.has(String(anchor.num)), + textCoverage: !!strengthMetrics && + strengthMetrics.textCoverage <= ADJ_TEXT_COVERAGE_MAX, + selectionArea: !!strengthMetrics && + strengthMetrics.selectionAreaRatio >= ADJ_SELECTION_AREA_MIN, + captionKnown: !!(caption && caption.box), + }; + const blocked = Object.entries(gate) + .filter(([, pass]) => !pass).map(([key]) => `blocked-${key}`); + let outputBoxPx = null; + if (!blocked.length) { + const selectedRegions = regions + .filter(region => selectedRegionSet.has(region.regionId)); + const union = { + x0: Math.min(...selectedRegions.map(region => region.bboxPx.x0)), + y0: Math.min(...selectedRegions.map(region => region.bboxPx.y0)), + x1: Math.max(...selectedRegions.map(region => region.bboxPx.x1)), + y1: Math.max(...selectedRegions.map(region => region.bboxPx.y1)), + }; + /* 합성 union은 잉크 경계라 same-page 상자보다 타이트하다. 여백은 same-page와 같은 10px을 + * 쓰되 페이지 변과 furniture(머리글·꼬리말·장식 띠)로 클램프해 넘침을 막는다. */ + const furnitureIds = new Set(composition.furniture.map(entry => entry.regionId)); + const furnitureBoxes = regions + .filter(region => furnitureIds.has(region.regionId)).map(region => region.bboxPx); + const box = { + x0: Math.max(0, union.x0 - ADJ_OUTSET_PX), + y0: Math.max(0, union.y0 - ADJ_OUTSET_PX), + x1: Math.min(grid.W, union.x1 + ADJ_OUTSET_PX), + y1: Math.min(grid.H, union.y1 + ADJ_OUTSET_PX), + }; + for (const fb of furnitureBoxes) { + if (adjacentIntersection(union, fb) > 0) continue; // 원래도 겹쳤으면 여백 탓이 아니다 + if (adjacentIntersection(box, fb) <= 0) continue; // 확장해도 안 닿으면 둘 필요 없다 + if (fb.y1 <= union.y0) box.y0 = Math.max(box.y0, fb.y1); + if (fb.y0 >= union.y1) box.y1 = Math.min(box.y1, fb.y0); + if (fb.x1 <= union.x0) box.x0 = Math.max(box.x0, fb.x1); + if (fb.x0 >= union.x1) box.x1 = Math.min(box.x1, fb.x0); + } + outputBoxPx = { + x0: Math.round(box.x0), y0: Math.round(box.y0), + x1: Math.round(box.x1), y1: Math.round(box.y1), + }; + /* same-page에서 막은 지면 장식 띠가 이 경로로 새어나가지 않게 같은 바닥을 적용한다 + * (v2.19.3). same-page 거부는 claim을 inactive로 만들어 그 anchor를 12-B 대상으로 + * 승격시키므로, 이 가드가 없으면 띠를 N−1에서 다시 방출할 수 있다. */ + const outHeightRatio = (outputBoxPx.y1 - outputBoxPx.y0) / Math.max(1, grid.H); + const outWidthRatio = (outputBoxPx.x1 - outputBoxPx.x0) / Math.max(1, grid.W); + if (outHeightRatio < FURNITURE_STRIP_MAX_HEIGHT && + outWidthRatio >= FURNITURE_STRIP_MIN_WIDTH) { + blocked.push("blocked-furnitureStrip"); + outputBoxPx = null; + } + if (outputBoxPx) emittedNums.add(String(anchor.num)); // 같은 num 두 앵커의 F8 중단 방지 + if (outputBoxPx) resolved.push({ + num: anchor.num, page: targetPage, + x0: outputBoxPx.x0, y0: outputBoxPx.y0, x1: outputBoxPx.x1, y1: outputBoxPx.y1, + caption: caption.text, captionBox: caption.box, + captionPage: anchor.captionPage, // page ≠ captionPage일 때만 소비자에 방출된다 + cropPng_: opts.cropImages === false ? null : makeCropPng(canvas, outputBoxPx), + }); + } + diag?.add("adjacent-resolution", { + anchorId: anchor.anchorId, num: anchor.num, + captionPage: anchor.captionPage, candidatePage: anchor.candidatePage, + regionIds: composition.regionIds, + strongMetrics: strengthMetrics, + thresholds: { + textCoverageMax: ADJ_TEXT_COVERAGE_MAX, + selectionAreaMin: ADJ_SELECTION_AREA_MIN, + provisional: true, + }, + outputBoxPx, outputBoxPt: outputBoxPx ? adjacentPxBoxToPt(outputBoxPx) : null, + replacementClass: "new-emission-pb4b-12b", + decision: blocked.length ? "abstain" : "emitted", + reasons: blocked.length ? blocked : ["provisional-threshold-pass"], + }); + } + } finally { + releaseOwnedCanvas(); + } + } + return resolved; +} + /* ===================== 메인 파이프라인 ===================== */ async function extract(data, opts = {}) { const onProgress = opts.onProgress || (() => {}); const dbg = opts.debug || (() => {}); + const diag = makeDiagnosticRecorder(opts.onDiagnostic); const maxPages = opts.maxPages; // 미지정 시 전체 페이지 스캔 (기본 상한 없음) + /* v2.19.1: 진단 전용 — false면 크롭 생성·PNG 직렬화를 통째로 건너뛴다. 크롭은 감지 **이후** + * 단계라 manifest 출력은 완전히 동일하고, PNG 인코딩·전송·저장 비용만 사라진다. + * 이 모드에서는 cropDataURL/cropBlob이 사용 불가이고 크롭 카나리아도 돌지 않는다. */ + const wantCropImages = opts.cropImages !== false; /* 협조적 취소 (PDFViewer#12): 페이지 단위로 signal 체크 — 문서 교체 시 호스트가 abort */ const checkAborted = () => { if (opts.signal && opts.signal.aborted) @@ -2283,7 +3565,15 @@ async function extract(data, opts = {}) { /* 1.5차: 문서 수준 게이트로 soft 캡션 앵커 승격 (v2.9.1). 여기서만 판단할 수 있다 — * captionAnchors는 페이지별이고 문서의 hard 앵커 총수는 1차 패스가 끝나야 확정된다. * 승격분은 아래 2차 패스의 prefilter(anchors.length)를 자동으로 통과한다. */ - promoteSoftAnchors(pageData, dbg); + promoteSoftAnchors(pageData, dbg, diag); + if (diag) for (const pd of pageData) { + const activeAnchorIds = pd.captionData.anchors.map(cap => diag.registerAnchor(pd, cap)); + diag.add("page-text", { + page: pd.num, widthPt: pd.w, heightPt: pd.h, lineCount: pd.lines.length, + activeAnchorCount: activeAnchorIds.length, activeAnchorIds, + decision: activeAnchorIds.length ? "anchor-prerequisite-met" : "anchor-prerequisite-absent", + }); + } const dom = Object.entries(fontW).sort((a, b) => b[1] - a[1])[0]?.[0]; /* 2차: 캡션 있는 페이지만 렌더 + 감지 */ const allFigs = []; @@ -2317,23 +3607,230 @@ async function extract(data, opts = {}) { if (opts.signal) opts.signal.removeEventListener("abort", onAbort); } } - const grid = makeInk(canvas); - const figs = detectPageWithFloor(pd, dom, grid, dbg); - /* 중복 번호 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); + /* 이 아래 페이지 본문은 **전부 동기**다 (detectPageWithFloor·dedup·makeCropPng). Chromium은 + * task 경계에서만 백킹 스토어를 버리므로, makeInk가 살아 있다고 확인한 캔버스는 크롭을 다 뜰 + * 때까지 죽지 않는다 — 크롭 카나리아가 1픽셀 검사로 충분한 근거다 (B7). + * **여기에 await를 추가하면 그 보장이 깨진다** — 백지 PNG 구멍이 조용히 다시 열린다. */ + const releasePageCanvas = () => { // 호스트 주입 캔버스는 우리 소유가 아니다 + if (!opts.renderPage && canvas) { canvas.width = 0; canvas.height = 0; } + }; + try { + const grid = makeInk(canvas, !opts.renderPage); // 카나리아는 엔진 소유 캔버스에서만 + if (diag) diag.add("page-render", { + page: pd.num, widthPx: grid.W, heightPx: grid.H, scale: S, + imageCount: pd.images.length, decision: "same-page-detect", + }); + let figs = detectPageWithFloor(pd, dom, grid, dbg, diag); + /* 중복 번호 dedup은 (num, page) 인스턴스 단위 (PDFViewer#14) — 합본 논문·부록 번호 재시작에서 + * 같은 번호가 다른 페이지에 재등장하는 figure를 보존한다. 경쟁은 같은 페이지 안에서만 발생하므로 + * dedup·최소 크기 필터를 페이지 단위로 끝내고, 살아남은 figure만 즉시 크롭해 보관한다. + * 페이지 전체 캔버스는 여기서 참조를 버림 — 스캔 중 동시 상주 최대 1장 (PDFViewer#12). */ + /* 지면 장식 띠 거부 (v2.19.3) — **dedup 앞**에서 판정한다. 뒤에 두면 띠가 우승자로 뽑힌 뒤 + * 죽고, 같은 num의 진짜 후보는 이미 버려져 복구할 길이 없다(래스터 띠는 raster_ 가산점 + * 1e9으로 비-래스터 진짜 figure를 무조건 이긴다 — 머리글 띠에 로고가 흔하다). */ + const strip = f => { + const heightRatio = (f.y1 - f.y0) / Math.max(1, grid.H); // canvas는 해제될 수 있어 grid가 정본 + const widthRatio = (f.x1 - f.x0) / Math.max(1, grid.W); + return heightRatio < FURNITURE_STRIP_MAX_HEIGHT && widthRatio >= FURNITURE_STRIP_MIN_WIDTH; + }; + const kept = figs.filter(f => !strip(f)); + const beforeStrip = figs; // dedup 사유 계산은 거부 전 모집단으로 해야 한다 (v2.19.4) + if (diag) { + for (const f of figs) { + if (!strip(f)) continue; + /* 거부된 후보에도 dedup 레코드를 남긴다 — 안 남기면 같은 num의 진짜 후보가 + * "sole-identity-candidate"로 기록돼 **경쟁자를 방금 잃은 바로 그 상황**에 거짓 사유가 + * 붙고, 그래프만 보고는 띠가 밀어냈다가 죽은 이력을 복원할 수 없다 (v2.19.4). */ + diag.add("dedup", { + page: pd.num, num: f.num, + candidateId: diag.figCandidateId(f), + winnerCandidateId: diag.figCandidateId(f), + rankScore: (f.raster_ ? 1e9 : 0) + f.h_, + decision: "dropped", reasons: ["furniture-strip"], + }); + diag.add("emission", { + page: pd.num, num: f.num, candidateId: diag.figCandidateId(f), + decision: "dropped", + heightRatio: +((f.y1 - f.y0) / Math.max(1, grid.H)).toFixed(4), + widthRatio: +((f.x1 - f.x0) / Math.max(1, grid.W)).toFixed(4), + maxHeightRatio: FURNITURE_STRIP_MAX_HEIGHT, minWidthRatio: FURNITURE_STRIP_MIN_WIDTH, + reasons: ["furniture-strip"], + }); + diag.add("claim", { + page: pd.num, num: f.num, candidateId: diag.figCandidateId(f), + decision: "inactive", reasons: ["furniture-strip"], + }); + } + } + for (const f of figs) { + if (!strip(f)) continue; + const info = pd.captionData.infoByAnchor.get(f._anchor); + if (info && info.adjacentState_ && info.adjacentState_.selectedFig === f) { + info.adjacentState_.emission = "dropped"; + info.adjacentState_.claim = "inactive"; + } + } + figs = kept; + 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 }; + } + if (diag) { + for (const f of figs) { + const winner = best[f.num].f; + const sameIdentityCount = beforeStrip.filter(other => other.num === f.num).length; + diag.add("dedup", { + page: pd.num, num: f.num, + candidateId: diag.figCandidateId(f), + winnerCandidateId: diag.figCandidateId(winner), + rankScore: (f.raster_ ? 1e9 : 0) + f.h_, + decision: f === winner ? "kept" : "dropped", + reasons: [sameIdentityCount === 1 ? "sole-identity-candidate" + : f === winner ? "raster-height-winner" : "lower-raster-height-rank"], + }); + } + } + for (const f of figs) { + const info = pd.captionData.infoByAnchor.get(f._anchor); + if (!info || !info.adjacentState_ || info.adjacentState_.selectedFig !== f) continue; + info.adjacentState_.emission = "none"; + info.adjacentState_.claim = "none"; + } + const emittedThisPage = diag ? [] : null; + for (const { f } of Object.values(best)) { + const widthPx = f.x1 - f.x0, heightPx = f.y1 - f.y0; + if (widthPx < 30 || heightPx < 30) { + const info = pd.captionData.infoByAnchor.get(f._anchor); + if (info && info.adjacentState_ && info.adjacentState_.selectedFig === f) { + info.adjacentState_.emission = "dropped"; + info.adjacentState_.claim = "inactive"; + } + if (diag) { + diag.add("emission", { + page: pd.num, num: f.num, candidateId: diag.figCandidateId(f), + decision: "dropped", widthPx, heightPx, minWidthPx: 30, minHeightPx: 30, + reasons: ["minimum-size"], + }); + diag.add("claim", { + page: pd.num, num: f.num, candidateId: diag.figCandidateId(f), + decision: "inactive", reasons: ["minimum-size"], + }); + } + continue; + } + if (wantCropImages) f.cropPng_ = makeCropPng(canvas, f); + allFigs.push(f); + const info = pd.captionData.infoByAnchor.get(f._anchor); + if (info && info.adjacentState_ && info.adjacentState_.selectedFig === f) { + info.adjacentState_.emission = "emitted"; + info.adjacentState_.claim = "owned"; + } + if (diag) emittedThisPage.push(f); + if (diag) { + const outputBoxPx = { x0: f.x0, y0: f.y0, x1: f.x1, y1: f.y1 }; + diag.add("emission", { + page: pd.num, num: f.num, candidateId: diag.figCandidateId(f), + decision: "emitted", widthPx, heightPx, outputBoxPx, + outputBoxPt: diag.pxBoxToPt(outputBoxPx), + reasons: ["dedup-and-size-pass"], + }); + diag.add("claim", { + page: pd.num, num: f.num, candidateId: diag.figCandidateId(f), + decision: "owned", outputBoxPx, outputBoxPt: diag.pxBoxToPt(outputBoxPx), + reasons: ["active-emitted-output"], + }); + } + } + if (diag) for (let i = 0; i < emittedThisPage.length; i++) { + const a = emittedThisPage[i]; + const areaA = Math.max(0, a.x1 - a.x0) * Math.max(0, a.y1 - a.y0); + for (let j = i + 1; j < emittedThisPage.length; j++) { + const b = emittedThisPage[j]; + const ix = Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)); + const iy = Math.max(0, Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0)); + const intersection = ix * iy; + if (!intersection) continue; + const areaB = Math.max(0, b.x1 - b.x0) * Math.max(0, b.y1 - b.y0); + const union = areaA + areaB - intersection; + diag.add("claim-relation", { + page: pd.num, decision: "contested", + claimantCandidateId: diag.figCandidateId(a), + counterpartCandidateId: diag.figCandidateId(b), + claimantNum: a.num, counterpartNum: b.num, nums: [a.num, b.num], + intersectionPx2: intersection, + iou: union > 0 ? intersection / union : 0, + claimantContainment: areaA > 0 ? intersection / areaA : 0, + counterpartContainment: areaB > 0 ? intersection / areaB : 0, + reasons: ["distinct-identity-overlap-keep", "active-emitted-claims"], + }); + } + } + } finally { + /* 정상·예외 어느 경로로 빠져나가도 페이지 캔버스 백킹 스토어를 반환한다 (B7) — + * 크롭이 이미 PNG로 직렬화돼 더 볼 일이 없고, GC를 기다리는 사이 다음 페이지 + * 캔버스와 동시 상주하는 것을 없앤다. adjacent 경로의 releaseOwnedCanvas와 같은 처리. */ + releasePageCanvas(); } } + /* N−1 observer 입력은 same-page lifecycle이 모두 끝난 이 지점에서 scalar로 동결한다. + * 이후 public figure 내부 필드 삭제와 adjacent 재렌더가 서로 영향을 주지 않는다. */ + /* 12-A/12-B eligibility는 기존 output이 없는 A/B 상태만이다. owned C 상태의 dominated/replace는 + * 12-C로 보류됐으므로 렌더하지 않는다. 이 필터 뒤 0건이면 observer가 즉시 반환한다. */ + /* 캡션 텍스트·박스는 diag record로 나가면 안 되는 원문이라 snapshot에 싣지 않고 별도 map으로 + * 넘긴다 (12-B 방출이 caption page 좌표계 값을 그대로 재사용한다). */ + const adjacentCaptionBySnapshot = new Map(); + const adjacentSnapshots = pageData.flatMap(pd => pd.num <= 1 ? [] : + pd.captionData.anchors.map(cap => { + const anchorId = diag ? diag.registerAnchor(pd, cap) : null; + const info = pd.captionData.infoByAnchor.get(cap); + const local = info && info.adjacentState_ || { + selection: "none", chosenDirection: null, selectedFig: null, + emission: "none", claim: "none", + }; + const state = diag ? diag.anchorState(anchorId) : { + chosenCandidateId: null, + chosenDirection: local.chosenDirection, + selection: local.selection, + emission: local.emission, + claim: local.claim, + }; + const snapshot = { + anchorId, num: info && info.num, + captionPage: pd.num, candidatePage: pd.num - 1, + currentSelection: state.selection, + currentChosenCandidateId: state.chosenCandidateId, + currentChosenDirection: state.chosenDirection, + currentEmission: state.emission, + currentClaimState: state.claim, + }; + adjacentCaptionBySnapshot.set(snapshot, { + text: info && info.captionTextObserved_ || (cap && cap.s) || "", + box: info && info.captionBoxObserved_ || null, + }); + return snapshot; + })).filter(snapshot => snapshot.currentClaimState !== "owned"); + const adjacentOwnedClaims = allFigs.map(f => ({ + candidateId: diag ? diag.figCandidateId(f) : null, + anchorId: diag ? diag.registerAnchor( + pageData[f.page - 1], f._anchor) : null, + num: f.num, page: f.page, + outputBoxPx: { x0: f.x0, y0: f.y0, x1: f.x1, y1: f.y1 }, + outputBoxPt: adjacentPxBoxToPt({ x0: f.x0, y0: f.y0, x1: f.x1, y1: f.y1 }), + })); + /* 12-B resolver는 정규화 **전에** 돌려야 신규 방출 figure가 같은 필드 정규화와 suspectedMissing + * 재계산을 그대로 통과한다. 방출이 0건이면 이전 버전과 완전히 동일한 경로다. */ + checkAborted(); + const adjacentResolved = await observeAdjacentPages( + pageData, dom, diag, opts, checkAborted, adjacentSnapshots, adjacentOwnedClaims, + adjacentCaptionBySnapshot); + /* 중단 조건(F8): resolver는 기존 num을 건드리지 않는다 — 같은 num이 이미 방출됐으면 gate에서 + * 걸러지므로 여기 도달하면 안 된다. 도달했다면 파일명 규칙까지 흔들리므로 즉시 실패시킨다. */ + for (const fig of adjacentResolved) { + if (allFigs.some(f => String(f.num) === String(fig.num))) + throw new Error(`PB-4B 12-B 중단: num ${fig.num}이 이미 방출됨 (replace는 12-C 잠금)`); + allFigs.push(fig); + } const figures = allFigs .sort((a, b) => a.page - b.page || String(a.num).localeCompare(String(b.num), undefined, { numeric: true })); @@ -2349,35 +3846,70 @@ async function extract(data, opts = {}) { delete f._anchor; // soft floor 판정용 내부 태그 — 출력 미포함 (v2.14.0) f.confidence = 1.0; // 당분간 고정 (Margin FigureEntry.confidence 대응) } - /* 후처리: 번호 공백 추론 — 감지된 정수 번호 1..최대 중 빠진 번호 = 미탐지 의심. - * 부록 번호("A.1", "B.2")·로마숫자는 1부터 시작한다는 가정이 안 통해 제외. (Dong-2025 유래) */ checkAborted(); // 마지막 페이지 렌더 중 abort돼도 완료 결과를 반환하지 않도록 최종 체크 + /* 후처리: 번호 공백 추론 — resolver 이후의 최종 figures만 읽어야 stale이 되지 않는다. + * 부록 번호("A.1", "B.2")·로마숫자는 + * 1부터 시작한다는 가정이 안 통해 제외한다. (Dong-2025 유래) */ + checkAborted(); const intNums = new Set(figures.map(f => String(f.num)).filter(n => /^\d+$/.test(n)).map(Number)); const suspectedMissing = []; if (intNums.size) { const maxN = Math.max(...intNums); for (let n = 1; n <= maxN; n++) if (!intNums.has(n)) suspectedMissing.push(String(n)); } + if (diag) { + diag.add("document-end", { + decision: "complete", numPages: pdf.numPages, scannedPages: nPages, + emittedFigures: figures.length, suspectedMissing, + }); + diag.finish(); + } return { title, numPages: pdf.numPages, figures, suspectedMissing, engineVersion: VERSION }; } /* ===================== 크롭 헬퍼 ===================== */ -/* 스캔 루프 안에서 페이지 캔버스로부터 그림 영역만 잘라낸다 — 페이지 캔버스는 보관하지 않는다 (#12) */ -function makeCrop(pageCanvas, f) { +/* 스캔 루프 안에서 페이지 캔버스로부터 그림 영역만 잘라내 **즉시 PNG로 직렬화**한다 (v2.19.1, B7). + * 캔버스로 들고 있으면 문서 스캔이 끝날 때까지(수십 초) Chrome이 백킹 스토어를 회수할 수 있는 + * 상태로 남는다 — 실제 전수 실행에서 그 창 동안 한 논문 크롭 전량이 백지가 됐다. PNG 문자열은 + * 평범한 JS 데이터라 회수 대상이 아니고, 압축돼 있어 논문당 상주도 130MB → 수MB로 떨어진다. */ +function makeCropPng(pageCanvas, f) { const cw = f.x1 - f.x0, ch = f.y1 - f.y0; + /* 기하 사전조건 — 도달 불가여야 한다(최소 크기 30px 필터·furniture 클램프). 일반 Error로 두는 + * 게 중요하다: FigRenderError로 던지면 배치 러너가 메모리 압력으로 오인해 논문마다 Chrome을 + * 재시작하고, 결정적 기하 버그가 일시적 장애로 위장된다. */ + if (cw <= 0 || ch <= 0) throw new Error(`크롭 영역이 비어 있습니다 (${cw}×${ch})`); 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(pageCanvas, f.x0, f.y0, cw, ch, 0, 0, cw, ch); - return c2; + /* 카나리아: 흰 배경을 칠했으므로 살아 있는 캔버스라면 (0,0)은 반드시 불투명하다. 1픽셀로 + * 충분한 이유는 호출부(페이지 루프) 주석 참고 — makeInk 이후 여기까지 await가 없다. */ + if (ctx.getImageData(0, 0, 1, 1).data[3] !== 255) + throw figRenderError(`크롭 캔버스가 비어 있습니다 (${cw}×${ch} — 메모리 부족으로 캔버스가 회수됐을 수 있습니다)`); + const png = c2.toDataURL("image/png"); + c2.width = 0; c2.height = 0; // 백킹 스토어 즉시 반환 + return 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")); +/* v2.19.1: 크롭은 스캔 중 PNG data URL로 직렬화된다 — 아래 둘은 `f.cropPng_`를 읽는 접근자. + * `cropCanvas` 접근자와 `figure.cropCanvas` 필드는 제거됐다 ([BREAKING], B7) — 크롭 캔버스를 + * 문서 끝까지 살려 두는 구조 자체가 결함의 원인이었다. */ +const cropDataURL = f => { + if (typeof f.cropPng_ !== "string") + throw new Error("크롭 이미지가 없습니다 — extract를 cropImages:false(진단 전용)로 호출했습니다."); + return f.cropPng_; +}; +/* base64를 직접 디코드한다 — `fetch(dataUrl)`가 한 줄이고 off-thread지만, 유일한 소비자인 + * Margin은 확장 CSP(connect-src) 아래라 data: fetch가 막힐 수 있다. 바이트는 동일하다. */ +const cropBlob = async f => { + const url = cropDataURL(f); + const bin = atob(url.slice(url.indexOf(",") + 1)); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return new Blob([buf], { type: "image/png" }); +}; -return { VERSION, extract, cropCanvas, cropDataURL, cropBlob, isCaption, isTableCaption, buildLines }; +return { VERSION, extract, cropDataURL, cropBlob, isCaption, isTableCaption, buildLines }; })(); diff --git a/src/viewer/panel/tab-figures.ts b/src/viewer/panel/tab-figures.ts index 5bb4deb..a74aac5 100644 --- a/src/viewer/panel/tab-figures.ts +++ b/src/viewer/panel/tab-figures.ts @@ -7,6 +7,18 @@ export type FiguresTabCallbacks = { type FiguresTabEngine = Pick; +/** + * 엔진이 `name`으로만 구별해 주는 렌더 실패 (v2.19.1+ — 별도 클래스가 아니라 이름을 덮어쓴 Error다). + * + * 엔진의 판별식(`fig-extract.js`: `!!error && error.name === "FigRenderError"`)을 **그대로** 쓴다. + * `instanceof Error`를 덧붙이면 소비자가 엔진보다 좁아진다 — 오류가 realm 경계(worker·다른 프레임)를 + * 넘거나 구조화 복제를 거치면 `instanceof`가 깨지는데 `name`은 남아, 이름은 맞지만 분기만 조용히 + * 사라진다. 좁힐 근거가 없으므로 공급자와 같은 술어를 쓴다. + */ +function isFigRenderError(error: unknown): boolean { + return !!error && (error as { name?: unknown }).name === 'FigRenderError'; +} + /** * 그림·표 탭 — fig-extract 엔진으로 문서를 스캔해 figure 프리뷰 카드를 렌더한다. * 스캔은 PDFDocumentProxy가 준비되는 즉시 1회 실행한다. 문서가 바뀌면 setDocument()로 리셋. @@ -101,7 +113,15 @@ export class FiguresTab { if (this.#scanGeneration !== scanGeneration) return; console.error('figure 스캔 실패', error); this.#state = 'error'; - this.#setStatus('figure 스캔에 실패했어요.', true); + /* FigRenderError(엔진 v2.19.1+)는 "렌더 결과가 존재하지 않는다" — 메모리 압력을 받은 Chrome이 + * 캔버스 백킹 스토어를 회수한 경우다. 일시적 조건이라 같은 문서로 다시 시도하면 성공할 수 + * 있으므로, 일반 실패와 달리 그 사실을 문구로 알린다. 재시도 경로 자체는 동일하다. */ + this.#setStatus( + isFigRenderError(error) + ? '메모리가 부족했을 수 있어요. 다른 탭을 닫고 다시 시도해 주세요.' + : 'figure 스캔에 실패했어요.', + true + ); } finally { if (this.#scanAbort === abort) this.#scanAbort = null; } diff --git a/src/viewer/pdf-host.ts b/src/viewer/pdf-host.ts index 41c5728..73bf077 100644 --- a/src/viewer/pdf-host.ts +++ b/src/viewer/pdf-host.ts @@ -318,9 +318,10 @@ export class PdfHost { this.refreshLayoutSoon(); /* 이전 문서는 **뷰어·linkService가 새 문서로 전환된 뒤에** 정리한다 (#35). pdf.js는 * PDFDocumentProxy를 destroy()하지 않으면 워커 측 자원과 페이지 프록시를 계속 붙들고 있어 - * GC 대상이 되지 않는다 — 한 세션에서 문서를 N번 열면 N개가 그대로 상주하고, 벤더링본 - * v2.14.0에서는 크롭도 살아 있는 캔버스라 Chrome이 캔버스 백킹 스토어를 회수하는 조건 - * (엔진 백로그 B7)에 직접 기여한다. + * GC 대상이 되지 않는다 — 한 세션에서 문서를 N번 열면 N개가 그대로 상주해 Chrome이 캔버스 + * 백킹 스토어를 회수하는 메모리 압력 조건(엔진 백로그 B7)에 직접 기여한다. + * (크롭 자체는 엔진 v2.19.1부터 PNG 문자열이라 회수 대상이 아니다. 남은 기여분은 문서 + * 프록시·워커 자원과 페이지 캔버스 쪽이다.) * 순서가 중요하다 — 전환 전에 destroy하면 뷰어가 방금 파괴된 문서를 렌더하려 한다. */ if (previous && previous !== doc) void this.#releaseDocument(previous); } diff --git a/test/fig-engine.test.ts b/test/fig-engine.test.ts index 38563ec..2998c9d 100644 --- a/test/fig-engine.test.ts +++ b/test/fig-engine.test.ts @@ -1,22 +1,48 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { FigExtract, VENDORED_ENGINE_VERSION, requireFigExtract, toFigureEntries, + toFigureEntry, toPdfRect, type EngineFigure, type EngineResult, type FigExtractApi } from '../src/core/fig-engine'; +/** + * 페이지 높이 조회 mock. **페이지마다 높이를 다르게 주는 것이 요점이다** — 모든 페이지가 같은 + * 높이면 `getPageHeightPt(f.page)`를 `f.captionPage ?? f.page`로 바꿔도 좌표가 그대로라 테스트가 + * 통과한다. 지도에 없는 페이지를 물으면 던져서, "어느 페이지 높이를 물었나"까지 고정한다. + */ +function pageHeights(heights: Record) { + return vi.fn((pageNum: number): number => { + const height = heights[pageNum]; + if (height === undefined) throw new Error(`물으면 안 되는 페이지 높이: ${pageNum}`); + return height; + }); +} + describe('figure engine integration', () => { it('pins the vendored engine version these types were written for', () => { - /* 이 파일의 타입·주석과 docs/fig-extract-integration.md의 "다음 벤더링 할 일"은 이 버전을 - * 전제한다. 엔진을 벤더링하면 여기서 깨지고, 그게 TODO 목록으로 돌아가라는 신호다. */ + /* 상수는 "옆에 있는 src/core/fig-extract.js가 이 버전이다"라는 단언이다. 엔진 파일을 + * 복사하면 여기서 깨지고, 그게 docs/fig-extract-integration.md §갱신 절차로 돌아가라는 + * 신호다 — 파일 복사와 상수 갱신은 같은 커밋이어야 한다. + * ⚠ 이 핀이 지키는 것은 **상수 ↔ 엔진 파일**뿐이다. 이 파일의 타입·주석이 어느 버전을 + * 기준으로 쓰였는지는 기계가 검사할 수 없다 — 그건 §갱신 절차가 지킨다. */ expect(FigExtract.VERSION).toBe(VENDORED_ENGINE_VERSION); }); + /* FigExtractApi는 손으로 쓴 선언이라 컴파일러가 런타임과 대조해 주지 않는다. 벤더링이 실제로 + * 쓰는 세 함수를 잃어버리면(예: v2.19.1의 `cropCanvas` 제거처럼 export 목록이 바뀌면) 타입은 + * 통과하고 프리뷰 렌더에서 처음 터진다 — 그 전에 여기서 잡는다. */ + it('exposes the crop accessors the viewer actually calls', () => { + for (const name of ['extract', 'cropDataURL', 'cropBlob'] as const) { + expect(typeof FigExtract[name]).toBe('function'); + } + }); + it('fails clearly when the vendored global is missing', () => { expect(() => requireFigExtract({})).toThrow(/FigExtract가 전역에 등록되지 않았습니다/); }); @@ -42,21 +68,21 @@ describe('figure engine integration', () => { { num: '2', page: 3, - confidence: 0.9, + confidence: 1, // 엔진은 1.0 고정 (플레이스홀더) — 다른 값은 나올 수 없다 caption: 'Figure 2. Result', 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 }, - /* 엔진이 실어 보내는 "선언에 없는 필드"의 대리다 — 벤더링본 v2.14.0의 `cropCanvas`, - * v2.19.1의 `cropPng_`, v2.19.0의 `captionPage`가 전부 여기 해당한다. 아래 toEqual이 - * 이걸 잡아야 "toFigureEntries가 엔진 객체를 그대로 흘리지 않는다"가 보장된다. - * (spread 리팩터가 들어오면 FigureSeed에 캔버스나 수 MB data URL이 실려 storage로 간다.) */ + /* 엔진이 실어 보내는 "선언에 없는 필드"의 대리다 — 벤더링본 v2.14.0의 `cropCanvas`와 + * v2.19.1의 `cropPng_`(수 MB PNG data URL)가 여기 해당한다. 아래 toEqual이 이걸 잡아야 + * "toFigureEntries가 엔진 객체를 그대로 흘리지 않는다"가 보장된다. + * (spread 리팩터가 들어오면 FigureSeed에 수 MB data URL이 실려 storage로 간다.) */ surplusEngineField: 'must not leak into FigureSeed' } as unknown as EngineFigure ] }; - const [seed] = toFigureEntries(result, () => 800); + const [seed] = toFigureEntries(result, pageHeights({ 3: 900 })); expect(seed).toEqual({ id: 'fig2-p3', kind: 'figure', @@ -64,14 +90,91 @@ describe('figure engine integration', () => { label: 'Figure 2', page: 3, captionText: 'Figure 2. Result', - region: { page: 3, rect: [10, 580, 110, 780] }, + /* 엔진이 captionPage를 안 실어 보내면 캡션도 그림 페이지에 있다는 뜻이다 */ + captionPage: 3, + region: { page: 3, rect: [10, 680, 110, 880] }, regionSource: 'auto', - confidence: 0.9 + confidence: 1 }); expect(seed).not.toHaveProperty('doc'); expect(seed).not.toHaveProperty('captionAnchor'); }); + /* v2.19.0 12-B: 캡션이 다음 장 상단이고 그림은 앞 페이지인 레이아웃. 이 필드를 버리면 M3의 + * captionAnchor 계산이 그림 페이지에서 캡션을 찾다가 **오류 없이** 빈손으로 끝난다. */ + it('carries the caption page for cross-page figures (v2.19.0 12-B)', () => { + const result: EngineResult = { + title: 'Cross-page caption', + numPages: 12, + engineVersion: 'test', + suspectedMissing: [], + figures: [ + { + num: '4', + page: 6, + captionPage: 7, + confidence: 1, + caption: 'Figure 4. Spans a page break', + bboxPt: { x0: 10, y0: 20, x1: 110, y1: 220 }, + /* caption page 좌표계다 — 그림 페이지 높이로 변환하면 조용히 틀린다. + * toFigureEntries는 이 박스를 변환하지 않으므로 여기서는 통과만 확인한다. */ + captionBoxPt: { x0: 10, y0: 40, x1: 110, y1: 70 }, + bboxPx: { x0: 22, y0: 44, x1: 242, y1: 484 } + } + ] + }; + + /* 두 페이지 높이를 다르게 준다: region이 어느 쪽 높이로 변환됐는지가 좌표에 드러난다 */ + const heights = pageHeights({ 6: 1000, 7: 500 }); + const [seed] = toFigureEntries(result, heights); + + expect(seed.page).toBe(6); // 식별 키 (num, page)·페이지 점프·region은 그림 페이지 + expect(seed.captionPage).toBe(7); // 캡션 텍스트 검색은 캡션 페이지 + expect(seed.id).toBe('fig4-p6'); + /* 그림 페이지(1000) 기준: y' = 1000 − y. 캡션 페이지(500)를 썼다면 [10, 280, 110, 480]이다 */ + expect(seed.region).toEqual({ page: 6, rect: [10, 780, 110, 980] }); + expect(heights.mock.calls).toEqual([[6]]); // 캡션 페이지 높이는 묻지 않는다 + }); + + /* M3 함정 고정: 영속 스키마에 seed 전용 필드가 새면 안 된다. TS strict도 스프레드 + * (`{ ...seed, doc, captionAnchor }`)에는 초과 속성 검사를 하지 않아 컴파일이 막지 않는다 — + * store.saveDoc이 화이트리스트 없이 통째로 저장하므로 그대로 chrome.storage에 영속된다. */ + it('drops seed-only fields when completing a FigureEntry', () => { + const seed = toFigureEntries( + { + title: 'Cross-page caption', + numPages: 12, + engineVersion: 'test', + suspectedMissing: [], + figures: [ + { + num: '4', + page: 6, + captionPage: 7, + confidence: 1, + caption: 'Figure 4. Spans a page break', + bboxPt: { x0: 10, y0: 20, x1: 110, y1: 220 }, + captionBoxPt: { x0: 10, y0: 40, x1: 110, y1: 70 }, + bboxPx: { x0: 22, y0: 44, x1: 242, y1: 484 } + } + ] + }, + pageHeights({ 6: 1000 }) + )[0]; + + /* captionAnchor.page는 seed.captionPage와 같아야 한다 — 호출 측 책임이고, 그래서 seed가 + * 그 값을 날라 왔다. 여기까지 오면 captionPage 자체는 더 이상 필요 없다. */ + const entry = toFigureEntry(seed, 'doc-1', { page: seed.captionPage, start: 12, end: 40 }); + + expect(entry).not.toHaveProperty('captionPage'); + expect(Object.keys(entry).sort()).toEqual([ + 'captionAnchor', 'captionText', 'confidence', 'doc', 'id', 'kind', + 'label', 'num', 'page', 'region', 'regionSource' + ]); + expect(entry.captionAnchor.page).toBe(7); + expect(entry.page).toBe(6); + }); + it('preserves figure numbers reused on different pages', () => { const box = { x0: 10, y0: 20, x1: 110, y1: 220 }; const captionBox = { x0: 10, y0: 225, x1: 110, y1: 250 }; @@ -85,7 +188,7 @@ describe('figure engine integration', () => { { num: '1', page: 2, - confidence: 0.9, + confidence: 1, caption: 'Figure 1. Chapter one result', bboxPt: box, captionBoxPt: captionBox, @@ -94,7 +197,7 @@ describe('figure engine integration', () => { { num: '1', page: 18, - confidence: 0.8, + confidence: 1, caption: 'Figure 1. Chapter two result', bboxPt: box, captionBoxPt: captionBox, @@ -103,11 +206,16 @@ describe('figure engine integration', () => { ] }; - const seeds = toFigureEntries(result, () => 800); + /* 페이지마다 높이가 다르므로 "figure별로 자기 페이지 높이를 쓴다"까지 고정된다 */ + const seeds = toFigureEntries(result, pageHeights({ 2: 800, 18: 1200 })); expect(seeds.map(({ id, num, page }) => ({ id, num, page }))).toEqual([ { id: 'fig1-p2', num: '1', page: 2 }, { id: 'fig1-p18', num: '1', page: 18 } ]); + expect(seeds.map((s) => s.region)).toEqual([ + { page: 2, rect: [10, 580, 110, 780] }, + { page: 18, rect: [10, 980, 110, 1180] } + ]); }); }); diff --git a/test/tab-figures.test.ts b/test/tab-figures.test.ts index eb19803..0a15f0b 100644 --- a/test/tab-figures.test.ts +++ b/test/tab-figures.test.ts @@ -57,9 +57,15 @@ class FakeElement { } const doc = {} as PDFDocumentProxy; -const figure = (page: number, num = String(page)): EngineFigure => ({ +const figure = ( + page: number, + num = String(page), + captionPage?: number +): EngineFigure => ({ num, page, + /* 엔진은 captionPage가 page와 다를 때만 필드를 싣는다 — 없는 상태도 그대로 재현한다 */ + ...(captionPage === undefined ? {} : { captionPage }), confidence: 1, caption: `Figure ${num}`, bboxPt: { x0: 0, y0: 0, x1: 10, y1: 10 }, @@ -118,6 +124,31 @@ describe('FiguresTab', () => { expect(onJumpToPage).toHaveBeenCalledWith(4); }); + /* v2.19.0 12-B: 캡션은 7페이지, 그림은 6페이지. 카드가 가리키는 것은 **그림 페이지**다 — + * 식별 키가 (num, page)이고 사용자가 카드를 눌러 보고 싶은 것도 그림이다. captionPage로 + * 바꾸는 뮤테이션을 이 테스트가 죽인다 (기존 픽스처는 captionPage가 없어 구별 불가였다). */ + it('points cards at the figure page, not the caption page', async () => { + const list = new FakeElement(); + const onJumpToPage = vi.fn(); + const engine = makeEngine(vi.fn(async () => result([figure(6, '4', 7)]))); + const tab = new FiguresTab( + list as unknown as HTMLElement, + { onJumpToPage }, + engine + ); + + tab.setDocument(doc); + await vi.waitFor(() => expect(list.children[0]?.className).toBe('fig-card')); + + const card = list.children[0]; + expect(card.dataset.page).toBe('6'); + expect(card.attributes['aria-label']).toBe('Figure 4, 6페이지로 이동'); + /* 카드 머리의 "p.N" 칩도 그림 페이지다 (children: img, head[label, page], caption) */ + expect(card.children[1].children[1].textContent).toBe('p.6'); + list.emit('click', card); + expect(onJumpToPage).toHaveBeenCalledWith(6); + }); + it('offers a retry after failure and succeeds without replacing the document', async () => { const list = new FakeElement(); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -139,6 +170,58 @@ describe('FiguresTab', () => { expect(error).toHaveBeenCalledTimes(1); }); + /* 엔진 v2.19.1+는 렌더 결과가 존재하지 않을 때(메모리 압력으로 Chrome이 캔버스 백킹 스토어를 + * 회수) `name === 'FigRenderError'`로 거절한다. 일시적 조건이라 재시도가 유효하므로 일반 + * 실패와 다른 문구를 준다 — 통합 규약 §주의사항 `FigRenderError`. */ + it('tells the user to retry when the engine reports a dead canvas', async () => { + const list = new FakeElement(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const renderError = new Error('페이지 렌더 결과가 비어 있습니다'); + renderError.name = 'FigRenderError'; + const tab = new FiguresTab( + list as unknown as HTMLElement, + { onJumpToPage: vi.fn() }, + makeEngine(vi.fn(async () => { throw renderError; })) + ); + + tab.setDocument(doc); + await vi.waitFor(() => expect(list.children[0]?.children[0]?.className).toContain('fig-retry')); + expect(list.children[0]?.textContent).toContain('메모리가 부족했을 수 있어요'); + }); + + /* 엔진의 판별식은 `!!error && error.name === "FigRenderError"`다. 소비자가 `instanceof Error`를 + * 덧붙이면 realm 경계나 구조화 복제를 넘어온 거절에서 이름은 맞는데 분기만 조용히 사라진다. + * Error 인스턴스가 아닌 거절로 그 좁힘을 죽인다. */ + it('recognises the engine error by name alone, not by instanceof', async () => { + const list = new FakeElement(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const tab = new FiguresTab( + list as unknown as HTMLElement, + { onJumpToPage: vi.fn() }, + makeEngine(vi.fn(async () => { + throw { name: 'FigRenderError', message: '페이지 렌더 결과가 비어 있습니다' }; + })) + ); + + tab.setDocument(doc); + await vi.waitFor(() => expect(list.children[0]?.children[0]?.className).toContain('fig-retry')); + expect(list.children[0]?.textContent).toContain('메모리가 부족했을 수 있어요'); + }); + + it('keeps the generic message for other failures', async () => { + const list = new FakeElement(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const tab = new FiguresTab( + list as unknown as HTMLElement, + { onJumpToPage: vi.fn() }, + makeEngine(vi.fn(async () => { throw new Error('boom'); })) + ); + + tab.setDocument(doc); + await vi.waitFor(() => expect(list.children[0]?.children[0]?.className).toContain('fig-retry')); + expect(list.children[0]?.textContent).toContain('figure 스캔에 실패했어요'); + }); + it('discards a stale scan when the document changes', async () => { const list = new FakeElement(); let resolveFirst: ((value: EngineResult) => void) | undefined; From 5ba94c1b2ab6cc17acc2f8250b766dec3c2fdcc2 Mon Sep 17 00:00:00 2001 From: onetwothr1 Date: Wed, 29 Jul 2026 02:49:24 +0900 Subject: [PATCH 2/2] chore: fix variable name in example code of integration doc --- docs/fig-extract-integration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/fig-extract-integration.md b/docs/fig-extract-integration.md index 0a77358..7c15310 100644 --- a/docs/fig-extract-integration.md +++ b/docs/fig-extract-integration.md @@ -41,7 +41,8 @@ const res = await FigExtract.extract(null, { const seeds = toFigureEntries(res, (p) => pageHeights[p]); // seeds: FigureEntry에서 doc·captionAnchor가 빠지고 captionPage가 더해진 형태. // 영속화는 반드시 toFigureEntry()로 — captionPage를 떨어뜨린다 (아래 §cross-page 캡션) -const entry = toFigureEntry(seed, docId, anchorFoundIn(seed.captionPage, seed.captionText)); +const entries = seeds.map((seed) => + toFigureEntry(seed, docId, anchorFoundIn(seed.captionPage, seed.captionText))); ``` ## 현재 통합 상태