feat(kebab-parse-pdf): P7-1 text PDF extractor — per-page CanonicalDocument

`PdfTextExtractor`(MediaType::Pdf) lopdf 기반 per-page 텍스트 추출.
페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`
emit. 본문이 비거나 추출 panic 인 페이지는 빈 paragraph + `Provenance::Warning`
("scanned candidate") 로 표시 — 이후 OCR fallback (별도 task) 의 입력.

핵심 동작:
- `lopdf::Document::load_mem` + `is_encrypted()` → 암호화 PDF 는 명시 에러
  (`qpdf --decrypt` 안내).
- 페이지 단위 `extract_text(&[page])` 를 `catch_unwind` 로 감싸 malformed
  page panic 을 recoverable warning 으로 변환.
- `/Info` dict 에서 Title/Producer/Creator best-effort 추출. UTF-16BE BOM
  prefixed 문자열도 디코드 (한국어 등 non-ASCII Title 정상 처리).
- 9개 통합 테스트: 3-page emit, scanned-mixed warning, encrypted refuse,
  corrupt header error, page_count 메타, UTF-16BE Title, filename
  fallback, determinism, snapshot.

`parser_version = "pdf-text-v1"`. Allowed deps: `lopdf 0.32` + `pdf-extract 0.7`
(원본 spec 그대로). 본문 다국어 OCR fallback 은 §9.2 후속 task (out of scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 08:34:55 +00:00
parent fd89777c83
commit 5a158d7343
9 changed files with 936 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
[package]
name = "kebab-parse-pdf"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "Text PDF extractor (per-page text + page citation) for the kebab pipeline (P7-1)"
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
time = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
# Per-page text extraction. `lopdf::Document::extract_text(&[page])`
# is the only stable per-page API across the pdf-extract / lopdf
# pair (pdf-extract 0.7 still exposes only whole-document calls).
lopdf = "0.32"
# Whole-document sanity-check call; covers a few format errors that
# lopdf swallows silently. Per-page text is sourced from lopdf only.
pdf-extract = "0.7"
[dev-dependencies]
tempfile = { workspace = true }
blake3 = { workspace = true }
serde_json = { workspace = true }