리뷰가 이 PR 의 핵심을 무너뜨리는 결함을 잡았다.
1) render_dpi 가 아무 일도 안 하고 있었다 (HIGH)
`set_maximum_*` 만 걸었는데, pdfium-render 에서 maximum 은 초과할 때만
줄이는 클램프이고 스케일이 아니다. 타깃도 배율도 없으면 스케일 1.0 —
1 pt → 1 px, 즉 **72 DPI** 로 렌더된다. 300 을 주든 1200 을 주든 산출물이
같았다. 렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다.
실측 (govdocs1-000157-ccitt.pdf 5쪽):
maximum_* 만 (초안) 621×801 px 72 DPI
target + maximum_* (수정) 1588×2048 px 184 DPI
같은 뿌리로 종횡비도 깨져 있었다. 클램프만 걸리는 경로는
`do_maintain_aspect_ratio = false` 라 가로·세로가 독립적으로 잘린다.
600×800pt 페이지를 600px 예산으로 렌더하면 600×600 으로 세로가 25%
눌린 채 나왔고, 긴 변만 보던 테스트는 초록불이었다.
`render_dpi_changes_the_rendered_size` 와
`the_pixel_budget_is_respected_without_distorting_the_page` 로 고정했다.
`set_target_width` 한 줄을 되돌리면 둘 다 실패하는 것을 확인했다.
덧붙여 render_dpi 는 **요청**이고 max_pixels 가 이긴다. PDF 기본값
2048 이면 A4 는 175 DPI 언저리에서 잘린다. 기본값 300 이 그대로 나오지
않는다는 뜻이라 config·README·SMOKE 문구를 실제와 맞췄다.
2) /MediaBox 를 직접 파싱하고 있었다 (MEDIUM)
`/MediaBox` 는 상속 속성이고 대부분의 생산자가 `/Pages` 노드에 한 번만
쓴다. lopdf 0.32 에는 상속 해석 헬퍼가 없어서 그런 PDF 는 전부 조용히
A4 폴백을 탔다. `/UserUnit` 도 미반영이었다.
pdfium 이 이미 페이지 크기를 안다. 거기서 받으니 40여 줄이 사라지고
상속·UserUnit 문제가 함께 없어졌으며, kebab-app 이 lopdf 딕셔너리를
뒤지던 레이어링도 정리됐다.
3) 렌더러가 있으면 오히려 손해 보는 경우가 있었다 (MEDIUM)
페이지 하나만 렌더에 실패하면 곧장 skip 이었고 DCTDecode 경로를 시도하지
않았다. "렌더러 우선 + 폴백" 이 렌더러 유무 수준에서만 성립했던 것이다.
페이지 단위 폴백을 넣었다.
4) 렌더러를 설정한 사용자에게 틀린 지시가 나갔다 (MEDIUM)
pdfium 이 PDF 자체를 못 열면 모든 페이지가 no_renderer 로 보고되면서
"render_library 를 지정하라" 고 안내했다. `unopenable_pdf` 로 갈랐다.
5) ⊘ 줄 수와 ocr-skipped 카운트가 안 맞았다 (MEDIUM)
카운트는 래스터 실패만 세는데 OCR 엔진 실패도 화면에는 똑같이 ⊘ 로
찍혔다. 사유를 라벨에 적어 둘을 구분한다 — 이 구분이 바로 아래 도그푸딩
에서 실제로 값을 했다.
6) 잔가지 (LOW)
docs 의 pdf-text-v1 잔재 3곳, doctor hint 의 줄 이음이 무너져 생긴 여백.
정답 있는 한국어 스캔으로 인식률을 쟀다 (CCITT 3건, qwen2.5vl:3b):
namu-beulenda… 8쪽 CER 15.65%
namu-bihaengdae 8쪽 CER 12.55%
namu-gu-anoli 6쪽 CER 15.08%
전 페이지 OCR 성공, 건너뜀 0. 수정 전에는 세 문서 모두 본문 0 자였다.
엔진 선택이 결과를 가른다는 것도 알게 됐다. 처음에는 이 머신에 있던
gemma3:4b 로 쟀는데 래스터는 정상인데 출력이 원문과 무관한 환각이었고,
해상도가 올라가자 밀집 한국어 페이지에서 180초 타임아웃이 났다. 범용
멀티모달 모델은 OCR 엔진이 아니다 — 이때 5번의 새 라벨이 "래스터 없음"이
아니라 "OCR 엔진 실패"로 찍어 줘서 원인이 바로 갈렸다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
177 lines
6.9 KiB
Rust
177 lines
6.9 KiB
Rust
//! Issue #232: every scanned page must produce a raster, whatever its
|
|
//! images are encoded with.
|
|
//!
|
|
//! These tests need a `libpdfium` and are `#[ignore]`d for the same
|
|
//! reason `kebab-store-vector`'s AVX suite is: the capability is
|
|
//! genuinely optional, and a lane without it must not report a failure
|
|
//! it cannot act on. Opt in with
|
|
//!
|
|
//! ```text
|
|
//! KEBAB_TEST_PDFIUM=/path/to/libpdfium.so \
|
|
//! cargo test -p kebab-parse-pdf --test page_render -- --ignored
|
|
//! ```
|
|
//!
|
|
//! The fallback path — what a machine *without* pdfium does — is covered
|
|
//! by `page_image.rs` and needs no library, so the behaviour that ships
|
|
//! to a bare install is tested unconditionally.
|
|
|
|
use kebab_parse_pdf::PageRenderer;
|
|
|
|
/// Bind to the library named by `KEBAB_TEST_PDFIUM`, or the system one.
|
|
///
|
|
/// Bound once for the whole binary, which is both what production does
|
|
/// (one renderer per ingest run) and what pdfium requires — binding it
|
|
/// from several threads at once fails, and cargo runs tests in parallel.
|
|
///
|
|
/// Panics rather than skipping: an `--ignored` run that silently passes
|
|
/// without exercising the renderer is worse than no test.
|
|
fn renderer() -> &'static PageRenderer {
|
|
static SHARED: std::sync::OnceLock<PageRenderer> = std::sync::OnceLock::new();
|
|
SHARED.get_or_init(|| {
|
|
let explicit = std::env::var("KEBAB_TEST_PDFIUM").ok();
|
|
let path = explicit.as_deref().map(std::path::Path::new);
|
|
PageRenderer::bind(path).expect(
|
|
"these tests require libpdfium; point KEBAB_TEST_PDFIUM at one or \
|
|
install it where the loader finds it",
|
|
)
|
|
})
|
|
}
|
|
|
|
/// PNG signature, so a test can tell "we got an image" from "we got
|
|
/// bytes".
|
|
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
|
|
|
|
/// The whole point of the change: a page whose image is **not** a single
|
|
/// DCTDecode JPEG still rasterizes. `ccitt.pdf` is the fixture the
|
|
/// DCTDecode path returns `None` for (see `page_image.rs`), which is
|
|
/// exactly the silent content loss issue #232 reported.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn a_ccitt_page_rasterizes_even_though_dctdecode_extraction_cannot() {
|
|
let bytes = include_bytes!("fixtures/ccitt.pdf");
|
|
|
|
// Precondition: this fixture is one the old path gives up on.
|
|
let doc = lopdf::Document::load_mem(bytes).unwrap();
|
|
assert!(
|
|
kebab_parse_pdf::extract_dctdecode_page_image(&doc, 1)
|
|
.unwrap()
|
|
.is_none(),
|
|
"fixture no longer exercises the gap — pick one the DCTDecode path drops"
|
|
);
|
|
|
|
let r = renderer();
|
|
let pdf = r.open(bytes, None).expect("open ccitt.pdf");
|
|
let png = pdf.render_page_png(1, 300, 1_000).expect("render page 1");
|
|
assert!(png.starts_with(PNG_MAGIC), "rendered bytes are not a PNG");
|
|
assert!(
|
|
png.len() > 1_000,
|
|
"suspiciously small render: {} B",
|
|
png.len()
|
|
);
|
|
}
|
|
|
|
/// FlateDecode raw pixels — the other encoding `page_image.rs` pins as
|
|
/// unreadable.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn a_flate_page_rasterizes_too() {
|
|
let bytes = include_bytes!("fixtures/flate_raw.pdf");
|
|
let r = renderer();
|
|
let pdf = r.open(bytes, None).expect("open flate_raw.pdf");
|
|
let png = pdf.render_page_png(1, 300, 1_000).expect("render page 1");
|
|
assert!(png.starts_with(PNG_MAGIC));
|
|
}
|
|
|
|
/// The DCTDecode case must not regress: rendering has to cover what the
|
|
/// passthrough already covered, or the change trades one gap for another.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn the_dctdecode_case_still_works_through_the_renderer() {
|
|
let bytes = include_bytes!("fixtures/scanned_page1.pdf");
|
|
let r = renderer();
|
|
let pdf = r.open(bytes, None).expect("open scanned_page1.pdf");
|
|
let png = pdf.render_page_png(1, 300, 1_000).expect("render page 1");
|
|
assert!(png.starts_with(PNG_MAGIC));
|
|
}
|
|
|
|
/// The pixel budget is a ceiling, not a suggestion — an OCR engine that
|
|
/// refuses oversized input would otherwise turn a render into a failure.
|
|
/// And the page must not be squashed to fit it: pdfium's clamp-only path
|
|
/// applies width and height independently and silently changes the
|
|
/// aspect ratio, which is not something OCR recovers from.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn the_pixel_budget_is_respected_without_distorting_the_page() {
|
|
let r = renderer();
|
|
for fixture in [
|
|
&include_bytes!("fixtures/scanned_page1.pdf")[..],
|
|
&include_bytes!("fixtures/ccitt.pdf")[..],
|
|
] {
|
|
let pdf = r.open(fixture, None).expect("open");
|
|
let png = pdf.render_page_png(1, 600, 600).expect("render");
|
|
let img = image::load_from_memory(&png).expect("decode render");
|
|
assert!(
|
|
img.width().max(img.height()) <= 600,
|
|
"long edge {} exceeds the 600px budget",
|
|
img.width().max(img.height())
|
|
);
|
|
// Both fixtures are portrait, so a render that kept the shape is
|
|
// taller than it is wide. A square output means the clamp ran
|
|
// without a scale and each side was cut to the cap on its own.
|
|
assert!(
|
|
img.height() > img.width(),
|
|
"portrait page came back {}x{} — aspect ratio was not preserved",
|
|
img.width(),
|
|
img.height()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `render_dpi` has to actually change the render. Clamping alone leaves
|
|
/// pdfium at 1pt-to-1px (72 DPI) no matter what is asked for, and the
|
|
/// knob reads as working because a render still comes back.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn render_dpi_changes_the_rendered_size() {
|
|
let bytes = include_bytes!("fixtures/scanned_page1.pdf");
|
|
let r = renderer();
|
|
let pdf = r.open(bytes, None).expect("open");
|
|
|
|
let size_at = |dpi: u32| {
|
|
let png = pdf.render_page_png(1, dpi, 10_000).expect("render");
|
|
let img = image::load_from_memory(&png).expect("decode");
|
|
img.width().max(img.height())
|
|
};
|
|
|
|
let at_72 = size_at(72);
|
|
let at_300 = size_at(300);
|
|
// A4-ish page at 72 DPI is its point size; at 300 it is ~4.17x that.
|
|
assert!(
|
|
at_300 > at_72 * 3,
|
|
"300 DPI produced {at_300}px against 72 DPI's {at_72}px — \
|
|
the dpi argument is not reaching the renderer"
|
|
);
|
|
}
|
|
|
|
/// A page number past the end is a caller error, not a panic. The OCR
|
|
/// loop walks pages from lopdf's count, and the two libraries disagreeing
|
|
/// about page count must degrade to a skip.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn a_page_past_the_end_errors_rather_than_panicking() {
|
|
let bytes = include_bytes!("fixtures/scanned_page1.pdf");
|
|
let r = renderer();
|
|
let pdf = r.open(bytes, None).expect("open");
|
|
assert!(pdf.render_page_png(9_999, 300, 600).is_err());
|
|
}
|
|
|
|
/// Bytes that are not a PDF must come back as an error from `open`, so
|
|
/// the caller falls through to the DCTDecode path instead of aborting
|
|
/// the ingest.
|
|
#[test]
|
|
#[ignore = "requires libpdfium"]
|
|
fn garbage_input_is_an_error_not_a_crash() {
|
|
let r = renderer();
|
|
assert!(r.open(b"this is not a pdf at all", None).is_err());
|
|
}
|