feat(parse-pdf): #232 스캔 PDF 를 페이지 렌더링으로 OCR #238
Reference in New Issue
Block a user
Delete Branch "feat/pdf-page-render"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
extract_dctdecode_page_image는 페이지의 image XObject 중/Filter가 정확히 DCTDecode 인 것 하나만 받는다. 실제 스캔본에서 흔한 CCITTFaxDecode·JBIG2Decode·FlateDecode·JPXDecode,[FlateDecode, DCTDecode]같은 필터 체인, Internet Archive 계열의 "배경 +/ImageMask" 분리 구조가 전부 걸러진다.주목할 점은 텍스트 게이트가 정상 동작했다는 것이다.
needs_ocr판정을 통과했다는 건 kebab 이 "이 페이지는 스캔본이라 OCR 이 필요하다"고 올바르게 본 것이다. 판정은 맞았고 래스터를 못 꺼냈을 뿐인데, 그 결과가 조용한 내용 손실이었다 — 색인은 "성공"으로 끝나고, 검색이 안 되는 시점에야 알게 되며, 그때 원인이 PDF 인코더라는 걸 역추적할 방법이 없다.페이지를 렌더링한다 (
page_render::PageRenderer, pdfium). 지원할 필터도, 고를 XObject 도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다. 이슈가 지적한 부수 결함 1번(image XObject 선택이 비결정적)도 이 경로에서는 성립하지 않는다.설계:
docs/superpowers/specs/2026-04-27-kebab-final-form-design.md§4.6 /docs/ARCHITECTURE.md왜 "교체" 가 아니라 "렌더러 우선 + 폴백" 인가
이슈는 DCTDecode 고속 경로를 남기지 말라고 권했다. 배포 형태 때문에 그러지 못했다 — pdfium 은 공유 라이브러리로만 배포되고 정적 빌드가 없다. 링크하면 CLAUDE.md 가 규정한 "single binary (
kebab)" 가 깨진다. 사용자와 상의해 정한 결론이다:[ingest.pdf.ocr] render_library로 경로 지정, 비우면 로더 경로 탐색.kebab doctor의pdf_render가 지금 어느 쪽인지 보고.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. 안 올리면 이미 색인된 스캔본에 적용되지 않는다 — PDF 파일 자체는 안 바뀌었으니 해시가 같고try_skip_unchanged가 Unchanged 로 건너뛴다. 사용자가--force-reingest를 떠올려야만 고쳐지는 수정은 고쳐진 게 아니다.스냅샷 두 개가 따라 움직였고, 바뀐 것이 파생 식별자뿐임을 확인했다:
vector_pdf_canonical.jsondoc_id·block_id·parser_version· provenance noteingest_report.snapshot.jsonocr_skipped_pages키 추가구현 중 발견 — pdfium 은 동시 사용이 안전하지 않다
테스트를 병렬로 돌리자
double free or corruption으로 프로세스가 죽었다.pdfium-render의thread_safe기능만으로는 부족하다. 단일 스레드에서는 6개 테스트가 전부 통과한다.ingest 는 PDF 를 한 번에 하나씩 처리하므로 오늘은 문제가 없지만,
Arc<PageRenderer>는 "공유해도 된다"고 광고하는 타입이다.PageRenderer안에 뮤텍스를 두고RenderedPdf가 문서 수명 동안 잡게 했다 — 필드 선언 순서가 load-bearing 이다(doc이 guard 보다 먼저 드롭돼야 한다). 지금 비용은 0 이고, ingest 루프가 병렬화되는 날 메모리 손상 대신 대기가 된다.set_target_width만 주면 긴 스캔에서 pdfium 이 C++length_error로 프로세스를 죽인다 — exceptions 비활성 빌드라Err로 받을 수도 없다. 양변을set_maximum_*으로 묶었다. 바인딩도 run 당 1회여야 한다(동시 바인딩은 실패).검증
govdocs1-000157-ccitt.pdf(22쪽, 그중 1쪽이 CCITT 스캔), gemma3:4b vision:⊘ 건너뜀 — 이 페이지의 인코딩은 페이지 렌더러 없이 읽을 수 없다✓ 101 chars, 6489msocr-skipped 1--json에서도pdf_ocr_finished.failure_reason = "no_renderer"와ingest_report.ocr_skipped_pages = 1로 나온다.렌더링 자체는 여섯 필터 계열 전부 확인했다 — CCITT / JBIG2 / Flate / JPX / 혼합(DCT+CCITT+JBIG2+Flate) / DCT, 300dpi 에서 페이지당 40~145 ms. OCR 호출(초 단위)에 묻히는 비용이다.
이슈가 명시한 240쪽 한글 JBIG2 스캔(1925년 동화집)의 전체 색인은 별도로 돌리는 중이다 — 한국어 밀집 페이지는 쪽당 약 35초라 2시간 규모다. 결과는 나오는 대로 HOTFIXES 에 추가한다.
cargo test --workspace --no-fail-fast녹색 (188 ok / 0 FAILED)cargo clippy --workspace --all-targets -- -D warnings녹색시험 항목 (Test Plan)
crates/kebab-parse-pdf/tests/page_render.rs(신규,#[ignore]+KEBAB_TEST_PDFIUMopt-in — 능력이 진짜 선택적이라 없는 레인에서 실패로 보고하면 안 된다):a_ccitt_page_rasterizes_even_though_dctdecode_extraction_cannot— 이 변경의 핵심. 같은 fixture 로 DCTDecode 경로가None을 낸다는 전제부터 단언한 뒤 렌더링이 되는지 본다. 전제가 깨지면 fixture 가 더 이상 그 구멍을 시험하지 않는다는 뜻이다.a_flate_page_rasterizes_toothe_dctdecode_case_still_works_through_the_renderer— 렌더링이 기존 커버리지를 잃으면 구멍을 옮긴 것에 불과하다.the_long_edge_budget_is_respected_in_both_orientations— 실제로 디코드해 픽셀을 잰다.a_page_past_the_end_errors_rather_than_panicking— lopdf 와 pdfium 의 페이지 수가 어긋나도 skip 으로 떨어져야 한다.garbage_input_is_an_error_not_a_crash—open실패가 ingest 를 죽이면 안 된다.page_render.rs유닛 (라이브러리 불필요):dpi_scales_from_pdf_user_space/dpi_is_capped_by_the_engines_pixel_budget/degenerate_inputs_still_produce_a_renderable_size폴백 경로(= pdfium 없는 머신의 동작)는 기존
page_image.rs가 라이브러리 없이 그대로 덮는다.비범위
버전 영향
minor bump + 도그푸딩 트리거다.
parser_versioncascade(V00X 급 재처리 유발), 신규 config 키 두 개, 신규 wire 필드, 그리고 "할 수 있는 일"의 추가(이제 CCITTFax/JBIG2/Flate/JPX 스캔을 읽는다) — CLAUDE.md §Versioning cascade 의 minor 기준에 해당한다. bump 는 릴리스 커밋에서.Assisted-by: Claude Code
`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리뷰가 이 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?회귀 + 렌더 경로 테스트 7b3057fcd62회차 리뷰가 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_017c9JwQq8ZkGvYjpKXMiDhF3회차 리뷰가 `.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