Files
kebab/crates/kebab-app/tests/pdf_ocr_apply.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

403 lines
14 KiB
Rust

//! Integration tests for pdf_ocr_apply helper. spec §5.5 MockOcrEngine pattern.
mod common;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use common::mock_ocr::MockOcrEngine;
use kebab_app::pdf_ocr_apply::{PdfOcrOpts, apply_ocr_to_pdf_pages};
use kebab_core::{
AssetStorage, Block, CanonicalDocument, Checksum, ExtractConfig, ExtractContext, Extractor,
Inline, Lang, MediaType, RawAsset, SourceSpan, SourceUri, WorkspacePath, id_for_asset,
};
use kebab_parse_pdf::PdfTextExtractor;
use time::OffsetDateTime;
// ── Fixture helpers ───────────────────────────────────────────────────────
fn f1_pdf_bytes() -> Vec<u8> {
std::fs::read("../kebab-parse-pdf/tests/fixtures/scanned_page1.pdf")
.expect("F1 fixture missing")
}
fn make_raw_asset(path: &str, media_type: MediaType, byte_len: u64) -> RawAsset {
let fake_hash = "0".repeat(64);
let asset_id = id_for_asset(&fake_hash);
RawAsset {
asset_id,
source_uri: SourceUri::File(PathBuf::from(path)),
workspace_path: WorkspacePath::new(path.to_string()).unwrap(),
media_type,
byte_len,
checksum: Checksum(fake_hash.clone()),
discovered_at: OffsetDateTime::UNIX_EPOCH,
stored: AssetStorage::Copied {
path: PathBuf::from(path),
},
}
}
/// Build a CanonicalDocument from raw PDF bytes using PdfTextExtractor.
/// F1 (scanned) returns an empty-text Block::Paragraph per page.
fn extract_canonical_from_bytes(bytes: &[u8]) -> CanonicalDocument {
let asset = make_raw_asset("test.pdf", MediaType::Pdf, bytes.len() as u64);
let workspace_root = Path::new("/");
let config = ExtractConfig::default();
let ctx = ExtractContext {
asset: &asset,
workspace_root,
config: &config,
source_id: None,
source_trust: None,
};
PdfTextExtractor::new().extract(&ctx, bytes).unwrap()
}
/// F1 bytes → canonical with 1 empty Block::Paragraph for page 1.
fn canonical_with_empty_block() -> CanonicalDocument {
extract_canonical_from_bytes(&f1_pdf_bytes())
}
/// F1-based canonical with block text replaced by `text` (high valid_ratio, chars≥20).
fn canonical_with_filled_block(text: &str) -> CanonicalDocument {
let mut canonical = extract_canonical_from_bytes(&f1_pdf_bytes());
if let Some(Block::Paragraph(tb)) = canonical.blocks.first_mut() {
let char_count = text.chars().count() as u32;
tb.text = text.to_string();
tb.inlines = vec![Inline::Text {
text: text.to_string(),
}];
if let SourceSpan::Page { char_end, .. } = &mut tb.common.source_span {
*char_end = Some(char_count);
}
}
canonical
}
/// F1-based canonical with block text replaced by PUA codepoints (low valid_ratio).
fn canonical_with_mojibake_block() -> CanonicalDocument {
let mut canonical = extract_canonical_from_bytes(&f1_pdf_bytes());
if let Some(Block::Paragraph(tb)) = canonical.blocks.first_mut() {
let pua = "\u{E000}".repeat(25); // 25 PUA codepoints → valid_ratio ≈ 0
let char_count = pua.chars().count() as u32;
tb.text = pua.clone();
tb.inlines = vec![Inline::Text { text: pua }];
if let SourceSpan::Page { char_end, .. } = &mut tb.common.source_span {
*char_end = Some(char_count);
}
}
canonical
}
fn default_opts(enabled: bool) -> PdfOcrOpts {
PdfOcrOpts {
enabled,
always_on: false,
valid_ratio_threshold: 0.5,
min_char_count: 20,
lang_hint: None,
cancel: None,
ocr_cache: None,
ocr_version_key: String::new(),
// No renderer in these unit tests: they pin the fallback path's
// behavior, which is what a machine without pdfium gets.
renderer: None,
render_dpi: 300,
max_pixels: 4096,
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
// Test 1: F1 + enabled=true → in-place mutate
#[test]
fn f1_input_with_ocr_enabled_replaces_empty_block() {
let bytes = f1_pdf_bytes();
let mut canonical = canonical_with_empty_block();
let engine = MockOcrEngine::single("MOCK_OCR_TEXT", false);
let opts = PdfOcrOpts {
enabled: true,
always_on: false,
valid_ratio_threshold: 0.5,
min_char_count: 20,
lang_hint: Some(Lang("kor".into())),
cancel: None,
ocr_cache: None,
ocr_version_key: String::new(),
// No renderer in these unit tests: they pin the fallback path's
// behavior, which is what a machine without pdfium gets.
renderer: None,
render_dpi: 300,
max_pixels: 4096,
};
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 1);
let first_para = canonical.blocks.iter().find_map(|b| match b {
Block::Paragraph(tb) => Some(tb),
_ => None,
});
assert!(first_para.is_some());
assert_eq!(first_para.unwrap().text, "MOCK_OCR_TEXT");
}
// Test 2: F3 vector (mock filled canonical) + enabled=true → OCR skip (needs_ocr=false)
#[test]
fn f3_input_with_ocr_enabled_keeps_text_detect_blocks() {
let bytes = f1_pdf_bytes(); // reuse F1 bytes; decision is based on canonical text
let text = "충분한 한국어 텍스트 컨텐츠입니다. This has more than twenty characters.";
let mut canonical = canonical_with_filled_block(text);
let engine = MockOcrEngine::single("SHOULD_NOT_BE_CALLED", false);
let opts = default_opts(true);
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 0, "vector PDF 의 OCR 호출 0");
let first_para = canonical.blocks.iter().find_map(|b| match b {
Block::Paragraph(tb) => Some(tb),
_ => None,
});
if let Some(tb) = first_para {
assert!(tb.text.starts_with("충분한"), "원본 text 보존");
}
}
// Test 3: F1 + enabled=false → no-op
#[test]
fn f1_input_with_ocr_disabled_keeps_empty_block() {
let bytes = f1_pdf_bytes();
let mut canonical = canonical_with_empty_block();
let engine = MockOcrEngine::single("IGNORED", false);
let opts = default_opts(false);
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 0);
assert_eq!(summary.ms_total, 0);
}
// Test 4: mojibake canonical (PUA chars) + enabled=true → in-place mutate
#[test]
fn f4_input_with_ocr_enabled_replaces_mojibake_block() {
let bytes = f1_pdf_bytes(); // F1 bytes carry DCTDecode image
let mut canonical = canonical_with_mojibake_block();
let engine = MockOcrEngine::single("OCR_MOJIBAKE_REPLACEMENT", false);
let opts = PdfOcrOpts {
enabled: true,
always_on: false,
valid_ratio_threshold: 0.5,
min_char_count: 20,
lang_hint: None,
cancel: None,
ocr_cache: None,
ocr_version_key: String::new(),
// No renderer in these unit tests: they pin the fallback path's
// behavior, which is what a machine without pdfium gets.
renderer: None,
render_dpi: 300,
max_pixels: 4096,
};
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 1, "mojibake page 의 OCR 호출");
let first_para = canonical.blocks.iter().find_map(|b| match b {
Block::Paragraph(tb) => Some(tb),
_ => None,
});
if let Some(tb) = first_para {
assert_eq!(tb.text, "OCR_MOJIBAKE_REPLACEMENT");
}
}
// Test 5: filled canonical + always_on=true → dual-block (+1 OCR block)
#[test]
fn f3_input_with_always_on_pushes_dual_blocks() {
let bytes = f1_pdf_bytes();
let text = "vector PDF 충분한 텍스트 컨텐츠입니다. This has enough characters for valid ratio.";
let mut canonical = canonical_with_filled_block(text);
let original_block_count = canonical.blocks.len();
let engine = MockOcrEngine::single("OCR_DUAL", false);
let opts = PdfOcrOpts {
enabled: true,
always_on: true,
valid_ratio_threshold: 0.5,
min_char_count: 20,
lang_hint: None,
cancel: None,
ocr_cache: None,
ocr_version_key: String::new(),
// No renderer in these unit tests: they pin the fallback path's
// behavior, which is what a machine without pdfium gets.
renderer: None,
render_dpi: 300,
max_pixels: 4096,
};
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 1);
assert_eq!(
canonical.blocks.len(),
original_block_count + 1,
"always_on 시 새 Block::Paragraph push"
);
let texts: Vec<&str> = canonical
.blocks
.iter()
.filter_map(|b| match b {
Block::Paragraph(tb) => Some(tb.text.as_str()),
_ => None,
})
.collect();
assert!(texts.contains(&"OCR_DUAL"), "OCR block 포함");
assert!(
texts.iter().any(|t| t.starts_with("vector")),
"원본 text-detect block 보존"
);
}
// Test 6: F6 FlateDecode → extract_dctdecode_page_image=None → skip + warning
#[test]
fn f6_flatedecode_skipped_with_warning() {
let bytes = std::fs::read("../kebab-parse-pdf/tests/fixtures/flate_raw.pdf")
.expect("F6 fixture missing");
let mut canonical = canonical_with_empty_block(); // page-1 block from F1
let engine = MockOcrEngine::single("SHOULD_NOT_BE_CALLED", false);
let opts = default_opts(true);
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(
summary.pages_ocrd, 0,
"FlateDecode page 는 skip (DCTDecode-only v1 invariant)"
);
let warning_count = canonical
.provenance
.events
.iter()
.filter(|e| e.kind == kebab_core::ProvenanceKind::Warning)
.count();
assert!(warning_count >= 1, "FlateDecode skip 시 Warning event 발행");
}
// Test 7: F7 CCITTFax → skip + warning (verifier M-4 split)
#[test]
fn f7_ccittfax_skipped_with_warning() {
let bytes =
std::fs::read("../kebab-parse-pdf/tests/fixtures/ccitt.pdf").expect("F7 fixture missing");
let mut canonical = canonical_with_empty_block(); // page-1 block from F1
let engine = MockOcrEngine::single("SHOULD_NOT_BE_CALLED", false);
let opts = default_opts(true);
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 0, "CCITTFax page 는 skip");
let warning_count = canonical
.provenance
.events
.iter()
.filter(|e| e.kind == kebab_core::ProvenanceKind::Warning)
.count();
assert!(warning_count >= 1, "CCITTFax skip 시 Warning event 발행");
}
// Test 8: OCR engine failure → warning event + skip
#[test]
fn ocr_engine_failure_surfaces_as_warning() {
let bytes = f1_pdf_bytes();
let mut canonical = canonical_with_empty_block();
let engine = MockOcrEngine::single("", true);
let opts = default_opts(true);
let summary = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
assert_eq!(summary.pages_ocrd, 0, "OCR failure 시 pages_ocrd=0");
let warning_with_failure = canonical.provenance.events.iter().any(|e| {
e.kind == kebab_core::ProvenanceKind::Warning
&& e.note.as_deref().unwrap_or("").contains("mock failure")
});
assert!(
warning_with_failure,
"OCR failure 의 error message 가 warning event 의 note 안"
);
}
// Test 9: dual-block ordinals are deterministic and unique
#[test]
fn dual_block_ordinals_are_deterministic_and_unique() {
let bytes = f1_pdf_bytes(); // 1-page PDF → page_count=1
let text = "vector 충분한 텍스트. This text has more than twenty characters total.";
let mut canonical = canonical_with_filled_block(text);
let engine = MockOcrEngine::single("DUAL", false);
let opts = PdfOcrOpts {
enabled: true,
always_on: true,
valid_ratio_threshold: 0.5,
min_char_count: 20,
lang_hint: None,
cancel: None,
ocr_cache: None,
ocr_version_key: String::new(),
// No renderer in these unit tests: they pin the fallback path's
// behavior, which is what a machine without pdfium gets.
renderer: None,
render_dpi: 300,
max_pixels: 4096,
};
apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {}).unwrap();
// page_count=1 → text-detect ordinal=0, ocr ordinal=1 (page_num-1 + page_count = 0+1=1)
let para_count = canonical
.blocks
.iter()
.filter(|b| matches!(b, Block::Paragraph(_)))
.count();
assert_eq!(para_count, 2, "dual-block: text-detect + OCR");
let all_page_1 = canonical
.blocks
.iter()
.filter_map(|b| match b {
Block::Paragraph(tb) => Some(&tb.common.source_span),
_ => None,
})
.all(|s| matches!(s, SourceSpan::Page { page: 1, .. }));
assert!(all_page_1, "두 block 모두 page=1");
}
// Test 10: cancel handle aborts mid-PDF
#[test]
fn cancel_handle_aborts_mid_pdf() {
let bytes = f1_pdf_bytes();
let mut canonical = canonical_with_empty_block();
let cancel = Arc::new(AtomicBool::new(true)); // pre-cancel
let engine = MockOcrEngine::single("IGNORED", false);
let opts = PdfOcrOpts {
enabled: true,
always_on: false,
valid_ratio_threshold: 0.5,
min_char_count: 20,
lang_hint: None,
cancel: Some(cancel.clone()),
ocr_cache: None,
ocr_version_key: String::new(),
// No renderer in these unit tests: they pin the fallback path's
// behavior, which is what a machine without pdfium gets.
renderer: None,
render_dpi: 300,
max_pixels: 4096,
};
let result = apply_ocr_to_pdf_pages(&mut canonical, &engine, &bytes, &opts, |_| {});
let err = result.expect_err("cancel=true 시 error 반환");
assert!(
format!("{err}").contains("cancelled mid-PDF"),
"error message 가 'cancelled mid-PDF' 포함: {err}"
);
}