feat(kebab-parse-pdf): P7-1 text PDF extractor #37
44
Cargo.lock
generated
@@ -3609,6 +3609,19 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"kebab-core",
|
||||
"lopdf",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-types"
|
||||
version = "0.1.0"
|
||||
@@ -4466,6 +4479,12 @@ dependencies = [
|
||||
"include_dir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -4521,6 +4540,25 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e775e4ee264e8a87d50a9efef7b67b4aa988cf94e75630859875fc347e6c872b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"itoa",
|
||||
"linked-hash-map",
|
||||
"log",
|
||||
"md5",
|
||||
"nom 7.1.3",
|
||||
"rayon",
|
||||
"time",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
@@ -4639,6 +4677,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "measure_time"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -20,6 +20,7 @@ members = [
|
||||
"crates/kebab-cli",
|
||||
"crates/kebab-eval",
|
||||
"crates/kebab-parse-image",
|
||||
"crates/kebab-parse-pdf",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
26
crates/kebab-parse-pdf/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[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" }
|
||||
|
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tracing = { 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).
|
||||
# pdf-extract is intentionally NOT pulled in here — its ~150 transitive
|
||||
# crates (pom, postscript, type1-encoding-parser, …) buy us nothing
|
||||
# at v1 (we don't call its whole-doc API), and the future scanned-PDF
|
||||
# OCR fallback can re-add it when it actually needs it.
|
||||
lopdf = "0.32"
|
||||
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `pdf-extract` 미사용 dep 를 미리 코멘트로 막아둔 게 좋습니다. "실제로 호출하지 않는다" + "OCR fallback task 가 필요로 할 때 다시 추가하라" 두 줄이 미래에 누군가 `pdf-extract` 를 reflexively 다시 넣으려고 할 때 동기를 멈추게 만듭니다. 본 워크스페이스의 dep 비대 비용 (workspace target/ 6–10 GB, 18 통합 테스트 바이너리 link 메모리 한계) 을 고려하면 이런 "왜 빠져 있는지" 코멘트가 단순한 dep 제거보다 가치가 큽니다.
|
||||
[dev-dependencies]
|
||||
blake3 = { workspace = true }
|
||||
76
crates/kebab-parse-pdf/src/info.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! `/Info` dictionary extraction (best-effort).
|
||||
//!
|
||||
//! PDFs may carry a `/Info` trailer dictionary with `Title`,
|
||||
//! `Producer`, `Creator`, etc. Strings are encoded as either
|
||||
//! UTF-16BE prefixed with the BOM `0xFE 0xFF` OR PDFDocEncoding
|
||||
|
claude-reviewer-01
commented
(nit) doc 코멘트의 "PDFDocEncoding (Latin-1 superset)" 는 부정확합니다. PDFDocEncoding 은 Latin-1 의 superset 이 아닙니다 — 0x18–0x1F 와 0x80–0x9F 영역에서 Latin-1 과 다른 매핑을 가집니다 (예: 0x18=breve, 0x80=bullet, 0x95=ellipsis 등). "Latin-1 과 0x20–0x7E + 0xA0–0xFF 범위에서 호환" 정도가 정확합니다. How to apply: 위 dep cleanup + 본 코멘트의 위 issue 와 함께 한 번에 수정. 한 줄짜리 표현 정정이라 PR scope 안에서 처리해도 무방. (nit) doc 코멘트의 "PDFDocEncoding (Latin-1 superset)" 는 부정확합니다. PDFDocEncoding 은 Latin-1 의 superset 이 아닙니다 — 0x18–0x1F 와 0x80–0x9F 영역에서 Latin-1 과 다른 매핑을 가집니다 (예: 0x18=breve, 0x80=bullet, 0x95=ellipsis 등). "Latin-1 과 0x20–0x7E + 0xA0–0xFF 범위에서 호환" 정도가 정확합니다.
How to apply: 위 dep cleanup + 본 코멘트의 위 issue 와 함께 한 번에 수정. 한 줄짜리 표현 정정이라 PR scope 안에서 처리해도 무방.
|
||||
//! (which agrees with Latin-1 over `0x20–0x7E` + `0xA0–0xFF` and
|
||||
//! diverges in the `0x18–0x1F` / `0x80–0x9F` ranges). We decode
|
||||
//! BOM'd strings as proper UTF-16BE; non-BOM strings are decoded
|
||||
//! as Latin-1 (byte → `char`), which is correct for the common
|
||||
//! ASCII case and a best-effort approximation for the divergent
|
||||
//! PDFDocEncoding ranges (full PDFDocEncoding tables aren't worth
|
||||
//! the maintenance for what is effectively legacy metadata). All
|
||||
//! fields are optional — a missing `/Info` dict is not an error.
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct InfoDict {
|
||||
pub title: Option<String>,
|
||||
pub producer: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn extract_info(doc: &lopdf::Document) -> InfoDict {
|
||||
let mut out = InfoDict::default();
|
||||
|
||||
let info_obj = match doc.trailer.get(b"Info") {
|
||||
Ok(o) => o,
|
||||
Err(_) => return out,
|
||||
};
|
||||
|
||||
let dict = match info_obj {
|
||||
lopdf::Object::Dictionary(d) => Some(d),
|
||||
lopdf::Object::Reference(id) => doc
|
||||
.get_object(*id)
|
||||
.ok()
|
||||
.and_then(|o| o.as_dict().ok()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some(dict) = dict else { return out };
|
||||
|
||||
out.title = pdf_string(dict, b"Title");
|
||||
out.producer = pdf_string(dict, b"Producer");
|
||||
out.creator = pdf_string(dict, b"Creator");
|
||||
out
|
||||
}
|
||||
|
||||
fn pdf_string(dict: &lopdf::Dictionary, key: &[u8]) -> Option<String> {
|
||||
let raw = dict.get(key).ok()?;
|
||||
let bytes: &[u8] = match raw {
|
||||
lopdf::Object::String(s, _) => s.as_slice(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// UTF-16BE with BOM (very common for non-ASCII PDF titles).
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
|
||||
let payload = &bytes[2..];
|
||||
if payload.len() % 2 == 0 {
|
||||
let units: Vec<u16> = payload
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let s = String::from_utf16_lossy(&units);
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
claude-reviewer-01
commented
(issue) Why: 현실의 PDF 는 거의 다 UTF-16BE BOM 경로를 쓰지만 (앞 분기에서 처리), 일부 레거시 PDF (특히 1990s–2000s 의 LaTeX 출력) 는 BOM 없는 PDFDocEncoding 으로 Title 을 씁니다. 그 경우 잘못된 Title 이 metadata 에 저장되고, downstream search/RAG 에 노출됩니다 (filename fallback 으로 빠지지도 않음 — How to apply: PDFDocEncoding 은 0x00–0x7F 가 ASCII 와 동일하고 0x80–0xFF 는 (몇 개의 예외를 제외하고) Latin-1 / Unicode codepoint 와 동일합니다. byte → char 직접 캐스팅이 ASCII 케이스를 깨지 않으면서 Latin-1 케이스를 살립니다: PDFDocEncoding 의 0x18–0x1F 7 byte 예외 (breve, caron 등) 는 정확하지 않게 매핑되지만 — 이것은 풀-퀄리티 PDFDocEncoding 디코더가 아닌 best-effort 의 한계로서 받아들일 수 있습니다 (현 fallback 도 그 영역을 정확히 매핑하지 않으므로 regression 없음). 대부분의 경우 (라틴 액센트 문자) 정확히 동작. (issue) `from_utf8_lossy` 는 Latin-1 디코더가 아닙니다. PDFDocEncoding 의 0x80–0xFF 영역 (액센트 / 라틴 확장 문자) 는 대부분 invalid UTF-8 단일 바이트라 `\u{FFFD}` (replacement char) 로 치환됩니다. 즉 BOM 없이 PDFDocEncoding 으로 인코딩된 비-ASCII Title (예: ISO-8859-1 기반 "Café") 는 `"C?fé"` 같은 깨진 결과가 나옵니다.
Why: 현실의 PDF 는 거의 다 UTF-16BE BOM 경로를 쓰지만 (앞 분기에서 처리), 일부 레거시 PDF (특히 1990s–2000s 의 LaTeX 출력) 는 BOM 없는 PDFDocEncoding 으로 Title 을 씁니다. 그 경우 잘못된 Title 이 metadata 에 저장되고, downstream search/RAG 에 노출됩니다 (filename fallback 으로 빠지지도 않음 — `.is_empty()` 체크는 통과하므로).
How to apply: PDFDocEncoding 은 0x00–0x7F 가 ASCII 와 동일하고 0x80–0xFF 는 (몇 개의 예외를 제외하고) Latin-1 / Unicode codepoint 와 동일합니다. byte → char 직접 캐스팅이 ASCII 케이스를 깨지 않으면서 Latin-1 케이스를 살립니다:
```rust
let s: String = bytes.iter().map(|&b| b as char).collect();
if s.is_empty() { None } else { Some(s) }
```
PDFDocEncoding 의 0x18–0x1F 7 byte 예외 (breve, caron 등) 는 정확하지 않게 매핑되지만 — 이것은 풀-퀄리티 PDFDocEncoding 디코더가 아닌 best-effort 의 한계로서 받아들일 수 있습니다 (현 fallback 도 그 영역을 정확히 매핑하지 않으므로 regression 없음). 대부분의 경우 (라틴 액센트 문자) 정확히 동작.
|
||||
// PDFDocEncoding fallback (no BOM). Direct byte → char cast is
|
||||
// a Latin-1 decoder: ASCII (0x00–0x7F) round-trips, and
|
||||
// 0xA0–0xFF maps to the matching Unicode code point. `from_utf8_lossy`
|
||||
// would have replaced 0x80–0xFF with U+FFFD, mangling legacy
|
||||
// PDFDocEncoded titles like "Café".
|
||||
let s: String = bytes.iter().map(|&b| b as char).collect();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
233
crates/kebab-parse-pdf/src/lib.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! `kebab-parse-pdf` — text PDF extractor (P7-1).
|
||||
//!
|
||||
//! Implements [`kebab_core::Extractor`] for [`MediaType::Pdf`]. Extracts
|
||||
//! text page-by-page via `lopdf`'s per-page API and emits one
|
||||
//! [`Block::Paragraph`] per page with [`SourceSpan::Page`] (1-based page,
|
||||
//! `char_start = 0`, `char_end = chars().count()`).
|
||||
//!
|
||||
//! Pages where text extraction fails or returns empty get an empty
|
||||
//! `Block::Paragraph` plus a `Provenance::Warning` flagging the page as
|
||||
//! a "scanned candidate" — out-of-scope OCR fallback can pick those up.
|
||||
//!
|
||||
//! Scope is intentionally narrow: page text + page numbers. Layout
|
||||
//! reconstruction (multi-column reading order, tables, math), form
|
||||
//! fields, bookmarks, and OCR for scanned PDFs are explicitly **not**
|
||||
//! in this task. See `tasks/p7/p7-1-pdf-text-extractor.md`.
|
||||
//!
|
||||
//! Per design §3.4 (`SourceSpan::Page` / `Block::Paragraph`),
|
||||
//! §9.2 (PDF text extraction), §9 versioning.
|
||||
|
||||
mod info;
|
||||
mod page_text;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::{
|
||||
Block, CanonicalDocument, CommonBlock, Extractor, Inline, Lang, MediaType, Metadata,
|
||||
ParserVersion, Provenance, ProvenanceEvent, ProvenanceKind, SourceSpan, SourceType, TextBlock,
|
||||
TrustLevel, id_for_block, id_for_doc,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
pub const PARSER_VERSION: &str = "pdf-text-v1";
|
||||
|
||||
/// Text-PDF extractor. Per-page text via `lopdf::Document::extract_text`
|
||||
/// (the only stable per-page API in the lopdf / pdf-extract pair —
|
||||
/// pdf-extract 0.7 only exposes whole-document calls).
|
||||
pub struct PdfTextExtractor;
|
||||
|
||||
impl PdfTextExtractor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PdfTextExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Extractor for PdfTextExtractor {
|
||||
fn supports(&self, m: &MediaType) -> bool {
|
||||
matches!(m, MediaType::Pdf)
|
||||
}
|
||||
|
||||
fn parser_version(&self) -> ParserVersion {
|
||||
ParserVersion(PARSER_VERSION.to_string())
|
||||
}
|
||||
|
||||
fn extract(
|
||||
&self,
|
||||
ctx: &kebab_core::ExtractContext<'_>,
|
||||
bytes: &[u8],
|
||||
) -> Result<CanonicalDocument> {
|
||||
let asset = ctx.asset;
|
||||
if !self.supports(&asset.media_type) {
|
||||
anyhow::bail!(
|
||||
"kebab-parse-pdf: unsupported media_type for PdfTextExtractor: {:?}",
|
||||
asset.media_type
|
||||
);
|
||||
}
|
||||
|
||||
let parser_version = self.parser_version();
|
||||
let doc_id = id_for_doc(&asset.workspace_path, &asset.asset_id, &parser_version);
|
||||
|
||||
// Catastrophic-decode guard via lopdf. `pdf-extract` is intentionally
|
||||
// not used for parsing here — it only exposes whole-doc text and
|
||||
// would re-parse the bytes a second time.
|
||||
let pdf_doc = lopdf::Document::load_mem(bytes)
|
||||
.context("kebab-parse-pdf: failed to parse PDF (corrupt header or not a PDF)")?;
|
||||
|
||||
if pdf_doc.is_encrypted() {
|
||||
anyhow::bail!(
|
||||
"kebab-parse-pdf: encrypted PDF; remove encryption (e.g. `qpdf --decrypt`) before ingest"
|
||||
);
|
||||
}
|
||||
|
||||
let info = info::extract_info(&pdf_doc);
|
||||
// `get_pages()` returns BTreeMap<u32, ObjectId> with 1-based page
|
||||
// numbers. We iterate keys in BTreeMap natural order so output is
|
||||
// deterministic.
|
||||
let pages = pdf_doc.get_pages();
|
||||
let page_count = pages.len() as u32;
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let mut events: Vec<ProvenanceEvent> = Vec::with_capacity(2 + pages.len());
|
||||
events.push(ProvenanceEvent {
|
||||
at: asset.discovered_at,
|
||||
agent: "kb-source-fs".to_string(),
|
||||
kind: ProvenanceKind::Discovered,
|
||||
note: None,
|
||||
});
|
||||
events.push(ProvenanceEvent {
|
||||
at: now,
|
||||
agent: "kb-parse-pdf".to_string(),
|
||||
kind: ProvenanceKind::Parsed,
|
||||
note: Some(format!(
|
||||
"parser_version={}; page_count={}",
|
||||
parser_version.0, page_count
|
||||
)),
|
||||
});
|
||||
|
||||
let mut blocks: Vec<Block> = Vec::with_capacity(pages.len());
|
||||
for (&page_num, _) in pages.iter() {
|
||||
let (text, warning) = match page_text::extract_one(&pdf_doc, page_num) {
|
||||
Ok(t) if !t.trim().is_empty() => (t, None),
|
||||
Ok(_) => (
|
||||
String::new(),
|
||||
Some(format!("page{page_num} empty (scanned candidate)")),
|
||||
),
|
||||
Err(e) => (
|
||||
String::new(),
|
||||
Some(format!(
|
||||
"page{page_num} extract failed: {e} (scanned candidate)"
|
||||
)),
|
||||
),
|
||||
};
|
||||
let char_count = text.chars().count() as u32;
|
||||
let span = SourceSpan::Page {
|
||||
page: page_num,
|
||||
char_start: Some(0),
|
||||
char_end: Some(char_count),
|
||||
};
|
||||
// lopdf's `get_pages()` is 1-based by contract. A 0-key would
|
||||
// collapse two pages onto the same ordinal (silently breaking
|
||||
// ordinal-based sorting downstream), so we assert the
|
||||
|
claude-reviewer-01
commented
(issue) Why: lopdf 0.32 가 1-based 를 보장하지만 미래 버전 / 손상된 PDF 에서 page=0 이 새어 들어올 가능성을 spec 이 명시적으로 가드하라고 지시한 건 아닙니다. 그러나 silent 흡수보다는 명시적 신호가 디버깅 친화적입니다. How to apply (둘 중 택일): 옵션 A — debug_assert 로 invariant 만 명시: 옵션 B — release 에서도 가드, 0 페이지를 Warning 으로 떨어뜨림: 옵션 A 가 본 PR scope 에 더 fit 합니다 (현재 saturating 가 코드 noise 인 vs assert 1 줄). (issue) `saturating_sub(1)` 가 "shouldn't-happen" 한 page=0 케이스를 silent 로 흡수하는데, 그 경로에서 page=0 과 page=1 둘 다 ordinal=0 으로 매핑되어 `id_for_block` 결과가 같아집니다 (heading_path 가 비어 있고 source_span 도 둘 다 SourceSpan::Page 이지만 page 필드만 다르므로 — span 은 다름, 따라서 collision 은 사실 안 일어남). 실제로 collision 은 안 나지만 ordinal 이 의미 없는 "실제 페이지 번호와 다른 값" 이 되어 chunk 단계에서 ordinal 기준 정렬을 했을 때 page=1 (ordinal 0) 과 page=0 (ordinal 0) 의 순서가 비결정적이 됩니다.
Why: lopdf 0.32 가 1-based 를 보장하지만 미래 버전 / 손상된 PDF 에서 page=0 이 새어 들어올 가능성을 spec 이 명시적으로 가드하라고 지시한 건 아닙니다. 그러나 silent 흡수보다는 명시적 신호가 디버깅 친화적입니다.
How to apply (둘 중 택일):
옵션 A — debug_assert 로 invariant 만 명시:
```rust
debug_assert!(page_num >= 1, "lopdf get_pages() returned 0-based page key");
let ordinal = page_num.saturating_sub(1);
```
옵션 B — release 에서도 가드, 0 페이지를 Warning 으로 떨어뜨림:
```rust
if page_num == 0 {
events.push(ProvenanceEvent { ..., kind: Warning, note: Some("lopdf yielded page=0 (skipped)".into()) });
continue;
}
let ordinal = page_num - 1;
```
옵션 A 가 본 PR scope 에 더 fit 합니다 (현재 saturating 가 코드 noise 인 vs assert 1 줄).
|
||||
// invariant in dev builds. The release fallback still uses
|
||||
// saturating_sub so a future lopdf regression degrades to
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `debug_assert!` + release fallback 의 조합이 디버깅 친화성과 운영 견고성을 한 번에 잡습니다. dev/test 에서는 lopdf 가 contract 위반 시 즉시 panic 으로 잡히고, release 에서는 saturating_sub 가 garbled order 로 degrade — 운영 중 한 페이지 순서가 어긋나는 게 panic 으로 ingest 전체가 멈추는 것보다 낫다는 판단이 P6-4 의 lenient policy (extract 성공 → 저장, OCR/caption 실패 → Warning) 와 톤이 같습니다.
|
||||
// garbled order rather than panic.
|
||||
debug_assert!(page_num >= 1, "lopdf get_pages() returned 0-based page key");
|
||||
let ordinal = page_num.saturating_sub(1);
|
||||
let block_id = id_for_block(&doc_id, "paragraph", &[], ordinal, &span);
|
||||
let common = CommonBlock {
|
||||
block_id,
|
||||
heading_path: Vec::new(),
|
||||
source_span: span,
|
||||
};
|
||||
let inlines = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Inline::Text { text: text.clone() }]
|
||||
};
|
||||
blocks.push(Block::Paragraph(TextBlock {
|
||||
common,
|
||||
text,
|
||||
inlines,
|
||||
}));
|
||||
if let Some(note) = warning {
|
||||
events.push(ProvenanceEvent {
|
||||
at: now,
|
||||
agent: "kb-parse-pdf".to_string(),
|
||||
kind: ProvenanceKind::Warning,
|
||||
note: Some(note),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let title = info
|
||||
.title
|
||||
.clone()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
let fname = filename_from_workspace_path(&asset.workspace_path.0);
|
||||
strip_extension(&fname)
|
||||
});
|
||||
|
||||
let mut user = Map::new();
|
||||
let mut pdf_meta = Map::new();
|
||||
pdf_meta.insert("page_count".into(), Value::Number(page_count.into()));
|
||||
if let Some(p) = &info.producer {
|
||||
pdf_meta.insert("producer".into(), Value::String(p.clone()));
|
||||
}
|
||||
if let Some(c) = &info.creator {
|
||||
pdf_meta.insert("creator".into(), Value::String(c.clone()));
|
||||
}
|
||||
user.insert("pdf".into(), Value::Object(pdf_meta));
|
||||
|
||||
let metadata = Metadata {
|
||||
aliases: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
created_at: asset.discovered_at,
|
||||
updated_at: asset.discovered_at,
|
||||
source_type: SourceType::Paper,
|
||||
trust_level: TrustLevel::Primary,
|
||||
user_id_alias: None,
|
||||
user,
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
target: "kebab-parse-pdf",
|
||||
"extracted PDF doc_id={} workspace_path={} pages={}",
|
||||
doc_id.0,
|
||||
asset.workspace_path.0,
|
||||
page_count
|
||||
);
|
||||
|
||||
Ok(CanonicalDocument {
|
||||
doc_id,
|
||||
source_asset_id: asset.asset_id.clone(),
|
||||
workspace_path: asset.workspace_path.clone(),
|
||||
title,
|
||||
lang: Lang("und".to_string()),
|
||||
blocks,
|
||||
metadata,
|
||||
provenance: Provenance { events },
|
||||
parser_version,
|
||||
schema_version: 1,
|
||||
doc_version: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn filename_from_workspace_path(p: &str) -> String {
|
||||
p.rsplit('/').next().unwrap_or(p).to_string()
|
||||
}
|
||||
|
||||
fn strip_extension(filename: &str) -> String {
|
||||
match filename.rfind('.') {
|
||||
Some(0) => filename.to_string(),
|
||||
Some(idx) => filename[..idx].to_string(),
|
||||
None => filename.to_string(),
|
||||
}
|
||||
}
|
||||
13
crates/kebab-parse-pdf/src/page_text.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! Per-page text extraction. `lopdf::Document::extract_text(&[page])`
|
||||
//! is the call we lean on; it has a thin history of panicking on
|
||||
//! malformed pages, so we wrap it in `catch_unwind` to convert the
|
||||
//! panic into a recoverable `Err` (which the caller maps to an empty
|
||||
//! page + Warning).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
pub(crate) fn extract_one(doc: &lopdf::Document, page: u32) -> anyhow::Result<String> {
|
||||
let result = catch_unwind(AssertUnwindSafe(|| doc.extract_text(&[page])))
|
||||
.map_err(|_| anyhow::anyhow!("panic during lopdf::Document::extract_text"))?;
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `catch_unwind` + `AssertUnwindSafe` 로 lopdf 의 historical malformed-page panic 을 recoverable `Err` 로 둔갑시킨 게 좋습니다. spec § Behavior contract 의 "wrap with `catch_unwind` to absorb the rare crash on malformed pages" 요구를 정확히 한 줄로 표현했고, 호출부 (lib.rs:115) 의 `match` arm 이 panic / Err / Ok-empty 셋을 같은 "scanned candidate" 채널로 묶어 운영자 관점에서 ���관됩니다. 향후 release 프로파일을 `panic=abort` 로 옮길 때만 caveat — 그때는 catch_unwind 가 작동하지 않으므로 본 함수 헤더 doc 에 "panic=unwind 전제" 한 줄을 더하면 미래 reader 에게 친절.
|
||||
result.map_err(|e| anyhow::anyhow!("lopdf extract_text error: {e}"))
|
||||
}
|
||||
224
crates/kebab-parse-pdf/tests/common/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
//! Test fixture builders for `kebab-parse-pdf`.
|
||||
//!
|
||||
//! PDFs are constructed in-memory at test time via `lopdf` rather than
|
||||
//! committed as binary fixtures. Same rationale as
|
||||
//! `kebab-parse-image::tests::common`: fixture provenance is auditable
|
||||
//! from source, no `include_bytes!` paths to keep in sync, and the test
|
||||
//! binary stays self-contained.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kebab_core::{
|
||||
AssetStorage, Checksum, ExtractConfig, ExtractContext, MediaType, RawAsset, SourceUri,
|
||||
WorkspacePath,
|
||||
};
|
||||
use lopdf::content::{Content, Operation};
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// `/Info` dict fields a fixture wants to surface (all optional).
|
||||
#[derive(Default, Clone)]
|
||||
pub struct InfoDict {
|
||||
pub title: Option<Vec<u8>>, // raw bytes — caller controls PDFDocEncoding vs UTF-16BE
|
||||
pub producer: Option<&'static str>,
|
||||
pub creator: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Build a Helvetica-text PDF. `pages` is one entry per page; `None`
|
||||
/// means the page exists in `/Pages` but has no `/Contents` stream
|
||||
/// (the "scanned candidate" shape — `extract_text` returns empty).
|
||||
pub fn build_text_pdf(pages: &[Option<&str>]) -> Vec<u8> {
|
||||
build_text_pdf_with_info(pages, &InfoDict::default())
|
||||
}
|
||||
|
||||
pub fn build_text_pdf_with_info(pages: &[Option<&str>], info: &InfoDict) -> Vec<u8> {
|
||||
let mut doc = Document::with_version("1.5");
|
||||
let pages_id = doc.new_object_id();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `lopdf` 빌더로 in-memory PDF 를 합성해 쓰는 패턴이 P6 의 `kebab-parse-image::tests::common` 에서 정립한 "binary fixture 커밋 안 함" 컨벤션을 그대로 잇습니다. `pages: &[Option<&str>]` 시그니처가 "있는 페이지" / "비어 있는 (Contents 없음) 페이지" 두 케이스를 한 슬라이스로 표현해 spec 의 "scanned-mixed PDF" 테스트 fixture 를 만들기에 깔끔합니다 — 호출부 (`tests/extractor.rs:empty_page_emits_warning_and_empty_paragraph`) 가 한 줄로 의도를 드러냅니다.
|
||||
});
|
||||
let resources_id = doc.add_object(dictionary! {
|
||||
"Font" => dictionary! { "F1" => font_id },
|
||||
});
|
||||
|
||||
let mut page_refs: Vec<Object> = Vec::new();
|
||||
for page in pages {
|
||||
let mut page_dict = dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
};
|
||||
if let Some(text) = page {
|
||||
let content = Content {
|
||||
operations: vec![
|
||||
Operation::new("BT", vec![]),
|
||||
Operation::new("Tf", vec!["F1".into(), 24.into()]),
|
||||
Operation::new(
|
||||
"Td",
|
||||
vec![Object::Integer(100), Object::Integer(700)],
|
||||
),
|
||||
Operation::new("Tj", vec![Object::string_literal(*text)]),
|
||||
Operation::new("ET", vec![]),
|
||||
],
|
||||
};
|
||||
let stream_data = content.encode().expect("content encode");
|
||||
let content_id =
|
||||
doc.add_object(Stream::new(dictionary! {}, stream_data));
|
||||
page_dict.set("Contents", content_id);
|
||||
}
|
||||
let page_id = doc.add_object(page_dict);
|
||||
page_refs.push(page_id.into());
|
||||
}
|
||||
|
||||
let count = page_refs.len() as i64;
|
||||
let pages_dict = dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => page_refs,
|
||||
"Count" => count,
|
||||
"Resources" => resources_id,
|
||||
"MediaBox" => vec![
|
||||
Object::Integer(0),
|
||||
Object::Integer(0),
|
||||
Object::Integer(595),
|
||||
Object::Integer(842),
|
||||
],
|
||||
};
|
||||
doc.objects
|
||||
.insert(pages_id, Object::Dictionary(pages_dict));
|
||||
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => pages_id,
|
||||
});
|
||||
doc.trailer.set("Root", catalog_id);
|
||||
|
||||
if info.title.is_some() || info.producer.is_some() || info.creator.is_some() {
|
||||
let mut info_dict = lopdf::Dictionary::new();
|
||||
if let Some(title) = &info.title {
|
||||
info_dict.set(
|
||||
"Title",
|
||||
Object::String(title.clone(), lopdf::StringFormat::Literal),
|
||||
);
|
||||
}
|
||||
if let Some(p) = info.producer {
|
||||
info_dict.set(
|
||||
"Producer",
|
||||
Object::String(p.as_bytes().to_vec(), lopdf::StringFormat::Literal),
|
||||
);
|
||||
}
|
||||
if let Some(c) = info.creator {
|
||||
info_dict.set(
|
||||
"Creator",
|
||||
Object::String(c.as_bytes().to_vec(), lopdf::StringFormat::Literal),
|
||||
);
|
||||
}
|
||||
let info_id = doc.add_object(Object::Dictionary(info_dict));
|
||||
doc.trailer.set("Info", info_id);
|
||||
}
|
||||
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
doc.save_to(&mut out).expect("save PDF to memory");
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap any valid PDF byte buffer with a fake `/Encrypt` trailer entry
|
||||
/// so `Document::is_encrypted()` flips to true. We don't actually
|
||||
/// encrypt anything — the extractor refuses encrypted PDFs **before**
|
||||
/// touching streams, so the marker is sufficient.
|
||||
pub fn make_encrypted_pdf() -> Vec<u8> {
|
||||
let bytes = build_text_pdf(&[Some("placeholder")]);
|
||||
let mut doc = Document::load_mem(&bytes).expect("load round-tripped PDF");
|
||||
let enc_id = doc.add_object(dictionary! {
|
||||
"Filter" => "Standard",
|
||||
"V" => 1,
|
||||
"R" => 2,
|
||||
"Length" => 40,
|
||||
"P" => -4,
|
||||
});
|
||||
doc.trailer.set("Encrypt", enc_id);
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out).expect("save encrypted PDF");
|
||||
out
|
||||
}
|
||||
|
||||
/// 27-byte garbage with no `%PDF-` header — `Document::load_mem` errors.
|
||||
pub fn corrupt_pdf() -> Vec<u8> {
|
||||
b"NOT A PDF; just plain bytes".to_vec()
|
||||
}
|
||||
|
||||
/// Encode a Rust `&str` as the PDF UTF-16BE-with-BOM string format.
|
||||
/// Used to verify `info::pdf_string` decodes the multilingual Title
|
||||
/// path correctly.
|
||||
pub fn utf16be_bom(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(2 + s.encode_utf16().count() * 2);
|
||||
out.extend_from_slice(&[0xFE, 0xFF]);
|
||||
for unit in s.encode_utf16() {
|
||||
out.extend_from_slice(&unit.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Asset + ExtractContext fixture, mirroring `kebab-parse-image::tests::common`.
|
||||
pub struct PdfFixture {
|
||||
pub asset: RawAsset,
|
||||
workspace_root: PathBuf,
|
||||
config: ExtractConfig,
|
||||
}
|
||||
|
||||
impl PdfFixture {
|
||||
pub fn ctx(&self) -> ExtractContext<'_> {
|
||||
ExtractContext {
|
||||
asset: &self.asset,
|
||||
workspace_root: &self.workspace_root,
|
||||
config: &self.config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fixture_for(workspace_path: &str, bytes: &[u8]) -> PdfFixture {
|
||||
let blake = blake3::hash(bytes);
|
||||
let full_hex = blake.to_hex().to_string();
|
||||
let asset_id = kebab_core::id_for_asset(&full_hex);
|
||||
let workspace_path = WorkspacePath::new(workspace_path.to_string()).unwrap();
|
||||
let discovered_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
|
||||
let asset = RawAsset {
|
||||
asset_id,
|
||||
source_uri: SourceUri::File(PathBuf::from(format!("/tmp/{}", workspace_path.0))),
|
||||
workspace_path,
|
||||
media_type: MediaType::Pdf,
|
||||
byte_len: bytes.len() as u64,
|
||||
checksum: Checksum(full_hex),
|
||||
discovered_at,
|
||||
stored: AssetStorage::Reference {
|
||||
path: PathBuf::from("/tmp/fake"),
|
||||
sha: Checksum("0".repeat(64)),
|
||||
},
|
||||
};
|
||||
PdfFixture {
|
||||
asset,
|
||||
workspace_root: PathBuf::from("/tmp/fake-root"),
|
||||
config: ExtractConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace every provenance event timestamp after index 0 (Discovered)
|
||||
/// with `<stripped>` so determinism / snapshot tests can compare JSON
|
||||
/// across runs. Same shape as `kebab-parse-image::tests::common::strip_dynamic_at`.
|
||||
pub fn strip_dynamic_at(json: &mut serde_json::Value) {
|
||||
if let Some(events) = json
|
||||
.get_mut("provenance")
|
||||
.and_then(|p| p.get_mut("events"))
|
||||
.and_then(|e| e.as_array_mut())
|
||||
{
|
||||
for (i, ev) in events.iter_mut().enumerate() {
|
||||
if i > 0
|
||||
&& let Some(obj) = ev.as_object_mut()
|
||||
{
|
||||
obj.insert("at".into(), serde_json::Value::String("<stripped>".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
285
crates/kebab-parse-pdf/tests/extractor.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! 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() {
|
||||
|
claude-reviewer-01
commented
(칭찬) 🥙 (U+1F959, supplementary plane) 가 (칭찬) 🥙 (U+1F959, supplementary plane) 가 `String::from_utf16_lossy` 의 surrogate pair 결합 분기를 강제로 통과시킵니다. BMP-only 테스트로는 절대 잡히지 않을 미래 회귀 (예: `chunks_exact(2)` 단위로만 보고 surrogate 페어를 나눠 받는 잘못된 리팩터링) 를 한 줄짜리 입력으로 차단합니다. 이모지 선택도 적절 — 한국어 지식 베이스의 정체성 (kebab) 과 정렬되어 있어 reader 가 의도를 즉시 알아봅니다.
|
||||
// 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-v1".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())
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ phase: P7
|
||||
component: kebab-parse-pdf (text extractor)
|
||||
task_id: p7-1
|
||||
title: "Text PDF extractor → CanonicalDocument with page-level blocks"
|
||||
status: planned
|
||||
status: completed
|
||||
depends_on: [p0-1, p1-6]
|
||||
unblocks: [p7-2]
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
|
||||
(issue / dep 정리) 다음 deps 가 라이브러리 / 테스트 어디에서도 import 되지 않습니다 —
cargo check -p kebab-parse-pdf후target/.../kebab_parse_pdf-*산출물에 이름이 등장하지 않고,grep -rn '<name>' crates/kebab-parse-pdf/{src,tests}가 0 건입니다:kebab-config(line 12) — runtime dep 인데 lib 에서 한 번도 가져오지 않음. P7-1 spec § Allowed deps 에는kebab-config가 명시돼 있지만 v1 구현이 실제로 필요로 하지 않으므로 일단 빼는 것이 정직합니다 (필요할 때 다시 추가).thiserror(line 18) — anyhow 만 사용 중이라 dead.pdf-extract(line 25) — 가장 무거운 dead dep. 본 PR diff Cargo.lock 부분에 보이듯pdf-extract는pom,postscript,type1-encoding-parser,adobe-cmap-parser,euclid,chrono,md5,linked-hash-map등 8 개 이상의 transitive crate 를 새로 끌어옵니다. 본 task 는 lopdf 만으로 작동하므로 (per-page API 가 lopdf 에 있고, sanity check 도 lopdf::load_mem 의 결과로 충분),pdf-extract는 후속 task (스캔 PDF OCR fallback) 에서 필요해질 때 추가하는 게 맞습니다.tempfile(line 28, dev-dep) — 테스트가 disk 를 만들지 않고 모두 in-memory bytes 로 동작하므로 dead.serde_json(line 30, dev-dep) — 이미 라인 14 의 regular dep 가 dev test 에 쓰이므로 dev-dep 중복.serde(line 14) —lib.rs가serde_json만 import 하고serde본체는 한 번도 가져오지 않습니다. derive 매크로는 kebab-core 가 자체적으로 사용 중. 빼도 안전.Why: dead dep 5 개가 transitive 로 ~150 crate 를 들여와 cold build 시간 + target/ 디스크 풋프린트 (workspace 가 이미 6–10 GB 인 상태) 를 부풀립니다. CLAUDE.md 의
-j 1풀-스위트 빌드가 메모리 한계를 친 이력과 맞물려 무시할 수 없는 비용입니다.How to apply: 위 6 개 라인 제거 후
cargo check -p kebab-parse-pdf && cargo test -p kebab-parse-pdf재확인. spec 의 Allowed deps 는 "가질 수 있는 것" 의 상한 선언이므로, 실제로 쓰지 않는 것을 쓰는 것이 spec 위반은 아닙니다.