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
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
리뷰가 이 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
`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