diff --git a/README.md b/README.md index 1b09202..1473604 100644 --- a/README.md +++ b/README.md @@ -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 엔진이 받는다. 다만 ollama-vision 엔진이 `max_pixels` 를 256~4096 으로 다시 조인다 — 그보다 크게 적어도 4096 이 상한이다. 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 엔진이 받는다. 다만 OCR 엔진(ollama-vision·paddle-onnx 둘 다)이 `max_pixels` 를 256~4096 으로 다시 조인다 — 그보다 크게 적어도 4096 이 상한이다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다. - **`--config `** — 임시 워크스페이스 / 격리 테스트용 (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 경로). diff --git a/crates/kebab-app/src/pdf_ocr_apply.rs b/crates/kebab-app/src/pdf_ocr_apply.rs index bb4e1de..afaebeb 100644 --- a/crates/kebab-app/src/pdf_ocr_apply.rs +++ b/crates/kebab-app/src/pdf_ocr_apply.rs @@ -313,7 +313,19 @@ where // one DCTDecode image, which is why rendering exists — but it is // still the right thing to try when rendering is unavailable or // fails for this particular page. - let dct = |doc: &LopdfDocument| extract_dctdecode_page_image(doc, page_num); + let dct = |doc: &LopdfDocument| { + extract_dctdecode_page_image(doc, page_num).inspect_err(|e| { + // Swallowed by the callers below on purpose — a page-local + // parse failure must not abort the document. Logged so the + // reason is recoverable, matching the OCR cache GET below. + tracing::debug!( + target: "kebab-app::pdf_ocr", + page = page_num, + error = %e, + "DCTDecode extraction failed; treating as no raster for this page" + ); + }) + }; let rasterized = match (rendered.as_ref(), renderer_configured) { (Some(doc), _) => match doc.render_page_png(page_num, opts.render_dpi, opts.max_pixels) @@ -330,13 +342,24 @@ where // 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. + // + // Defensive rather than demonstrated: the loop only visits + // pages `get_pages()` lists, which is the same lookup that + // makes `extract_dctdecode_page_image` fail, so no fixture + // reaches this arm's error path. An attempt at a test for + // it passed while exercising nothing and was removed + // rather than kept as false coverage. Err(e) => match dct(&pdf_doc).ok().flatten() { Some(b) => Ok(b), None => Err(RasterFailure::Render(e.to_string())), }, }, - // A renderer is configured but could not open this PDF. - (None, true) => match dct(&pdf_doc)? { + // A renderer is configured but could not open this PDF. Same + // reasoning as above and stronger: reaching here means pdfium + // rejected the whole document, so the odds that lopdf also + // stumbles on it are at their highest — exactly where an `?` + // would turn one file into an aborted run. + (None, true) => match dct(&pdf_doc).ok().flatten() { Some(b) => Ok(b), None => Err(RasterFailure::Unopenable), }, diff --git a/crates/kebab-app/tests/pdf_ocr_apply.rs b/crates/kebab-app/tests/pdf_ocr_apply.rs index 0aab2ef..a6e6848 100644 --- a/crates/kebab-app/tests/pdf_ocr_apply.rs +++ b/crates/kebab-app/tests/pdf_ocr_apply.rs @@ -491,39 +491,52 @@ fn a_dctdecode_page_still_works_with_a_renderer_configured() { 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. +/// A PDF that lopdf parses but pdfium refuses. The renderer is +/// configured and working — it simply cannot open *this* file — so the +/// page must fall through to the DCTDecode path and, when that also has +/// nothing, be reported as `unopenable_pdf` rather than `no_renderer`. +/// Telling a user who already configured a renderer to configure one is +/// the wrong instruction. /// -/// Constructed rather than fixtured: the case is a disagreement between -/// two parsers, which is easier to state than to find in the wild. +/// The fixture is an encrypted PDF with no password: lopdf reads the +/// object graph without decrypting, pdfium refuses outright +/// (`PasswordError`). A first attempt at this test used truncated bytes, +/// which lopdf rejected before the branch was ever reached — the test +/// passed while exercising nothing. #[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 bytes = std::fs::read("../kebab-parse-pdf/tests/fixtures/encrypted_no_password.pdf") + .expect("encrypted fixture missing"); 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( + let mut reasons = Vec::new(); + let summary = apply_ocr_to_pdf_pages( &mut canonical, &engine, &bytes, &opts_with_renderer(), - |_| {}, + |p| { + if let kebab_app::pdf_ocr_apply::PdfOcrProgress::Finished { + failure_reason: Some(r), + .. + } = p + { + reasons.push(r); + } + }, + ) + .expect("a PDF the renderer cannot open must not abort the run"); + + assert_eq!(summary.pages_ocrd, 0, "nothing could be rasterized"); + assert_eq!( + summary.pages_skipped, 1, + "and the page is counted as skipped" + ); + assert_eq!( + reasons, + vec!["unopenable_pdf".to_string()], + "the reason must name the real problem, not a missing 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" - ); - } } diff --git a/crates/kebab-parse-pdf/tests/fixtures/encrypted_no_password.pdf b/crates/kebab-parse-pdf/tests/fixtures/encrypted_no_password.pdf new file mode 100644 index 0000000..96394b2 --- /dev/null +++ b/crates/kebab-parse-pdf/tests/fixtures/encrypted_no_password.pdf @@ -0,0 +1,25 @@ +%PDF-1.4 +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +4 0 obj +</U<0000000000000000000000000000000000000000000000000000000000000000>>> +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000054 00000 n +0000000105 00000 n +0000000170 00000 n +trailer +<<02>]>> +startxref +366 +%%EOF diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index f3584ef..236accf 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -128,6 +128,16 @@ OCR 브렌다(메이즈 러너 시리즈) / 개요 / 원작 소설과 영화 이때 새 라벨이 제 역할을 했다. 타임아웃 페이지가 "래스터 없음" 이 아니라 **"OCR 엔진 실패"** 로 찍혀서, 렌더링 문제가 아니라 엔진 문제라는 게 로그만 보고 갈렸다. +### 리뷰가 잡은 것 — 내 테스트가 아무것도 검증하지 않고 있었다 + +2회차에서 새 세 분기에 테스트를 넣었는데, 그중 "렌더러가 PDF 를 못 여는 경우" 테스트는 **잘린 바이트를 썼고 lopdf 가 그걸 먼저 거부했다**. 함수 초반 `load_mem(...)?` 에서 리턴되므로 pdfium 은 호출조차 안 됐고, 검증 대상 분기는 한 번도 실행되지 않았다. 통과하는데 아무것도 지키지 않는 테스트였다. + +**암호 없는 암호화 PDF** 로 픽스처를 만들어 고쳤다 — lopdf 는 객체 그래프를 읽고 pdfium 은 `PasswordError` 로 거부한다. 정확히 원하던 불일치다. `unopenable_pdf` 를 `no_renderer` 로 되돌리면 실패하는 것을 확인했다. + +같은 회차에서 `(None, true)` 분기에도 `?` 가 남아 있던 것을 고쳤다. `(Some, _)` 와 근거가 같고 오히려 더 강하다 — pdfium 이 문서 전체를 열지 못한 상태라 lopdf 도 깨져 있을 상관관계가 최대인 지점이다. + +`(Some, _)` 쪽 `?` 수정은 **테스트 없이 남긴다**. 루프가 `get_pages()` 가 나열한 페이지만 도는데 그 조회가 곧 `extract_dctdecode_page_image` 가 실패하는 조건이라, 이 arm 의 에러 경로에 닿는 픽스처를 만들 수 없다. 0-페이지 PDF 로 시도했다가 루프 자체가 안 도는 것을 확인하고 접었다 — 통과하지만 아무것도 안 지키는 테스트를 또 만드는 것보다 없는 편이 정직하다. 근거를 코드 주석에 남겼다. + ### 범위 밖 이슈가 권한 "DCTDecode 고속 경로를 남기지 말 것" 은 따르지 않았다. 폴백이 곧 그 경로이고, 폴백을 두는 것이 배포 결정의 귀결이다. 다만 렌더러가 있으면 그 경로는 타지 않는다.