feat(parse-pdf): #232 스캔 PDF 를 페이지 렌더링으로 OCR #238

Merged
altair823 merged 4 commits from feat/pdf-page-render into main 2026-08-16 18:45:02 +00:00

4 Commits

Author SHA1 Message Date
df76e5e874 chore: PR #238 회차 3 리뷰 반영 — 검증 안 하던 테스트 교체
3회차 리뷰가 `.ok().flatten()` 의 타당성과 `long_edge_for_dpi` 비공개화를
확인하고 머지 가능으로 결론냈다. 남은 둘을 반영한다.

1) 2회차에 넣은 테스트 하나가 아무것도 검증하지 않았다 (MEDIUM)

   "렌더러가 PDF 를 못 여는 경우" 테스트에 잘린 바이트를 썼는데 **lopdf 가
   그걸 먼저 거부**했다. 함수 초반 `load_mem(...)?` 에서 리턴되므로 pdfium
   은 호출조차 안 됐고 `(None, true)` 분기는 한 번도 실행되지 않았다.
   통과하는데 아무것도 지키지 않는 테스트였다 — 1회차에서 "렌더가 실패하지
   않으니 도그푸딩도 통과해 버렸다" 고 배운 것과 같은 함정을 테스트에서
   반복한 셈이다.

   **암호 없는 암호화 PDF** 픽스처로 교체했다. lopdf 는 객체 그래프를 읽고
   pdfium 은 `PasswordError` 로 거부하는, 정확히 원하던 불일치다. 이제
   `failure_reason` 까지 단언하고, `unopenable_pdf` 를 `no_renderer` 로
   되돌리면 실패하는 것을 확인했다.

2) `(None, true)` 분기에도 `?` 가 남아 있었다 (MEDIUM)

   `(Some, _)` 와 근거가 같고 오히려 더 강하다 — pdfium 이 문서 전체를
   열지 못한 상태라 lopdf 도 깨져 있을 상관관계가 최대인 지점이다.

3) 잔가지 (LOW)

   - 삼킨 DCT 에러를 `inspect_err` 로 debug 로그에 남긴다. 바로 아래 OCR
     캐시 GET 이 같은 규율을 쓰는데 이 자리만 비어 있었다.
   - README 의 max_pixels 하드캡 안내를 "ollama-vision 엔진" 에서 "OCR
     엔진(둘 다)" 으로 — paddle-onnx 도 동일하게 조인다.

미반영으로 **명시**: `(Some, _)` 의 `?` 수정에는 테스트가 없다. 루프가
`get_pages()` 가 나열한 페이지만 도는데 그 조회가 곧
`extract_dctdecode_page_image` 가 실패하는 조건이라, 이 arm 의 에러 경로에
닿는 픽스처를 만들 수 없다. 0-페이지 PDF 로 시도했다가 루프 자체가 안 도는
것을 확인하고 접었고, 만들었던 테스트와 픽스처는 지웠다 — 통과하지만
아무것도 안 지키는 테스트를 또 만드는 것보다 없는 편이 정직하다. 왜
못 만드는지를 코드 주석에 남겼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 03:36:03 +09:00
7b3057fcd6 chore: PR #238 회차 2 리뷰 반영 — 폴백의 ? 회귀 + 렌더 경로 테스트
2회차 리뷰가 1회차 지적 일곱 건 모두 해결을 확인했고(HIGH 수정은
pdfium-render 내부 로직 정독 + 세로·가로 양쪽 실측으로 정당성 검증)
머지 가능으로 결론냈다. 남은 둘을 반영한다.

