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
Owner

요약

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 doctorpdf_render 가 지금 어느 쪽인지 보고.
  • 바이너리 392.9 MB → 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-v1pdf-text-v2. 안 올리면 이미 색인된 스캔본에 적용되지 않는다 — PDF 파일 자체는 안 바뀌었으니 해시가 같고 try_skip_unchanged 가 Unchanged 로 건너뛴다. 사용자가 --force-reingest 를 떠올려야만 고쳐지는 수정은 고쳐진 게 아니다.

스냅샷 두 개가 따라 움직였고, 바뀐 것이 파생 식별자뿐임을 확인했다:

스냅샷 바뀐 것 그대로인 것
vector_pdf_canonical.json doc_id · block_id · parser_version · provenance note 본문 텍스트 · inlines · source_span · metadata
ingest_report.snapshot.json ocr_skipped_pages 키 추가 나머지 전부

구현 중 발견 — pdfium 은 동시 사용이 안전하지 않다

테스트를 병렬로 돌리자 double free or corruption 으로 프로세스가 죽었다. pdfium-renderthread_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:

렌더러 없음 렌더러 있음
OCR 결과 ⊘ 건너뜀 — 이 페이지의 인코딩은 페이지 렌더러 없이 읽을 수 없다 ✓ 101 chars, 6489ms
색인 chunk 35 36
색인 글자 수 35,994 36,095
요약 ocr-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_PDFIUM opt-in — 능력이 진짜 선택적이라 없는 레인에서 실패로 보고하면 안 된다):

  • a_ccitt_page_rasterizes_even_though_dctdecode_extraction_cannot이 변경의 핵심. 같은 fixture 로 DCTDecode 경로가 None 을 낸다는 전제부터 단언한 뒤 렌더링이 되는지 본다. 전제가 깨지면 fixture 가 더 이상 그 구멍을 시험하지 않는다는 뜻이다.
  • a_flate_page_rasterizes_too
  • the_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_crashopen 실패가 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 가 라이브러리 없이 그대로 덮는다.

비범위

  • DCTDecode 고속 경로 제거. 폴백이 곧 그 경로이고, 폴백을 두는 것이 배포 결정의 귀결이다. 렌더러가 있으면 그 경로는 타지 않는다.
  • 필터별 디코더 추가(이슈 안 B) · 외부 도구 shell out(안 C). 이슈의 평가에 동의한다.

버전 영향

minor bump + 도그푸딩 트리거다. parser_version cascade(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` 판정을 통과했다는 건 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` 가 지금 어느 쪽인지 보고. - 바이너리 392.9 MB → 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`**. 안 올리면 이미 색인된 스캔본에 적용되지 않는다 — PDF 파일 자체는 안 바뀌었으니 해시가 같고 `try_skip_unchanged` 가 Unchanged 로 건너뛴다. 사용자가 `--force-reingest` 를 떠올려야만 고쳐지는 수정은 고쳐진 게 아니다. 스냅샷 두 개가 따라 움직였고, **바뀐 것이 파생 식별자뿐임을 확인**했다: | 스냅샷 | 바뀐 것 | 그대로인 것 | |---|---|---| | `vector_pdf_canonical.json` | `doc_id` · `block_id` · `parser_version` · provenance note | **본문 텍스트 · inlines · source_span · metadata** | | `ingest_report.snapshot.json` | `ocr_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: | | 렌더러 없음 | 렌더러 있음 | |---|---|---| | OCR 결과 | `⊘ 건너뜀 — 이 페이지의 인코딩은 페이지 렌더러 없이 읽을 수 없다` | `✓ 101 chars, 6489ms` | | 색인 chunk | 35 | **36** | | 색인 글자 수 | 35,994 | **36,095** | | 요약 | `ocr-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_PDFIUM` opt-in — 능력이 진짜 선택적이라 없는 레인에서 실패로 보고하면 안 된다): - [x] `a_ccitt_page_rasterizes_even_though_dctdecode_extraction_cannot` — **이 변경의 핵심.** 같은 fixture 로 DCTDecode 경로가 `None` 을 낸다는 전제부터 단언한 뒤 렌더링이 되는지 본다. 전제가 깨지면 fixture 가 더 이상 그 구멍을 시험하지 않는다는 뜻이다. - [x] `a_flate_page_rasterizes_too` - [x] `the_dctdecode_case_still_works_through_the_renderer` — 렌더링이 기존 커버리지를 잃으면 구멍을 옮긴 것에 불과하다. - [x] `the_long_edge_budget_is_respected_in_both_orientations` — 실제로 디코드해 픽셀을 잰다. - [x] `a_page_past_the_end_errors_rather_than_panicking` — lopdf 와 pdfium 의 페이지 수가 어긋나도 skip 으로 떨어져야 한다. - [x] `garbage_input_is_an_error_not_a_crash` — `open` 실패가 ingest 를 죽이면 안 된다. `page_render.rs` 유닛 (라이브러리 불필요): - [x] `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` 가 라이브러리 없이 그대로 덮는다. ## 비범위 - DCTDecode 고속 경로 제거. 폴백이 곧 그 경로이고, 폴백을 두는 것이 배포 결정의 귀결이다. 렌더러가 있으면 그 경로는 타지 않는다. - 필터별 디코더 추가(이슈 안 B) · 외부 도구 shell out(안 C). 이슈의 평가에 동의한다. ## 버전 영향 **minor bump + 도그푸딩 트리거**다. `parser_version` cascade(V00X 급 재처리 유발), 신규 config 키 두 개, 신규 wire 필드, 그리고 "할 수 있는 일"의 추가(이제 CCITTFax/JBIG2/Flate/JPX 스캔을 읽는다) — CLAUDE.md §Versioning cascade 의 minor 기준에 해당한다. bump 는 릴리스 커밋에서. Assisted-by: Claude Code
altair823 added 1 commit 2026-08-16 16:54:47 +00:00
`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
altair823 added 1 commit 2026-08-16 17:45:53 +00:00
리뷰가 이 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
altair823 added 1 commit 2026-08-16 18:12:40 +00:00
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
altair823 added 1 commit 2026-08-16 18:36:07 +00:00
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
altair823 merged commit 500ed96358 into main 2026-08-16 18:45:02 +00:00
altair823 deleted branch feat/pdf-page-render 2026-08-16 18:45:04 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#238