Files
kebab/crates/kebab-parse-pdf/tests/page_render.rs
altair823 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

139 lines
5.3 KiB
Rust

//! Issue #232: every scanned page must produce a raster, whatever its
//! images are encoded with.
//!
//! These tests need a `libpdfium` and are `#[ignore]`d for the same
//! reason `kebab-store-vector`'s AVX suite is: the capability is
//! genuinely optional, and a lane without it must not report a failure
//! it cannot act on. Opt in with
//!
//! ```text
//! KEBAB_TEST_PDFIUM=/path/to/libpdfium.so \
//! cargo test -p kebab-parse-pdf --test page_render -- --ignored
//! ```
//!
//! The fallback path — what a machine *without* pdfium does — is covered
//! by `page_image.rs` and needs no library, so the behaviour that ships
//! to a bare install is tested unconditionally.
use kebab_parse_pdf::PageRenderer;
/// Bind to the library named by `KEBAB_TEST_PDFIUM`, or the system one.
///
/// Bound once for the whole binary, which is both what production does
/// (one renderer per ingest run) and what pdfium requires — binding it
/// from several threads at once fails, and cargo runs tests in parallel.
///
/// Panics rather than skipping: an `--ignored` run that silently passes
/// without exercising the renderer is worse than no test.
fn renderer() -> &'static PageRenderer {
static SHARED: std::sync::OnceLock<PageRenderer> = std::sync::OnceLock::new();
SHARED.get_or_init(|| {
let explicit = std::env::var("KEBAB_TEST_PDFIUM").ok();
let path = explicit.as_deref().map(std::path::Path::new);
PageRenderer::bind(path).expect(
"these tests require libpdfium; point KEBAB_TEST_PDFIUM at one or \
install it where the loader finds it",
)
})
}
/// PNG signature, so a test can tell "we got an image" from "we got
/// bytes".
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
/// The whole point of the change: a page whose image is **not** a single
/// DCTDecode JPEG still rasterizes. `ccitt.pdf` is the fixture the
/// DCTDecode path returns `None` for (see `page_image.rs`), which is
/// exactly the silent content loss issue #232 reported.
#[test]
#[ignore = "requires libpdfium"]
fn a_ccitt_page_rasterizes_even_though_dctdecode_extraction_cannot() {
let bytes = include_bytes!("fixtures/ccitt.pdf");
// Precondition: this fixture is one the old path gives up on.
let doc = lopdf::Document::load_mem(bytes).unwrap();
assert!(
kebab_parse_pdf::extract_dctdecode_page_image(&doc, 1)
.unwrap()
.is_none(),
"fixture no longer exercises the gap — pick one the DCTDecode path drops"
);
let r = renderer();
let pdf = r.open(bytes, None).expect("open ccitt.pdf");
let png = pdf.render_page_png(1, 1_000).expect("render page 1");
assert!(png.starts_with(PNG_MAGIC), "rendered bytes are not a PNG");
assert!(
png.len() > 1_000,
"suspiciously small render: {} B",
png.len()
);
}
/// FlateDecode raw pixels — the other encoding `page_image.rs` pins as
/// unreadable.
#[test]
#[ignore = "requires libpdfium"]
fn a_flate_page_rasterizes_too() {
let bytes = include_bytes!("fixtures/flate_raw.pdf");
let r = renderer();
let pdf = r.open(bytes, None).expect("open flate_raw.pdf");
let png = pdf.render_page_png(1, 1_000).expect("render page 1");
assert!(png.starts_with(PNG_MAGIC));
}
/// The DCTDecode case must not regress: rendering has to cover what the
/// passthrough already covered, or the change trades one gap for another.
#[test]
#[ignore = "requires libpdfium"]
fn the_dctdecode_case_still_works_through_the_renderer() {
let bytes = include_bytes!("fixtures/scanned_page1.pdf");
let r = renderer();
let pdf = r.open(bytes, None).expect("open scanned_page1.pdf");
let png = pdf.render_page_png(1, 1_000).expect("render page 1");
assert!(png.starts_with(PNG_MAGIC));
}
/// The long edge is a budget, not a suggestion — an OCR engine that
/// refuses oversized input would otherwise turn a render into a failure.
#[test]
#[ignore = "requires libpdfium"]
fn the_long_edge_budget_is_respected_in_both_orientations() {
let r = renderer();
for fixture in [
&include_bytes!("fixtures/scanned_page1.pdf")[..],
&include_bytes!("fixtures/ccitt.pdf")[..],
] {
let pdf = r.open(fixture, None).expect("open");
let png = pdf.render_page_png(1, 600).expect("render");
let img = image::load_from_memory(&png).expect("decode render");
assert!(
img.width().max(img.height()) <= 600,
"long edge {} exceeds the 600px budget",
img.width().max(img.height())
);
}
}
/// A page number past the end is a caller error, not a panic. The OCR
/// loop walks pages from lopdf's count, and the two libraries disagreeing
/// about page count must degrade to a skip.
#[test]
#[ignore = "requires libpdfium"]
fn a_page_past_the_end_errors_rather_than_panicking() {
let bytes = include_bytes!("fixtures/scanned_page1.pdf");
let r = renderer();
let pdf = r.open(bytes, None).expect("open");
assert!(pdf.render_page_png(9_999, 600).is_err());
}
/// Bytes that are not a PDF must come back as an error from `open`, so
/// the caller falls through to the DCTDecode path instead of aborting
/// the ingest.
#[test]
#[ignore = "requires libpdfium"]
fn garbage_input_is_an_error_not_a_crash() {
let r = renderer();
assert!(r.open(b"this is not a pdf at all", None).is_err());
}