`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
494 lines
18 KiB
Rust
494 lines
18 KiB
Rust
//! P7-3 PDF ingest wiring — end-to-end integration.
|
|
//!
|
|
//! Each test spins up a `TempDir` workspace + writes one or more PDF
|
|
//! fixtures via the same `lopdf` builder pattern
|
|
//! `kebab-parse-pdf::tests::common` uses, then runs `kebab_app::
|
|
//! ingest_with_config` against it. PDF ingest needs no external HTTP
|
|
//! adapter (no OCR / caption / LM), so unlike the image pipeline these
|
|
//! tests do NOT need wiremock — they run sync, no async runtime.
|
|
|
|
mod common;
|
|
|
|
use std::path::Path;
|
|
|
|
use common::TestEnv;
|
|
use kebab_config::Config;
|
|
use kebab_core::{Block, IngestItemKind, SourceSpan};
|
|
use lopdf::content::{Content, Operation};
|
|
use lopdf::{Document, Object, Stream, dictionary};
|
|
|
|
// ── Fixture helpers ──────────────────────────────────────────────────────
|
|
|
|
/// Build a Helvetica-text PDF mirroring `kebab-parse-pdf::tests::common::
|
|
/// build_text_pdf`. `pages` is one entry per page; `None` means the page
|
|
/// has no `/Contents` stream (the "scanned candidate" shape — extract
|
|
/// returns empty + emits a Provenance Warning).
|
|
fn build_text_pdf(pages: &[Option<&str>]) -> 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",
|
|
});
|
|
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);
|
|
|
|
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. Mirrors
|
|
/// `kebab-parse-pdf::tests::common::make_encrypted_pdf`.
|
|
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
|
|
}
|
|
|
|
fn corrupt_pdf() -> Vec<u8> {
|
|
b"NOT A PDF; just plain bytes".to_vec()
|
|
}
|
|
|
|
fn write_pdf(root: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
|
let path = root.join(name);
|
|
std::fs::write(&path, bytes).expect("write PDF fixture");
|
|
path
|
|
}
|
|
|
|
fn cfg_with_pdf(env: &TestEnv) -> Config {
|
|
let mut cfg = env.config.clone();
|
|
// p9-fb-25: workspace.include removed; extension routing is now
|
|
// handled by extractor matching alone (no config knob).
|
|
// PDF ingest does not need OCR / caption / LM — leave defaults
|
|
// (ocr.enabled=false, caption.enabled=false). The image pipeline
|
|
// construction step skips both adapters.
|
|
cfg.ingest.image.ocr.enabled = false;
|
|
cfg.ingest.image.caption.enabled = false;
|
|
cfg
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────
|
|
|
|
/// 3-page text PDF → 1 doc + 3 chunks, each chunk's `source_spans[0]`
|
|
/// is `Page { page: i, .. }`.
|
|
#[test]
|
|
fn ingest_3_page_pdf_produces_one_doc_and_per_page_chunks() {
|
|
let env = TestEnv::lexical_only();
|
|
let bytes = build_text_pdf(&[
|
|
Some("Hello page 1 body."),
|
|
Some("Hello page 2 body."),
|
|
Some("Hello page 3 body."),
|
|
]);
|
|
write_pdf(&env.workspace_root, "three.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default())
|
|
.expect("PDF ingest must succeed");
|
|
|
|
assert_eq!(report.errors, 0);
|
|
let items = report.items.as_ref().expect("items present");
|
|
let pdf_item = items
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("three.pdf"))
|
|
.expect("PDF item present");
|
|
assert_eq!(pdf_item.kind, IngestItemKind::New);
|
|
assert_eq!(
|
|
pdf_item.block_count,
|
|
Some(3),
|
|
"one Block::Paragraph per page"
|
|
);
|
|
assert_eq!(
|
|
pdf_item.chunk_count,
|
|
Some(3),
|
|
"one chunk per non-empty page"
|
|
);
|
|
assert_eq!(
|
|
pdf_item.parser_version
|
|
.as_ref()
|
|
.map(|p| p.0.split('|').next().unwrap()),
|
|
Some("pdf-text-v2")
|
|
);
|
|
assert_eq!(
|
|
pdf_item.chunker_version.as_ref().map(|c| c.0.as_str()),
|
|
Some("pdf-page-v1.2")
|
|
);
|
|
|
|
// Inspect the stored doc to confirm SourceSpan::Page round-trip.
|
|
let doc = kebab_app::inspect_doc_with_config(cfg, pdf_item.doc_id.as_ref().unwrap())
|
|
.expect("inspect_doc returns the PDF document");
|
|
assert_eq!(doc.blocks.len(), 3);
|
|
for (i, block) in doc.blocks.iter().enumerate() {
|
|
let want_page = (i as u32) + 1;
|
|
let common = match block {
|
|
Block::Paragraph(p) => &p.common,
|
|
other => panic!("expected Paragraph, got {other:?}"),
|
|
};
|
|
match common.source_span {
|
|
SourceSpan::Page { page, .. } => assert_eq!(page, want_page),
|
|
ref other => panic!("expected Page span, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-ingest the SAME PDF bytes → identical doc_id, item kind =
|
|
/// Unchanged. p9-fb-23 task 7 introduced the early-skip path: when
|
|
/// checksum + parser/chunker/embedding versions all match, the second
|
|
/// run reports `Unchanged` rather than `Updated` and skips parse /
|
|
/// chunk / embed entirely. The pre-p9-fb-23 contract was `Updated`;
|
|
/// the `force_reingest=true` path still exercises that branch (see
|
|
/// `incremental_ingest.rs`).
|
|
#[test]
|
|
fn re_ingest_identical_pdf_produces_unchanged_with_same_doc_id() {
|
|
let env = TestEnv::lexical_only();
|
|
let bytes = build_text_pdf(&[Some("page 1"), Some("page 2")]);
|
|
write_pdf(&env.workspace_root, "stable.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report1 = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
let item1 = report1
|
|
.items
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("stable.pdf"))
|
|
.cloned()
|
|
.unwrap();
|
|
assert_eq!(item1.kind, IngestItemKind::New);
|
|
|
|
let report2 = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
let item2 = report2
|
|
.items
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|i| i.doc_path.0.ends_with("stable.pdf"))
|
|
.unwrap();
|
|
assert_eq!(item2.kind, IngestItemKind::Unchanged);
|
|
assert_eq!(item2.doc_id, item1.doc_id);
|
|
}
|
|
|
|
/// Edit a PDF (replace bytes) → different blake3 → different asset_id
|
|
/// → different doc_id → `new+=1` for the new doc_id; stale doc /
|
|
/// asset / chunk / embedding rows for the prior bytes are swept by
|
|
/// `purge_orphan_at_workspace_path` (HOTFIXES 2026-05-02 P7-3 storage
|
|
/// fix shipped alongside this test).
|
|
#[test]
|
|
fn re_ingest_edited_pdf_produces_new_doc_id() {
|
|
let env = TestEnv::lexical_only();
|
|
let path = env.workspace_root.join("evolving.pdf");
|
|
let bytes_v1 = build_text_pdf(&[Some("version one body")]);
|
|
std::fs::write(&path, &bytes_v1).unwrap();
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report_v1 = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
let id_v1 = report_v1
|
|
.items
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("evolving.pdf"))
|
|
.unwrap()
|
|
.doc_id
|
|
.clone()
|
|
.unwrap();
|
|
|
|
let bytes_v2 = build_text_pdf(&[Some("VERSION TWO entirely different body content.")]);
|
|
std::fs::write(&path, &bytes_v2).unwrap();
|
|
|
|
let report_v2 = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
let item_v2 = report_v2
|
|
.items
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("evolving.pdf"))
|
|
.unwrap();
|
|
assert_eq!(
|
|
item_v2.kind,
|
|
IngestItemKind::New,
|
|
"edited PDF gets a new asset_id → new doc_id → counted as New"
|
|
);
|
|
assert_ne!(item_v2.doc_id.as_ref().unwrap().0, id_v1.0);
|
|
}
|
|
|
|
/// Encrypted PDF → asset NOT stored; errors+=1; IngestItem.error
|
|
/// preserves the qpdf decrypt hint from kebab-parse-pdf verbatim.
|
|
#[test]
|
|
fn encrypted_pdf_fails_with_qpdf_hint() {
|
|
let env = TestEnv::lexical_only();
|
|
let bytes = make_encrypted_pdf();
|
|
write_pdf(&env.workspace_root, "secret.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg, env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
assert_eq!(
|
|
report.errors, 1,
|
|
"encrypted PDF must increment errors exactly once"
|
|
);
|
|
let items = report.items.as_ref().unwrap();
|
|
let pdf_item = items
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("secret.pdf"))
|
|
.expect("encrypted PDF item present");
|
|
assert_eq!(pdf_item.kind, IngestItemKind::Error);
|
|
let err = pdf_item.error.as_ref().expect("error field set");
|
|
assert!(
|
|
err.contains("encrypted"),
|
|
"error mentions encryption: {err}"
|
|
);
|
|
assert!(
|
|
err.contains("qpdf") || err.contains("decrypt"),
|
|
"error preserves remediation hint: {err}"
|
|
);
|
|
}
|
|
|
|
/// Corrupt header PDF → asset NOT stored; errors+=1.
|
|
#[test]
|
|
fn corrupt_pdf_fails_without_storing() {
|
|
let env = TestEnv::lexical_only();
|
|
let bytes = corrupt_pdf();
|
|
write_pdf(&env.workspace_root, "corrupt.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
assert_eq!(
|
|
report.errors, 1,
|
|
"corrupt PDF must increment errors exactly once"
|
|
);
|
|
let items = report.items.as_ref().unwrap();
|
|
let pdf_item = items
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("corrupt.pdf"))
|
|
.unwrap();
|
|
assert_eq!(pdf_item.kind, IngestItemKind::Error);
|
|
|
|
// Confirm the doc was NOT stored — list_docs returns nothing for
|
|
// this path.
|
|
let summaries =
|
|
kebab_app::list_docs_with_config(cfg, kebab_core::DocFilter::default()).unwrap();
|
|
assert!(
|
|
!summaries
|
|
.iter()
|
|
.any(|s| s.doc_path.0.ends_with("corrupt.pdf")),
|
|
"corrupt PDF must not have a stored doc row"
|
|
);
|
|
}
|
|
|
|
/// Mixed page PDF (text page 1, empty page 2, text page 3) → asset
|
|
/// stored; 2 chunks (pages 1 + 3); doc.provenance.events contains the
|
|
/// page-2 Warning emitted by kebab-parse-pdf.
|
|
#[test]
|
|
fn mixed_page_pdf_stores_asset_with_scanned_candidate_warning() {
|
|
let env = TestEnv::lexical_only();
|
|
let bytes = build_text_pdf(&[Some("first page"), None, Some("third page")]);
|
|
write_pdf(&env.workspace_root, "mixed.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
assert_eq!(
|
|
report.errors, 0,
|
|
"scanned candidate is a Warning, not Error"
|
|
);
|
|
let pdf_item = report
|
|
.items
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("mixed.pdf"))
|
|
.unwrap();
|
|
assert_eq!(pdf_item.kind, IngestItemKind::New);
|
|
assert_eq!(
|
|
pdf_item.block_count,
|
|
Some(3),
|
|
"still 3 blocks (P7-1 emits empty Block::Paragraph for the empty page)"
|
|
);
|
|
assert_eq!(
|
|
pdf_item.chunk_count,
|
|
Some(2),
|
|
"pdf-page-v1.2 emits 0 chunks for the empty page; total = 2"
|
|
);
|
|
|
|
let doc = kebab_app::inspect_doc_with_config(cfg, pdf_item.doc_id.as_ref().unwrap()).unwrap();
|
|
let warnings: Vec<_> = doc
|
|
.provenance
|
|
.events
|
|
.iter()
|
|
.filter(|e| e.kind == kebab_core::ProvenanceKind::Warning)
|
|
.collect();
|
|
assert_eq!(
|
|
warnings.len(),
|
|
1,
|
|
"exactly one Warning event for the empty page"
|
|
);
|
|
let note = warnings[0].note.as_deref().unwrap_or("");
|
|
assert!(
|
|
note.contains("page2") && note.contains("scanned candidate"),
|
|
"Warning note marks page 2 as scanned candidate: {note}"
|
|
);
|
|
|
|
// R1: Warning notes also surface on `IngestItem.warnings` so
|
|
// operators can see the partial-success signal in the ingest
|
|
// summary without `kebab inspect doc`.
|
|
assert_eq!(
|
|
pdf_item.warnings.len(),
|
|
1,
|
|
"exactly one warning surfaced on IngestItem"
|
|
);
|
|
assert!(
|
|
pdf_item.warnings[0].contains("page2")
|
|
&& pdf_item.warnings[0].contains("scanned candidate"),
|
|
"IngestItem.warnings preserves the Provenance Warning note: {:?}",
|
|
pdf_item.warnings
|
|
);
|
|
}
|
|
|
|
/// IngestReport invariant `scanned == new + updated + skipped + errors`
|
|
/// when ingesting a mixed corpus including a corrupt PDF.
|
|
#[test]
|
|
fn ingest_report_arithmetic_invariant_holds_with_corrupt_pdf() {
|
|
let env = TestEnv::lexical_only();
|
|
write_pdf(
|
|
&env.workspace_root,
|
|
"good.pdf",
|
|
&build_text_pdf(&[Some("ok body")]),
|
|
);
|
|
write_pdf(&env.workspace_root, "broken.pdf", &corrupt_pdf());
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg, env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
let total = report.new + report.updated + report.skipped + report.errors;
|
|
assert_eq!(
|
|
report.scanned, total,
|
|
"invariant: scanned ({}) == new ({}) + updated ({}) + skipped ({}) + errors ({})",
|
|
report.scanned, report.new, report.updated, report.skipped, report.errors
|
|
);
|
|
// Sanity: 1 good (new) + 1 broken (error) = 2 scanned for our PDFs;
|
|
// markdown fixtures already in the workspace add to scanned/new
|
|
// alike, so we only assert the invariant rather than absolute counts.
|
|
}
|
|
|
|
/// 50-page PDF → ≥50 chunks (≥1 per page); ingest completes; storage
|
|
/// round-trips. Vector embedding is disabled in the lexical-only env
|
|
/// so this exercises the SQLite write path only.
|
|
#[test]
|
|
fn long_pdf_round_trips_through_lexical_pipeline() {
|
|
let env = TestEnv::lexical_only();
|
|
let pages: Vec<String> = (1..=50)
|
|
.map(|i| format!("Page {i} body — lorem ipsum dolor sit amet."))
|
|
.collect();
|
|
let page_refs: Vec<Option<&str>> = pages.iter().map(|s| Some(s.as_str())).collect();
|
|
let bytes = build_text_pdf(&page_refs);
|
|
write_pdf(&env.workspace_root, "long.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
assert_eq!(report.errors, 0);
|
|
let pdf_item = report
|
|
.items
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("long.pdf"))
|
|
.unwrap();
|
|
assert_eq!(pdf_item.block_count, Some(50));
|
|
assert!(
|
|
pdf_item.chunk_count.unwrap() >= 50,
|
|
"chunk_count={:?} should be ≥50",
|
|
pdf_item.chunk_count
|
|
);
|
|
|
|
// Round-trip: list_docs sees the long PDF.
|
|
let summaries =
|
|
kebab_app::list_docs_with_config(cfg, kebab_core::DocFilter::default()).unwrap();
|
|
assert!(summaries.iter().any(|s| s.doc_path.0.ends_with("long.pdf")));
|
|
}
|
|
|
|
/// `kebab inspect doc <pdf_doc_id>` returns the PDF CanonicalDocument
|
|
/// with per-page Block::Paragraph + SourceSpan::Page intact.
|
|
#[test]
|
|
fn inspect_doc_surfaces_page_spans() {
|
|
let env = TestEnv::lexical_only();
|
|
let bytes = build_text_pdf(&[Some("alpha body"), Some("beta body"), Some("gamma body")]);
|
|
write_pdf(&env.workspace_root, "inspect.pdf", &bytes);
|
|
let cfg = cfg_with_pdf(&env);
|
|
|
|
let report = kebab_app::ingest_with_config(cfg.clone(), env.scope(), kebab_app::IngestOpts::default()).unwrap();
|
|
let pdf_item = report
|
|
.items
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("inspect.pdf"))
|
|
.unwrap();
|
|
let doc = kebab_app::inspect_doc_with_config(cfg, pdf_item.doc_id.as_ref().unwrap()).unwrap();
|
|
// v0.26.2: stored parser_version is now `pdf-text-v2|<ingest-config-sig>`
|
|
// (the signature folds chunking / pdf.ocr settings for skip detection).
|
|
// Assert the base identity by taking the prefix before the first '|'.
|
|
assert_eq!(doc.parser_version.0.split('|').next().unwrap(), "pdf-text-v2");
|
|
assert_eq!(doc.blocks.len(), 3);
|
|
for block in &doc.blocks {
|
|
match block {
|
|
Block::Paragraph(p) => assert!(matches!(p.common.source_span, SourceSpan::Page { .. })),
|
|
other => panic!("expected Paragraph, got {other:?}"),
|
|
}
|
|
}
|
|
}
|