1) 페이지 폴백의 `?` 가 페이지 스킵을 문서 전체 중단으로 격상시켰다 (MEDIUM)

   1회차에서 넣은 페이지 단위 DCTDecode 폴백이 `dct(&pdf_doc)?` 였다.
   `extract_dctdecode_page_image` 는 페이지 딕셔너리를 못 읽으면 Err 를
   내므로, **렌더 실패 + DCT 추출 에러 = PDF 한 건 전체 OCR 중단**이다.
   두 실패는 상관관계도 있다 — 렌더가 깨지는 PDF 가 곧 lopdf 딕셔너리도
   이상한 PDF다.

   이 PR 이 직접 만든 회귀다. 이전에는 렌더러가 열린 이상 렌더 실패가
   절대 치명적일 수 없었다. 게다가 성격이 이 PR 이 잡으려던 "조용한 손실"
   과 정확히 같은 계열이다 — 페이지 하나 때문에 문서 전체를 잃는다.
   같은 파일 주석이 스스로 "the per-page loop is resilient by design" 이라
   적어 둔 규율을 한 줄이 깨고 있었다. `.ok().flatten()` 으로 고쳤다.

2) 분기가 가장 많이 늘어난 파일에 검증이 가장 적었다 (MEDIUM)

   1회차의 교훈이 "렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다" 였는데,
   2회차에서 새로 만든 세 분기((Some,_) / (None,true) / (None,false)) 에
   테스트가 하나도 없었다. 추가한 2건은 전부 렌더 기하 테스트였다.

   `crates/kebab-app/tests/pdf_ocr_apply.rs` 에 세 건을 넣었다. OCR 엔진은
   기존 MockOcrEngine 이라 네트워크도 모델도 필요 없고, 래스터화와 그것을
   고르는 분기만 탄다.

   - `a_ccitt_page_reaches_the_ocr_engine_once_a_renderer_is_configured`
     — 이 이슈의 핵심. 바로 위 `f7_ccittfax_skipped_with_warning` 이 같은
     픽스처가 렌더러 없이 skip 됨을 고정하고 있으니, 둘이 짝으로 "렌더러가
     차이를 만든다" 를 증명한다. 렌더 경로를 우회시키면 실패하는 것을
     확인했다.
   - `a_dctdecode_page_still_works_with_a_renderer_configured` — 렌더러가
     기존 커버리지를 잃으면 구멍을 옮긴 것에 불과하다.
   - `a_pdf_the_renderer_cannot_open_falls_back_instead_of_blaming_config`

3) 잔가지 (LOW)

   - `long_edge_for_dpi` 의 `pub use` 가 죽었다. kebab-app 이 캡 계산을
     넘긴 뒤로 크레이트 밖 호출자가 없다. 비공개로 내렸다.
   - HOTFIXES 의 CER 표가 8+8+6=22 쪽인데 문장은 "8 페이지 전부" 였다.
   - 같은 절의 "수정 전" 이 "72 DPI 수정 전" 으로도 읽혔다. 그 해석이면
     거짓이라(초안도 렌더는 했다) "이 PR 이전에는" 으로 바꿨다.
   - README 의 max_pixels 상향 안내에 엔진 하드캡이 빠졌다 —
     ollama-vision 이 256~4096 으로 다시 조인다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 03:12:38 +09:00
e76e909f56 chore: PR #238 회차 1 리뷰 반영 — render_dpi 가 동작하지 않았다
리뷰가 이 PR 의 핵심을 무너뜨리는 결함을 잡았다.

1) render_dpi 가 아무 일도 안 하고 있었다 (HIGH)

   `set_maximum_*` 만 걸었는데, pdfium-render 에서 maximum 은 초과할 때만
   줄이는 클램프이고 스케일이 아니다. 타깃도 배율도 없으면 스케일 1.0 —
   1 pt → 1 px, 즉 **72 DPI** 로 렌더된다. 300 을 주든 1200 을 주든 산출물이
   같았다. 렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다.

   실측 (govdocs1-000157-ccitt.pdf 5쪽):
     maximum_* 만 (초안)        621×801 px    72 DPI
     target + maximum_* (수정)  1588×2048 px  184 DPI

   같은 뿌리로 종횡비도 깨져 있었다. 클램프만 걸리는 경로는
   `do_maintain_aspect_ratio = false` 라 가로·세로가 독립적으로 잘린다.
   600×800pt 페이지를 600px 예산으로 렌더하면 600×600 으로 세로가 25%
   눌린 채 나왔고, 긴 변만 보던 테스트는 초록불이었다.

   `render_dpi_changes_the_rendered_size` 와
   `the_pixel_budget_is_respected_without_distorting_the_page` 로 고정했다.
   `set_target_width` 한 줄을 되돌리면 둘 다 실패하는 것을 확인했다.

   덧붙여 render_dpi 는 **요청**이고 max_pixels 가 이긴다. PDF 기본값
   2048 이면 A4 는 175 DPI 언저리에서 잘린다. 기본값 300 이 그대로 나오지
   않는다는 뜻이라 config·README·SMOKE 문구를 실제와 맞췄다.

