`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
295 lines
9.6 KiB
Rust
295 lines
9.6 KiB
Rust
//! Integration tests for `kebab_parse_pdf::PdfTextExtractor` (P7-1).
|
|
|
|
mod common;
|
|
|
|
use kebab_core::{Block, Extractor, ProvenanceKind, SourceSpan};
|
|
use kebab_parse_pdf::PdfTextExtractor;
|
|
use serde_json::Value;
|
|
|
|
use crate::common::{
|
|
InfoDict, build_text_pdf, build_text_pdf_with_info, corrupt_pdf, fixture_for,
|
|
make_encrypted_pdf, strip_dynamic_at, utf16be_bom,
|
|
};
|
|
|
|
fn paragraph_blocks(doc: &kebab_core::CanonicalDocument) -> Vec<&kebab_core::TextBlock> {
|
|
doc.blocks
|
|
.iter()
|
|
.map(|b| match b {
|
|
Block::Paragraph(t) => t,
|
|
other => panic!("expected Paragraph, got {other:?}"),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn three_page_pdf_emits_one_paragraph_block_per_page() {
|
|
let bytes = build_text_pdf(&[
|
|
Some("Hello page 1"),
|
|
Some("Hello page 2"),
|
|
Some("Hello page 3"),
|
|
]);
|
|
let fx = fixture_for("docs/three.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("3-page extraction must succeed");
|
|
|
|
assert_eq!(doc.title, "three");
|
|
assert_eq!(doc.lang.0, "und");
|
|
assert_eq!(doc.parser_version.0, kebab_parse_pdf::PARSER_VERSION);
|
|
assert_eq!(
|
|
doc.metadata.user["pdf"]["page_count"],
|
|
Value::Number(3.into())
|
|
);
|
|
|
|
let blocks = paragraph_blocks(&doc);
|
|
assert_eq!(blocks.len(), 3);
|
|
for (i, b) in blocks.iter().enumerate() {
|
|
let want_page = (i as u32) + 1;
|
|
match b.common.source_span {
|
|
SourceSpan::Page {
|
|
page,
|
|
char_start,
|
|
char_end,
|
|
} => {
|
|
assert_eq!(page, want_page);
|
|
assert_eq!(char_start, Some(0));
|
|
let chars = b.text.chars().count() as u32;
|
|
assert_eq!(char_end, Some(chars));
|
|
}
|
|
ref other => panic!("expected Page span, got {other:?}"),
|
|
}
|
|
assert!(
|
|
b.text.contains(&format!("Hello page {want_page}")),
|
|
"page {want_page} text mismatch: {:?}",
|
|
b.text
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_page_emits_warning_and_empty_paragraph() {
|
|
let bytes = build_text_pdf(&[Some("page one text"), None, Some("page three text")]);
|
|
let fx = fixture_for("docs/scanned-mixed.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("scanned-mixed extraction must succeed");
|
|
|
|
let blocks = paragraph_blocks(&doc);
|
|
assert_eq!(blocks.len(), 3);
|
|
assert!(blocks[1].text.is_empty(), "page 2 should have empty text");
|
|
assert!(
|
|
blocks[1].inlines.is_empty(),
|
|
"page 2 inlines should be empty"
|
|
);
|
|
match blocks[1].common.source_span {
|
|
SourceSpan::Page {
|
|
page,
|
|
char_start,
|
|
char_end,
|
|
} => {
|
|
assert_eq!(page, 2);
|
|
assert_eq!(char_start, Some(0));
|
|
assert_eq!(char_end, Some(0));
|
|
}
|
|
ref other => panic!("expected Page, got {other:?}"),
|
|
}
|
|
|
|
let warnings: Vec<_> = doc
|
|
.provenance
|
|
.events
|
|
.iter()
|
|
.filter(|e| e.kind == ProvenanceKind::Warning)
|
|
.collect();
|
|
assert_eq!(warnings.len(), 1, "exactly one warning for the empty page");
|
|
assert!(
|
|
warnings[0]
|
|
.note
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.contains("page2 empty (scanned candidate)"),
|
|
"warning note must mark page 2 as scanned candidate: {:?}",
|
|
warnings[0].note
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypted_pdf_returns_helpful_error() {
|
|
let bytes = make_encrypted_pdf();
|
|
let fx = fixture_for("docs/encrypted.pdf", &bytes);
|
|
let err = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect_err("encrypted PDF must be refused");
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.contains("encrypted"),
|
|
"error must mention encryption: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("qpdf") || msg.contains("decrypt"),
|
|
"error should point at remediation: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_header_returns_error() {
|
|
let bytes = corrupt_pdf();
|
|
let fx = fixture_for("docs/corrupt.pdf", &bytes);
|
|
let err = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect_err("corrupt PDF must error");
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.to_lowercase().contains("pdf") || msg.contains("parse"),
|
|
"error must mention PDF parse failure: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn page_count_matches_actual_count() {
|
|
let bytes = build_text_pdf(&[Some("a"), Some("b"), Some("c"), Some("d"), Some("e")]);
|
|
let fx = fixture_for("docs/five.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("5-page extraction must succeed");
|
|
|
|
assert_eq!(
|
|
doc.metadata.user["pdf"]["page_count"],
|
|
Value::Number(5.into())
|
|
);
|
|
assert_eq!(doc.blocks.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn info_dict_title_utf16be_bom_decoded() {
|
|
// Korean Title encoded as UTF-16BE with BOM is the standard PDF
|
|
// path for any non-ASCII metadata. We don't try to decode the
|
|
// body text in non-Latin scripts here (CID font support is out
|
|
// of scope for v1) — but the metadata path is in scope.
|
|
let info = InfoDict {
|
|
title: Some(utf16be_bom("케밥 문서")),
|
|
producer: Some("kebab-test"),
|
|
creator: None,
|
|
};
|
|
let bytes = build_text_pdf_with_info(&[Some("body")], &info);
|
|
let fx = fixture_for("docs/korean-title.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("PDF with UTF-16BE Title must extract");
|
|
|
|
assert_eq!(doc.title, "케밥 문서");
|
|
assert_eq!(
|
|
doc.metadata.user["pdf"]["producer"],
|
|
Value::String("kebab-test".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn info_dict_title_utf16be_surrogate_pair_decoded() {
|
|
// 🥙 (U+1F959 STUFFED FLATBREAD) sits in the supplementary plane,
|
|
// so encoding it as UTF-16BE produces a surrogate pair (D83E DD59).
|
|
// BMP-only inputs would never exercise the pair-joining path of
|
|
// `String::from_utf16_lossy` — this asserts that path round-trips.
|
|
let info = InfoDict {
|
|
title: Some(utf16be_bom("케밥 🥙 문서")),
|
|
producer: None,
|
|
creator: None,
|
|
};
|
|
let bytes = build_text_pdf_with_info(&[Some("body")], &info);
|
|
let fx = fixture_for("docs/emoji-title.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("PDF with surrogate-pair Title must extract");
|
|
assert_eq!(doc.title, "케밥 🥙 문서");
|
|
}
|
|
|
|
#[test]
|
|
fn info_dict_title_pdfdocencoding_latin1_high_bytes_decoded() {
|
|
// BOM-less PDFDocEncoded title with a high-byte char (0xE9 = 'é').
|
|
// `from_utf8_lossy` would have replaced this with U+FFFD; the
|
|
// byte-as-char path keeps it intact.
|
|
let info = InfoDict {
|
|
title: Some(b"Caf\xE9".to_vec()),
|
|
producer: None,
|
|
creator: None,
|
|
};
|
|
let bytes = build_text_pdf_with_info(&[Some("body")], &info);
|
|
let fx = fixture_for("docs/cafe-title.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("PDF with Latin-1 Title must extract");
|
|
assert_eq!(doc.title, "Café");
|
|
}
|
|
|
|
#[test]
|
|
fn info_dict_title_falls_back_to_filename_when_missing() {
|
|
let bytes = build_text_pdf(&[Some("body")]);
|
|
let fx = fixture_for("docs/no-info.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("no-info PDF must extract");
|
|
assert_eq!(doc.title, "no-info");
|
|
}
|
|
|
|
#[test]
|
|
fn determinism_identical_bytes_produce_identical_documents() {
|
|
let bytes = build_text_pdf(&[Some("alpha"), Some("beta"), Some("gamma")]);
|
|
let fx = fixture_for("docs/det.pdf", &bytes);
|
|
|
|
let mut a = serde_json::to_value(
|
|
PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("first extract"),
|
|
)
|
|
.unwrap();
|
|
let mut b = serde_json::to_value(
|
|
PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("second extract"),
|
|
)
|
|
.unwrap();
|
|
|
|
strip_dynamic_at(&mut a);
|
|
strip_dynamic_at(&mut b);
|
|
assert_eq!(a, b, "two extracts of identical bytes must be byte-equal");
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_three_page_canonical_document_stable() {
|
|
let bytes = build_text_pdf(&[Some("p1"), Some("p2"), Some("p3")]);
|
|
let fx = fixture_for("docs/snapshot.pdf", &bytes);
|
|
let doc = PdfTextExtractor::new()
|
|
.extract(&fx.ctx(), &bytes)
|
|
.expect("snapshot extract");
|
|
let mut json = serde_json::to_value(&doc).unwrap();
|
|
strip_dynamic_at(&mut json);
|
|
|
|
// Spot-check the load-bearing shape rather than committing a full
|
|
// golden file (the full JSON contains BLAKE3 ids that would
|
|
// change if `id_from(...)`'s tuple shape ever shifts — that would
|
|
// be a separate, intentional break).
|
|
assert_eq!(json["parser_version"], Value::String("pdf-text-v2".into()));
|
|
assert_eq!(json["lang"], Value::String("und".into()));
|
|
assert_eq!(json["schema_version"], Value::Number(1.into()));
|
|
assert_eq!(json["doc_version"], Value::Number(1.into()));
|
|
assert_eq!(json["blocks"].as_array().unwrap().len(), 3);
|
|
for (i, block) in json["blocks"].as_array().unwrap().iter().enumerate() {
|
|
assert_eq!(block["kind"], Value::String("paragraph".into()));
|
|
assert_eq!(
|
|
block["common"]["source_span"]["kind"],
|
|
Value::String("page".into())
|
|
);
|
|
assert_eq!(
|
|
block["common"]["source_span"]["page"],
|
|
Value::Number(((i as u64) + 1).into())
|
|
);
|
|
}
|
|
assert_eq!(
|
|
json["metadata"]["source_type"],
|
|
Value::String("paper".into())
|
|
);
|
|
assert_eq!(
|
|
json["metadata"]["trust_level"],
|
|
Value::String("primary".into())
|
|
);
|
|
}
|