chore: PR #238 회차 2 리뷰 반영 — 폴백의 ? 회귀 + 렌더 경로 테스트
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
This commit is contained in:
@@ -172,7 +172,7 @@ nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedn
|
||||
- **`[ingest.image.ocr]`** — 이미지 OCR. on/off 토글(`enabled`, default off / opt-in)은 미디어별이며, 엔진 설정은 `[ingest.ocr]` 에서 상속하되 이 블록에서 override 할 수 있다. `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능. engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
|
||||
- **`[ingest.pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). on/off 토글(`enabled`/`always_on`)과 PDF 고유 키(`valid_ratio_threshold`/`min_char_count`/`lang_hint`)는 미디어별이고, 엔진 설정은 `[ingest.ocr]` 에서 상속하되 이 블록에서 override 한다(PDF 기본 모델은 `qwen2.5vl:3b`, 이미지의 `gemma4:e4b` 와 다름 — 미디어별 기본값 보존). 활성화 후 옛 색인분은 `kebab ingest --force-reingest` 로 재처리.
|
||||
|
||||
**스캔본을 제대로 읽으려면 페이지 렌더러가 필요하다.** `render_library` 에 `libpdfium` 경로를 적거나 로더가 찾는 곳에 두면, 페이지 이미지가 어떤 인코딩이든(CCITTFax·JBIG2·Flate·JPX, 배경+마스크 분리 구조 포함) OCR 된다. 없으면 **단일 DCTDecode(JPEG) 이미지 페이지만** OCR 되고 나머지는 본문 없이 색인되며, 그 건수가 ingest 요약의 `ocr-skipped` 와 `--json` 의 `ocr_skipped_pages` 에 찍힌다. `kebab doctor` 의 `pdf_render` 가 지금 어느 쪽인지 알려준다. `render_dpi` 는 렌더 해상도 **요청**(기본 300)이고 실제로는 `max_pixels` 가 이긴다 — PDF 기본값 `max_pixels = 2048` 이면 A4 는 175 DPI 언저리에서 잘린다. 300 을 실제로 쓰려면 `max_pixels` 를 3500 이상으로 올려야 하고, 그만큼 큰 이미지를 OCR 엔진이 받는다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다.
|
||||
**스캔본을 제대로 읽으려면 페이지 렌더러가 필요하다.** `render_library` 에 `libpdfium` 경로를 적거나 로더가 찾는 곳에 두면, 페이지 이미지가 어떤 인코딩이든(CCITTFax·JBIG2·Flate·JPX, 배경+마스크 분리 구조 포함) OCR 된다. 없으면 **단일 DCTDecode(JPEG) 이미지 페이지만** OCR 되고 나머지는 본문 없이 색인되며, 그 건수가 ingest 요약의 `ocr-skipped` 와 `--json` 의 `ocr_skipped_pages` 에 찍힌다. `kebab doctor` 의 `pdf_render` 가 지금 어느 쪽인지 알려준다. `render_dpi` 는 렌더 해상도 **요청**(기본 300)이고 실제로는 `max_pixels` 가 이긴다 — PDF 기본값 `max_pixels = 2048` 이면 A4 는 175 DPI 언저리에서 잘린다. 300 을 실제로 쓰려면 `max_pixels` 를 3500 이상으로 올려야 하고, 그만큼 큰 이미지를 OCR 엔진이 받는다. 다만 ollama-vision 엔진이 `max_pixels` 를 256~4096 으로 다시 조인다 — 그보다 크게 적어도 4096 이 상한이다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다.
|
||||
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI honor).
|
||||
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
|
||||
- **`KEBAB_*` env** — 런타임 override용 ~22개 키만 노출. 엔드포인트(`KEBAB_MODELS_LLM_ENDPOINT`, `KEBAB_MODELS_EMBEDDING_ENDPOINT`, `KEBAB_OCR_ENDPOINT`), 모델명/프로바이더(`KEBAB_MODELS_LLM_MODEL`, `KEBAB_MODELS_EMBEDDING_MODEL`, `KEBAB_MODELS_EMBEDDING_PROVIDER`, `KEBAB_MODELS_LLM_PROVIDER`, `KEBAB_MODELS_NLI_MODEL`), 경로(`KEBAB_WORKSPACE_ROOT`, `KEBAB_STORAGE_DATA_DIR`), 병렬도(`KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS`, `KEBAB_INDEXING_MAX_PARALLEL_EMBEDDINGS`), 청킹(`KEBAB_CHUNKING_TARGET_TOKENS`, `KEBAB_CHUNKING_OVERLAP_TOKENS`), OCR 토글/엔진/언어(`KEBAB_IMAGE_OCR_ENABLED`, `KEBAB_PDF_OCR_ENABLED`, `KEBAB_OCR_ENGINE`, `KEBAB_OCR_MODEL`, `KEBAB_OCR_LANGUAGES`), 기타(`KEBAB_IMAGE_CAPTION_ENABLED`, `KEBAB_SEARCH_DEFAULT_K`, `KEBAB_RAG_PROMPT_TEMPLATE_VERSION`). 나머지 세부 튜닝 키(score_gate, rrf_k, temperature 등)는 `config.toml` 전용. 특수: `KEBAB_READONLY=1`(write-path 비활성), `KEBAB_PROGRESS=plain`(non-TTY 진행 출력), `KEBAB_EVAL_GOLDEN`(eval golden set 경로).
|
||||
|
||||
@@ -323,7 +323,14 @@ where
|
||||
// cost content that the DCTDecode path could still read.
|
||||
// Configuring a renderer should never make a page worse
|
||||
// off than not having one.
|
||||
Err(e) => match dct(&pdf_doc)? {
|
||||
//
|
||||
// `.ok()` rather than `?`: a page that fails to render is
|
||||
// often a page whose lopdf dictionary is also malformed,
|
||||
// and propagating that error here would turn one bad page
|
||||
// into an aborted document — the opposite of this loop's
|
||||
// per-page `continue`-on-error discipline, and the same
|
||||
// silent-total-loss shape this whole change is about.
|
||||
Err(e) => match dct(&pdf_doc).ok().flatten() {
|
||||
Some(b) => Ok(b),
|
||||
None => Err(RasterFailure::Render(e.to_string())),
|
||||
},
|
||||
|
||||
@@ -107,8 +107,15 @@ pub fn ingest_md(env: &TestEnv, relative_path: &str, content: &str) {
|
||||
std::fs::create_dir_all(parent).expect("create parent dirs");
|
||||
}
|
||||
std::fs::write(&path, content).expect("write workspace file");
|
||||
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() })
|
||||
.expect("ingest_with_config");
|
||||
kebab_app::ingest_with_config(
|
||||
env.config.clone(),
|
||||
env.scope(),
|
||||
kebab_app::IngestOpts {
|
||||
summary_only: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("ingest_with_config");
|
||||
}
|
||||
|
||||
/// Test helper: build a `SearchQuery` for lexical mode at k=10. Used
|
||||
|
||||
@@ -400,3 +400,130 @@ fn cancel_handle_aborts_mid_pdf() {
|
||||
"error message 가 'cancelled mid-PDF' 포함: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Renderer path (issue #232) ────────────────────────────────────────────
|
||||
//
|
||||
// The tests above pin what a machine *without* pdfium does. These pin the
|
||||
// other half. They are `#[ignore]`d behind `KEBAB_TEST_PDFIUM` for the
|
||||
// same reason the renderer itself is optional — a lane without the
|
||||
// library must not report a failure it cannot act on:
|
||||
//
|
||||
// KEBAB_TEST_PDFIUM=/path/to/libpdfium.so \
|
||||
// cargo test -p kebab-app --test pdf_ocr_apply -- --ignored
|
||||
//
|
||||
// The OCR engine is mocked, so these exercise rasterization and the
|
||||
// branch that chooses it without touching a network or a model.
|
||||
|
||||
/// Bound once for the binary: pdfium's initialization is not reentrant
|
||||
/// and cargo runs tests in parallel.
|
||||
fn test_renderer() -> Arc<kebab_parse_pdf::PageRenderer> {
|
||||
static SHARED: std::sync::OnceLock<Arc<kebab_parse_pdf::PageRenderer>> =
|
||||
std::sync::OnceLock::new();
|
||||
SHARED
|
||||
.get_or_init(|| {
|
||||
let explicit = std::env::var("KEBAB_TEST_PDFIUM").ok();
|
||||
let path = explicit.as_deref().map(Path::new);
|
||||
Arc::new(
|
||||
kebab_parse_pdf::PageRenderer::bind(path)
|
||||
.expect("these tests require libpdfium; point KEBAB_TEST_PDFIUM at one"),
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn opts_with_renderer() -> PdfOcrOpts {
|
||||
PdfOcrOpts {
|
||||
renderer: Some(test_renderer()),
|
||||
..default_opts(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole point of issue #232. `f7_ccittfax_skipped_with_warning`
|
||||
/// above pins that this exact fixture is skipped with no renderer; with
|
||||
/// one, the same bytes must reach the OCR engine instead.
|
||||
#[test]
|
||||
#[ignore = "requires libpdfium"]
|
||||
fn a_ccitt_page_reaches_the_ocr_engine_once_a_renderer_is_configured() {
|
||||
let bytes =
|
||||
std::fs::read("../kebab-parse-pdf/tests/fixtures/ccitt.pdf").expect("F7 fixture missing");
|
||||
let mut canonical = canonical_with_empty_block();
|
||||
let engine = MockOcrEngine::single("RASTERIZED AND READ", false);
|
||||
|
||||
let summary = apply_ocr_to_pdf_pages(
|
||||
&mut canonical,
|
||||
&engine,
|
||||
&bytes,
|
||||
&opts_with_renderer(),
|
||||
|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
summary.pages_ocrd, 1,
|
||||
"the CCITT page must be OCR'd, not skipped: {summary:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
summary.pages_skipped, 0,
|
||||
"and must not be counted as a page with no raster"
|
||||
);
|
||||
}
|
||||
|
||||
/// A renderer must not cost the DCTDecode path its coverage. Same
|
||||
/// fixture as `f1_enabled_true_mutates_block_in_place`, with a renderer
|
||||
/// added — the outcome has to be the same.
|
||||
#[test]
|
||||
#[ignore = "requires libpdfium"]
|
||||
fn a_dctdecode_page_still_works_with_a_renderer_configured() {
|
||||
let bytes = f1_pdf_bytes();
|
||||
let mut canonical = canonical_with_empty_block();
|
||||
let engine = MockOcrEngine::single("STILL READ", false);
|
||||
|
||||
let summary = apply_ocr_to_pdf_pages(
|
||||
&mut canonical,
|
||||
&engine,
|
||||
&bytes,
|
||||
&opts_with_renderer(),
|
||||
|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summary.pages_ocrd, 1);
|
||||
assert_eq!(summary.pages_skipped, 0);
|
||||
}
|
||||
|
||||
/// A PDF that lopdf parses but pdfium refuses must degrade to the
|
||||
/// DCTDecode path rather than reporting `no_renderer` — telling a user
|
||||
/// who already configured a renderer to configure one is the wrong
|
||||
/// instruction, and giving up on a page the old path could read would
|
||||
/// make installing pdfium a downgrade.
|
||||
///
|
||||
/// Constructed rather than fixtured: the case is a disagreement between
|
||||
/// two parsers, which is easier to state than to find in the wild.
|
||||
#[test]
|
||||
#[ignore = "requires libpdfium"]
|
||||
fn a_pdf_the_renderer_cannot_open_falls_back_instead_of_blaming_config() {
|
||||
// Truncated after the header: lopdf's lenient path still yields a
|
||||
// document object, pdfium refuses it outright.
|
||||
let bytes = b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n".to_vec();
|
||||
let mut canonical = canonical_with_empty_block();
|
||||
let engine = MockOcrEngine::single("SHOULD_NOT_BE_CALLED", false);
|
||||
|
||||
// Either lopdf also rejects it (then this test has nothing to say and
|
||||
// the error surfaces) or the run completes with the page skipped —
|
||||
// what must never happen is a panic or a silent success.
|
||||
let result = apply_ocr_to_pdf_pages(
|
||||
&mut canonical,
|
||||
&engine,
|
||||
&bytes,
|
||||
&opts_with_renderer(),
|
||||
|_| {},
|
||||
);
|
||||
// `Err` means lopdf rejected it first, which is the pre-existing path
|
||||
// and not this test's subject.
|
||||
if let Ok(summary) = result {
|
||||
assert_eq!(
|
||||
summary.pages_ocrd, 0,
|
||||
"nothing can be read from a PDF neither parser accepts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ mod page_text;
|
||||
mod text_quality;
|
||||
|
||||
pub use page_image::extract_dctdecode_page_image;
|
||||
pub use page_render::{PageRenderer, RenderedPdf, long_edge_for_dpi};
|
||||
pub use page_render::{PageRenderer, RenderedPdf};
|
||||
pub use text_quality::compute_valid_char_ratio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
@@ -185,7 +185,7 @@ impl RenderedPdf<'_> {
|
||||
/// `dpi / 72`. Clamped to `max_px` because the OCR engine's own pixel
|
||||
/// budget is the real ceiling — raising `render_dpi` past what the engine
|
||||
/// accepts would only cost time.
|
||||
pub fn long_edge_for_dpi(page_long_edge_pt: f32, dpi: u32, max_px: u32) -> u32 {
|
||||
fn long_edge_for_dpi(page_long_edge_pt: f32, dpi: u32, max_px: u32) -> u32 {
|
||||
let scaled = (page_long_edge_pt * dpi as f32 / 72.0).round();
|
||||
let scaled = if scaled.is_finite() && scaled >= 1.0 {
|
||||
scaled as u32
|
||||
|
||||
@@ -113,7 +113,7 @@ ingest 는 PDF 를 한 번에 하나씩 처리하므로 오늘은 문제가 없
|
||||
| namu-bihaengdae-seutoli | 8 | **12.55%** |
|
||||
| namu-gu-anoli-en | 6 | **15.08%** |
|
||||
|
||||
8 페이지 전부 OCR 성공, 건너뜀 0. 수정 전에는 세 문서 모두 본문 0 자였으므로 비교 대상 CER 은 100% 다.
|
||||
22 페이지 전부 OCR 성공, 건너뜀 0. **이 PR 이전에는** 세 문서 모두 본문 0 자였으므로(CCITT 는 DCTDecode 경로가 못 읽는다) 비교 대상 CER 은 100% 다.
|
||||
|
||||
첫 문단 대조 (읽히는 수준인지 확인용):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user