2) /MediaBox 를 직접 파싱하고 있었다 (MEDIUM)

   `/MediaBox` 는 상속 속성이고 대부분의 생산자가 `/Pages` 노드에 한 번만
   쓴다. lopdf 0.32 에는 상속 해석 헬퍼가 없어서 그런 PDF 는 전부 조용히
   A4 폴백을 탔다. `/UserUnit` 도 미반영이었다.

   pdfium 이 이미 페이지 크기를 안다. 거기서 받으니 40여 줄이 사라지고
   상속·UserUnit 문제가 함께 없어졌으며, kebab-app 이 lopdf 딕셔너리를
   뒤지던 레이어링도 정리됐다.

3) 렌더러가 있으면 오히려 손해 보는 경우가 있었다 (MEDIUM)

   페이지 하나만 렌더에 실패하면 곧장 skip 이었고 DCTDecode 경로를 시도하지
   않았다. "렌더러 우선 + 폴백" 이 렌더러 유무 수준에서만 성립했던 것이다.
   페이지 단위 폴백을 넣었다.

4) 렌더러를 설정한 사용자에게 틀린 지시가 나갔다 (MEDIUM)

   pdfium 이 PDF 자체를 못 열면 모든 페이지가 no_renderer 로 보고되면서
   "render_library 를 지정하라" 고 안내했다. `unopenable_pdf` 로 갈랐다.

5) ⊘ 줄 수와 ocr-skipped 카운트가 안 맞았다 (MEDIUM)

   카운트는 래스터 실패만 세는데 OCR 엔진 실패도 화면에는 똑같이 ⊘ 로
   찍혔다. 사유를 라벨에 적어 둘을 구분한다 — 이 구분이 바로 아래 도그푸딩
   에서 실제로 값을 했다.

6) 잔가지 (LOW)

   docs 의 pdf-text-v1 잔재 3곳, doctor hint 의 줄 이음이 무너져 생긴 여백.

정답 있는 한국어 스캔으로 인식률을 쟀다 (CCITT 3건, qwen2.5vl:3b):

  namu-beulenda…   8쪽  CER 15.65%
  namu-bihaengdae  8쪽  CER 12.55%
  namu-gu-anoli    6쪽  CER 15.08%

전 페이지 OCR 성공, 건너뜀 0. 수정 전에는 세 문서 모두 본문 0 자였다.

엔진 선택이 결과를 가른다는 것도 알게 됐다. 처음에는 이 머신에 있던
gemma3:4b 로 쟀는데 래스터는 정상인데 출력이 원문과 무관한 환각이었고,
해상도가 올라가자 밀집 한국어 페이지에서 180초 타임아웃이 났다. 범용
멀티모달 모델은 OCR 엔진이 아니다 — 이때 5번의 새 라벨이 "래스터 없음"이
아니라 "OCR 엔진 실패"로 찍어 줘서 원인이 바로 갈렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 02:45:49 +09:00
2871da14f4 feat(parse-pdf): #232 스캔 PDF 를 페이지 렌더링으로 OCR
`extract_dctdecode_page_image` 는 페이지의 image XObject 중 `/Filter` 가
정확히 DCTDecode 인 것 하나만 받는다. 실제 스캔본에서 흔한 CCITTFaxDecode·
JBIG2Decode·FlateDecode·JPXDecode, `[FlateDecode, DCTDecode]` 같은 체인,
Internet Archive 계열의 "배경 + /ImageMask" 분리 구조가 전부 걸러진다.

