`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
114 lines
4.1 KiB
Rust
114 lines
4.1 KiB
Rust
//! Snapshot test pinning the JSON wire form of `kebab_core::IngestReport`
|
|
//! for an inline fixture run. The store crate doesn't (yet) write
|
|
//! IngestReports — that's `kb-app`'s job — but the wire schema lives in
|
|
//! `kb-core`, and we want a determinism pin that fails loudly if the
|
|
//! shape drifts.
|
|
//!
|
|
//! Set `UPDATE_SNAPSHOTS=1` to re-bake the baseline.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use kebab_core::{
|
|
AssetId, ChunkerVersion, DocumentId, IngestItem, IngestItemKind, IngestReport, ParserVersion,
|
|
SourceScope, WorkspacePath,
|
|
};
|
|
use serde_json::Value;
|
|
|
|
fn baseline_path() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("snapshots")
|
|
.join("ingest_report.snapshot.json")
|
|
}
|
|
|
|
fn fixture_report() -> IngestReport {
|
|
IngestReport {
|
|
scope: SourceScope {
|
|
root: PathBuf::from("/home/u/KB"),
|
|
include: vec!["**/*.md".into()],
|
|
exclude: vec![".git/**".into()],
|
|
},
|
|
scanned: 3,
|
|
new: 2,
|
|
updated: 1,
|
|
skipped: 0,
|
|
unchanged: 0,
|
|
errors: 0,
|
|
duration_ms: 187,
|
|
skipped_by_extension: std::collections::BTreeMap::new(),
|
|
skipped_gitignore: 0,
|
|
skipped_kebabignore: 0,
|
|
skipped_builtin_blacklist: 0,
|
|
skipped_generated: 0,
|
|
skipped_size_exceeded: 0,
|
|
skip_examples: kebab_core::SkipExamples::default(),
|
|
purged_deleted_files: 0,
|
|
ocr_skipped_pages: 0,
|
|
items: Some(vec![
|
|
IngestItem {
|
|
kind: IngestItemKind::New,
|
|
doc_id: Some(DocumentId("a".repeat(32))),
|
|
doc_path: WorkspacePath::new("notes/alpha.md".into()).unwrap(),
|
|
asset_id: Some(AssetId("a".repeat(32))),
|
|
byte_len: Some(1234),
|
|
block_count: Some(7),
|
|
chunk_count: Some(3),
|
|
parser_version: Some(ParserVersion("md-frontmatter-v2".into())),
|
|
chunker_version: Some(ChunkerVersion("md-heading-v1".into())),
|
|
warnings: vec![],
|
|
pdf_ocr_pages: None,
|
|
pdf_ocr_ms_total: None,
|
|
error: None,
|
|
},
|
|
IngestItem {
|
|
kind: IngestItemKind::Updated,
|
|
doc_id: Some(DocumentId("b".repeat(32))),
|
|
doc_path: WorkspacePath::new("notes/beta.md".into()).unwrap(),
|
|
asset_id: Some(AssetId("b".repeat(32))),
|
|
byte_len: Some(2048),
|
|
block_count: Some(12),
|
|
chunk_count: Some(5),
|
|
parser_version: Some(ParserVersion("md-frontmatter-v2".into())),
|
|
chunker_version: Some(ChunkerVersion("md-heading-v1".into())),
|
|
warnings: vec!["malformed frontmatter".into()],
|
|
pdf_ocr_pages: None,
|
|
pdf_ocr_ms_total: None,
|
|
error: None,
|
|
},
|
|
]),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ingest_report_wire_form_is_stable() {
|
|
let report = fixture_report();
|
|
let actual = serde_json::to_value(&report).unwrap();
|
|
let baseline = match std::fs::read_to_string(baseline_path()) {
|
|
Ok(s) => s,
|
|
Err(_) if std::env::var("UPDATE_SNAPSHOTS").is_ok() => {
|
|
std::fs::create_dir_all(baseline_path().parent().unwrap()).unwrap();
|
|
let pretty = serde_json::to_string_pretty(&actual).unwrap();
|
|
std::fs::write(baseline_path(), format!("{pretty}\n")).unwrap();
|
|
return;
|
|
}
|
|
Err(e) => panic!(
|
|
"missing baseline {}; run with UPDATE_SNAPSHOTS=1: {e}",
|
|
baseline_path().display()
|
|
),
|
|
};
|
|
let expected: Value = serde_json::from_str(&baseline).unwrap();
|
|
if actual != expected {
|
|
if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
|
|
let pretty = serde_json::to_string_pretty(&actual).unwrap();
|
|
std::fs::write(baseline_path(), format!("{pretty}\n")).unwrap();
|
|
return;
|
|
}
|
|
let pretty = serde_json::to_string_pretty(&actual).unwrap();
|
|
panic!(
|
|
"ingest_report snapshot drift\n\
|
|
--- expected ({}) ---\n{baseline}\n\
|
|
--- actual ---\n{pretty}",
|
|
baseline_path().display()
|
|
);
|
|
}
|
|
}
|