텍스트 게이트는 정상 동작했다. `needs_ocr` 판정을 통과했다는 건 "이 페이지는
스캔본이라 OCR 이 필요하다" 고 올바르게 본 것이다. 판정은 맞았고 래스터를 못
꺼냈을 뿐인데, 결과가 조용한 내용 손실이었다 — 색인은 성공으로 끝나고,
검색이 안 되는 시점에야 알게 되며, 그때 원인이 PDF 인코더라는 걸 역추적할
방법이 없다.

페이지를 렌더링한다 (`page_render::PageRenderer`, pdfium). 지원할 필터도,
고를 XObject 도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다.
이슈가 지적한 "image XObject 선택이 비결정적" 문제도 이 경로에서는 성립하지
않는다.

이슈는 교체를 권했지만 렌더러 우선 + DCTDecode 폴백으로 갔다. 배포 형태
때문이다 — pdfium 은 공유 라이브러리로만 배포되고 정적 빌드가 없어서,
링크하면 CLAUDE.md 가 규정한 단일 바이너리가 깨진다. 사용자와 상의해 정했다.

  - 런타임 바인딩. 있으면 전 인코딩 커버, 없으면 오늘 동작 + 왜 건너뛰었는지.
  - `[ingest.pdf.ocr] render_library` 로 경로 지정, 비우면 로더 경로 탐색.
  - `kebab doctor` 의 `pdf_render` 가 어느 쪽인지 보고.
  - 바이너리 392.9 → 399.3 MB (+6.4 MB 글루). ldd 에 pdfium 없음.

조용한 손실을 시끄럽게 (이슈 부수 제안 2·3):

`failure_reason` 이 CLI 에서 `..` 로 버려지고 있었다. wire 이벤트는 원인을
구분해 싣는데 사람이 보는 출력이 "no DCTDecode or engine fail" 로 뭉갰다.
이제 no_renderer / render_error / ocr_error 를 구분해 찍는다.
`IngestReport.ocr_skipped_pages` 를 추가하고(additive) 사람용 요약에도
`ocr-skipped N` 으로 낸다 — stderr 한 줄로 흘리면 대량 ingest 에서 지나간다.

parser_version cascade: pdf-text-v1 → pdf-text-v2. 안 올리면 이미 색인된
스캔본에 적용되지 않는다 (파일이 안 바뀌었으니 해시가 같고 Unchanged 로
건너뛴다). 사용자가 --force-reingest 를 떠올려야만 고쳐지는 수정은 고쳐진 게
아니다. 스냅샷 둘이 따라 움직였고 바뀐 것이 파생 식별자뿐임을 확인했다 —
본문 텍스트·inlines·source_span·metadata 는 동일.

구현 중 발견: pdfium 은 동시 사용이 안전하지 않다. 테스트를 병렬로 돌리자
`double free or corruption` 으로 프로세스가 죽었고, `thread_safe` 기능만으로는
부족했다. ingest 는 PDF 를 하나씩 처리하니 오늘은 문제가 없지만 `Arc` 는
공유해도 된다고 광고하는 타입이라, `PageRenderer` 안에 뮤텍스를 두고
`RenderedPdf` 가 문서 수명 동안 잡게 했다 (필드 선언 순서가 load-bearing —
doc 이 guard 보다 먼저 드롭돼야 한다). 지금 비용 0, 병렬화되는 날 메모리
손상 대신 대기가 된다. `set_target_width` 만 주면 긴 스캔에서 pdfium 이 C++
length_error 로 프로세스를 죽여서(exceptions 비활성 빌드라 Err 로 못 받는다)
양변을 set_maximum_* 으로 묶었다. 바인딩도 run 당 1회여야 한다.

실측 (govdocs1-000157-ccitt.pdf, 22쪽 중 1쪽이 CCITT 스캔, gemma3:4b):

                  렌더러 없음                        렌더러 있음
  OCR        ⊘ 건너뜀 — 인코딩을 읽을 수 없다    ✓ 101 chars, 6489ms
  chunk                35                              36
  글자 수            35,994                          36,095
  요약           ocr-skipped 1                        (없음)

렌더링 자체는 여섯 필터 계열 전부 확인 — CCITT / JBIG2 / Flate / JPX /
혼합(DCT+CCITT+JBIG2+Flate) / DCT, 300dpi 페이지당 40~145 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 01:53:46 +09:00