chore: PR #238 회차 1 리뷰 반영 — render_dpi 가 동작하지 않았다

리뷰가 이 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
This commit is contained in:
2026-08-17 02:45:49 +09:00
parent 2871da14f4
commit e76e909f56
14 changed files with 202 additions and 103 deletions

View File

@@ -172,7 +172,7 @@ nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedn
- **`[ingest.image.ocr]`** — 이미지 OCR. on/off 토글(`enabled`, default off / opt-in)은 미디어별이며, 엔진 설정은 `[ingest.ocr]` 에서 상속하되 이 블록에서 override 할 수 있다. `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능. engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
- **`[ingest.pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). on/off 토글(`enabled`/`always_on`)과 PDF 고유 키(`valid_ratio_threshold`/`min_char_count`/`lang_hint`)는 미디어별이고, 엔진 설정은 `[ingest.ocr]` 에서 상속하되 이 블록에서 override 한다(PDF 기본 모델은 `qwen2.5vl:3b`, 이미지의 `gemma4:e4b` 와 다름 — 미디어별 기본값 보존). 활성화 후 옛 색인분은 `kebab ingest --force-reingest` 로 재처리.
**스캔본을 제대로 읽으려면 페이지 렌더러가 필요하다.** `render_library``libpdfium` 경로를 적거나 로더가 찾는 곳에 두면, 페이지 이미지가 어떤 인코딩이든(CCITTFax·JBIG2·Flate·JPX, 배경+마스크 분리 구조 포함) OCR 된다. 없으면 **단일 DCTDecode(JPEG) 이미지 페이지만** OCR 되고 나머지는 본문 없이 색인되며, 그 건수가 ingest 요약의 `ocr-skipped``--json``ocr_skipped_pages` 에 찍힌다. `kebab doctor``pdf_render` 가 지금 어느 쪽인지 알려준다. `render_dpi` 는 렌더 해상도(기본 300)이고 `max_pixels`상한이다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다.
**스캔본을 제대로 읽으려면 페이지 렌더러가 필요하다.** `render_library``libpdfium` 경로를 적거나 로더가 찾는 곳에 두면, 페이지 이미지가 어떤 인코딩이든(CCITTFax·JBIG2·Flate·JPX, 배경+마스크 분리 구조 포함) OCR 된다. 없으면 **단일 DCTDecode(JPEG) 이미지 페이지만** OCR 되고 나머지는 본문 없이 색인되며, 그 건수가 ingest 요약의 `ocr-skipped``--json``ocr_skipped_pages` 에 찍힌다. `kebab doctor``pdf_render` 가 지금 어느 쪽인지 알려준다. `render_dpi` 는 렌더 해상도 **요청**(기본 300)이고 실제로는 `max_pixels` 가 이긴다 — PDF 기본값 `max_pixels = 2048` 이면 A4 는 175 DPI 언저리에서 잘린다. 300 을 실제로 쓰려면 `max_pixels` 를 3500 이상으로 올려야 하고, 그만큼 큰 이미지를 OCR 엔진이 받는다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다.
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI honor).
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
- **`KEBAB_*` env** — 런타임 override용 ~22개 키만 노출. 엔드포인트(`KEBAB_MODELS_LLM_ENDPOINT`, `KEBAB_MODELS_EMBEDDING_ENDPOINT`, `KEBAB_OCR_ENDPOINT`), 모델명/프로바이더(`KEBAB_MODELS_LLM_MODEL`, `KEBAB_MODELS_EMBEDDING_MODEL`, `KEBAB_MODELS_EMBEDDING_PROVIDER`, `KEBAB_MODELS_LLM_PROVIDER`, `KEBAB_MODELS_NLI_MODEL`), 경로(`KEBAB_WORKSPACE_ROOT`, `KEBAB_STORAGE_DATA_DIR`), 병렬도(`KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS`, `KEBAB_INDEXING_MAX_PARALLEL_EMBEDDINGS`), 청킹(`KEBAB_CHUNKING_TARGET_TOKENS`, `KEBAB_CHUNKING_OVERLAP_TOKENS`), OCR 토글/엔진/언어(`KEBAB_IMAGE_OCR_ENABLED`, `KEBAB_PDF_OCR_ENABLED`, `KEBAB_OCR_ENGINE`, `KEBAB_OCR_MODEL`, `KEBAB_OCR_LANGUAGES`), 기타(`KEBAB_IMAGE_CAPTION_ENABLED`, `KEBAB_SEARCH_DEFAULT_K`, `KEBAB_RAG_PROMPT_TEMPLATE_VERSION`). 나머지 세부 튜닝 키(score_gate, rrf_k, temperature 등)는 `config.toml` 전용. 특수: `KEBAB_READONLY=1`(write-path 비활성), `KEBAB_PROGRESS=plain`(non-TTY 진행 출력), `KEBAB_EVAL_GOLDEN`(eval golden set 경로).

View File

@@ -476,7 +476,7 @@ pub fn doctor_with_config_path(
Err(e) => (
"페이지 렌더러 없음 — 단일 DCTDecode 이미지 페이지만 OCR 된다".to_string(),
Some(format!(
"CCITTFax / JBIG2 / Flate / JPX 스캔은 본문 없이 색인된다. libpdfium 을 로더 경로에 두거나 `[ingest.pdf.ocr] render_library` 로 지정하라 ({e})"
"CCITTFax / JBIG2 / Flate / JPX 스캔은 본문 없이 색인된다. libpdfium 을 로더 경로에 두거나 `[ingest.pdf.ocr] render_library` 로 지정하라 ({e})"
)),
),
};

View File

@@ -112,6 +112,11 @@ enum RasterFailure {
NoRenderer(Vec<String>),
/// A renderer was configured and rasterizing this page failed.
Render(String),
/// A renderer was configured but could not open this PDF at all, so
/// no page of it can be rendered. Distinct from `NoRenderer` because
/// telling a user who already configured a renderer to configure one
/// is the wrong instruction.
Unopenable,
}
impl RasterFailure {
@@ -121,6 +126,7 @@ impl RasterFailure {
match self {
Self::NoRenderer(_) => "no_renderer",
Self::Render(_) => "render_error",
Self::Unopenable => "unopenable_pdf",
}
}
@@ -139,6 +145,11 @@ impl RasterFailure {
)
}
Self::Render(e) => format!("page renderer failed: {e}"),
Self::Unopenable => {
"the page renderer could not open this PDF, so no page of it could be \
rasterized; the file may be malformed or encrypted"
.to_string()
}
}
}
}
@@ -203,55 +214,6 @@ fn page_filters(doc: &LopdfDocument, page_num: u32) -> Vec<String> {
names
}
/// A page's longer side in PDF points, for the DPI calculation.
///
/// Falls back to A4 when the page has no usable `/MediaBox` — a wrong
/// guess costs a differently-sized render, while returning zero would
/// ask pdfium for an empty bitmap.
fn page_long_edge_pt(doc: &LopdfDocument, page_num: u32) -> f32 {
use lopdf::Object;
const A4_LONG_EDGE_PT: f32 = 841.89;
let Some(&page_oid) = doc.get_pages().get(&page_num) else {
return A4_LONG_EDGE_PT;
};
let Ok(page) = doc.get_dictionary(page_oid) else {
return A4_LONG_EDGE_PT;
};
let media = match page.get(b"MediaBox").ok() {
Some(Object::Array(a)) => a.clone(),
Some(Object::Reference(r)) => match doc.get_object(*r) {
Ok(Object::Array(a)) => a.clone(),
_ => return A4_LONG_EDGE_PT,
},
_ => return A4_LONG_EDGE_PT,
};
if media.len() != 4 {
return A4_LONG_EDGE_PT;
}
let num = |o: &Object| -> Option<f32> {
match o {
Object::Integer(i) => Some(*i as f32),
Object::Real(f) => Some(*f),
_ => None,
}
};
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (
num(&media[0]),
num(&media[1]),
num(&media[2]),
num(&media[3]),
) else {
return A4_LONG_EDGE_PT;
};
let long = (x1 - x0).abs().max((y1 - y0).abs());
if long.is_finite() && long >= 1.0 {
long
} else {
A4_LONG_EDGE_PT
}
}
/// Post-extract OCR enrichment for PDF. Walks `canonical.blocks` page-by-page,
/// classifies each page via `text_quality::compute_valid_char_ratio` +
/// `min_char_count`, and either:
@@ -294,6 +256,7 @@ where
// failure here is not fatal: the DCTDecode path still reads the pages
// that are a single JPEG, and reporting per page is what tells the
// user which pages lost content and why.
let renderer_configured = opts.renderer.is_some();
let rendered = opts
.renderer
.as_ref()
@@ -346,21 +309,31 @@ where
emit_progress(PdfOcrProgress::Started { page: page_num });
let rasterized = match rendered.as_ref() {
Some(doc) => {
let long_edge = kebab_parse_pdf::long_edge_for_dpi(
page_long_edge_pt(&pdf_doc, page_num),
opts.render_dpi,
opts.max_pixels,
);
match doc.render_page_png(page_num, long_edge) {
Ok(png) => Ok(png),
Err(e) => Err(RasterFailure::Render(e.to_string())),
}
}
// No renderer: read back an embedded JPEG, which only exists
// when the page is exactly one DCTDecode image.
None => match extract_dctdecode_page_image(&pdf_doc, page_num)? {
// Read back an embedded JPEG. Only works when the page is exactly
// one DCTDecode image, which is why rendering exists — but it is
// still the right thing to try when rendering is unavailable or
// fails for this particular page.
let dct = |doc: &LopdfDocument| extract_dctdecode_page_image(doc, page_num);
let rasterized = match (rendered.as_ref(), renderer_configured) {
(Some(doc), _) => match doc.render_page_png(page_num, opts.render_dpi, opts.max_pixels)
{
Ok(png) => Ok(png),
// Per-page fallback: one page failing to render must not
// cost content that the DCTDecode path could still read.
// Configuring a renderer should never make a page worse
// off than not having one.
Err(e) => match dct(&pdf_doc)? {
Some(b) => Ok(b),
None => Err(RasterFailure::Render(e.to_string())),
},
},
// A renderer is configured but could not open this PDF.
(None, true) => match dct(&pdf_doc)? {
Some(b) => Ok(b),
None => Err(RasterFailure::Unopenable),
},
(None, false) => match dct(&pdf_doc)? {
Some(b) => Ok(b),
None => Err(RasterFailure::NoRenderer(page_filters(&pdf_doc, page_num))),
},

View File

@@ -475,19 +475,25 @@ impl ProgressDisplay {
// one the OCR engine failed on. Collapsing both
// into "no DCTDecode or engine fail" told the user
// neither.
// Two different failures wear the same ⊘ here, and
// only one of them is counted in the run summary's
// `ocr-skipped`. Say which, so the line and the
// count can be reconciled: the page never produced
// an image to read, or the engine failed to read
// one that it did get.
let why = match failure_reason.as_deref() {
Some("no_renderer") => {
" — 이 페이지의 인코딩은 페이지 렌더러 없이 읽을 수 없다"
"래스터 없음 — 이 페이지의 인코딩은 페이지 렌더러 없이 읽을 수 없다"
}
Some("render_error") => " — 페이지 렌더링 실패",
Some(other) => {
let _ =
writeln!(err, " ⊘ OCR page {page} 건너뜀 — {other} ({ms}ms)");
return Ok(());
Some("render_error") => "래스터 없음 — 페이지 렌더링 실패",
Some("unopenable_pdf") => {
"래스터 없음 — 렌더러가 이 PDF 를 열지 못했다"
}
None => "",
Some("ocr_error") => "OCR 엔진 실패",
Some(other) => other,
None => "사유 미상",
};
let _ = writeln!(err, " ⊘ OCR page {page} 건너뜀{why} ({ms}ms)");
let _ = writeln!(err, " ⊘ OCR page {page} 건너뜀{why} ({ms}ms)");
} else {
let _ = writeln!(
err,

View File

@@ -831,9 +831,15 @@ pub struct PdfOcrCfg {
/// single-binary property. `kebab doctor` reports which mode is live.
#[serde(default)]
pub render_library: Option<String>,
/// Rendering resolution in DPI. Default `300` — the scanning
/// convention, and what OCR engines are tuned for. Bounded above by
/// `max_pixels`, which is the engine's real limit.
/// Requested rendering resolution in DPI. Default `300` — the
/// scanning convention, and what OCR engines are tuned for.
///
/// **`max_pixels` wins.** It is the ceiling the OCR engine will
/// accept on either side, so the effective resolution is whatever
/// fits: an A4 page at the PDF default `max_pixels = 2048` tops out
/// near 175 DPI no matter what is asked for here. Raise `max_pixels`
/// to actually reach 300 (A4 needs ~3500), at the cost of a larger
/// image for the engine to chew on.
#[serde(default = "default_pdf_render_dpi")]
pub render_dpi: u32,
}

View File

@@ -126,7 +126,7 @@ fn key_comment(path: &str) -> Option<&'static str> {
"ingest.pdf.ocr.model" => "ollama-vision 전용. paddle-onnx 는 번들 모델 사용.",
"ingest.pdf.ocr.valid_ratio_threshold" => "유효문자 비율 < 이면 scanned 판정.",
"ingest.pdf.ocr.min_char_count" => "page 문자수 < 이면 auto-scanned.",
"ingest.pdf.ocr.render_dpi" => "스캔 page 렌더 해상도(DPI). max_pixels 가 상한.",
"ingest.pdf.ocr.render_dpi" => "스캔 page 렌더 해상도 요청(DPI). max_pixels 가 이기므로 실효 DPI 는 그쪽에 달렸다.",
"ingest.pdf.ocr.render_library" => {
"libpdfium 경로. 지정하면 CCITTFax/JBIG2/Flate/JPX 스캔도 OCR. 비우면 로더 경로 탐색."
}

View File

@@ -60,6 +60,11 @@ pub struct IngestReport {
/// and nothing else: the run reported success, and the absence only
/// showed up later as a search that found nothing. Additive field —
/// older wire consumers read it as 0 via `#[serde(default)]`.
///
/// Counts pages with no image to read, not pages the OCR engine
/// failed on — those are engine errors, already tracked separately,
/// and lumping them together would hide which of the two a run hit.
/// The progress line names the cause for each.
#[serde(default)]
pub ocr_skipped_pages: u32,
/// `None` ↔ wire `items: null` (`--summary-only`).

View File

@@ -128,10 +128,13 @@ impl RenderedPdf<'_> {
/// that costs recognition accuracy. The bytes go straight to an OCR
/// engine and are never stored, so the size difference is transient.
///
/// `long_edge_px` caps the longer side. It is the same budget the OCR
/// config already spends on images (`max_pixels`), so a page cannot
/// blow past what the engine is willing to take.
pub fn render_page_png(&self, page_num: u32, long_edge_px: u32) -> Result<Vec<u8>> {
/// `dpi` is the requested resolution; `max_px` is the ceiling the OCR
/// engine will accept on either side, and it wins. The page's own
/// size comes from pdfium, which already parsed it — asking the PDF
/// dictionary ourselves would mean reimplementing `/MediaBox`
/// inheritance and `/UserUnit`, and getting it wrong silently
/// produces a differently-sized render.
pub fn render_page_png(&self, page_num: u32, dpi: u32, max_px: u32) -> Result<Vec<u8>> {
let index = i32::try_from(page_num.saturating_sub(1))
.with_context(|| format!("page {page_num} out of pdfium's index range"))?;
let page = self
@@ -140,14 +143,24 @@ impl RenderedPdf<'_> {
.get(index)
.with_context(|| format!("pdfium: get page {page_num}"))?;
// Bound both sides rather than forcing one. Setting a target
// width alone lets a tall page render to whatever height the
// aspect ratio implies, which on a long scan is an allocation
// pdfium aborts on — it throws `length_error` from C++ with
// exceptions disabled, so it takes the process with it rather
// than returning an error we could downgrade to a skip.
let cap = i32::try_from(long_edge_px.max(1)).unwrap_or(i32::MAX);
let long_edge_pt = page.width().value.max(page.height().value);
let cap = i32::try_from(long_edge_for_dpi(long_edge_pt, dpi, max_px)).unwrap_or(i32::MAX);
// All three, and the combination is the point.
//
// `set_maximum_*` alone does not scale — it only clamps a render
// that would otherwise exceed it. With no target and no scale
// factor pdfium renders at 1pt-to-1px, i.e. 72 DPI, and
// `render_dpi` silently does nothing. A target alone is what
// makes a long scan allocate past what pdfium will take: it
// throws `length_error` from C++ with exceptions disabled, which
// aborts the process rather than returning an error we could
// downgrade to a skip. Target sets the scale, the maxima bound
// it, and together they also keep the aspect ratio — the
// clamp-only path sets `do_maintain_aspect_ratio = false` and
// squashes the page.
let config = PdfRenderConfig::new()
.set_target_width(cap)
.set_maximum_width(cap)
.set_maximum_height(cap);

View File

@@ -61,7 +61,7 @@ fn a_ccitt_page_rasterizes_even_though_dctdecode_extraction_cannot() {
let r = renderer();
let pdf = r.open(bytes, None).expect("open ccitt.pdf");
let png = pdf.render_page_png(1, 1_000).expect("render page 1");
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,
@@ -78,7 +78,7 @@ 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, 1_000).expect("render page 1");
let png = pdf.render_page_png(1, 300, 1_000).expect("render page 1");
assert!(png.starts_with(PNG_MAGIC));
}
@@ -90,31 +90,69 @@ 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, 1_000).expect("render page 1");
let png = pdf.render_page_png(1, 300, 1_000).expect("render page 1");
assert!(png.starts_with(PNG_MAGIC));
}
/// The long edge is a budget, not a suggestion — an OCR engine that
/// 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_long_edge_budget_is_respected_in_both_orientations() {
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).expect("render");
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.
@@ -124,7 +162,7 @@ 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, 600).is_err());
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

View File

@@ -349,7 +349,7 @@ valid_ratio_threshold = 0.5 # PDF 고유 키 (image 에 없음)
min_char_count = 20
lang_hint = "kor"
# render_library = "/usr/lib/libpdfium.so" # 있으면 모든 인코딩의 스캔 페이지 OCR
render_dpi = 300 # 렌더 해상도. max_pixels 가 상한
render_dpi = 300 # 렌더 해상도 요청. 실효값은 max_pixels 가 결정
```
> `render_library` 를 비워 두면 로더 경로에서 찾고, 못 찾으면 **단일 DCTDecode(JPEG) 페이지만** OCR 된다. CCITTFax·JBIG2·Flate·JPX 스캔은 본문 없이 색인되고 그 건수가 `ocr-skipped` 로 찍힌다. `kebab doctor` 의 `pdf_render` 로 확인.
@@ -713,7 +713,7 @@ KB --json schema | jq '.stats.code_lang_breakdown'
- 코퍼스에 없는 주제로 `kebab ask` → `refusal_reason: "llm_self_judge"` (또는 `no_chunks` / `score_gate`) + `grounded: false`.
- (P6-4) `image.ocr.enabled = true` 로 PNG 자산을 ingest 하면 `kebab list docs` 가 markdown 옆에 image doc 도 출력 (`workspace_path` 가 `*.png`). `kebab inspect doc <image_doc_id>` 의 `block.ocr.joined` 가 vision LM 의 OCR 결과 (예: 스크린샷 안의 텍스트). `kebab search --mode lexical "<OCR text>"` 가 그 image chunk 를 반환하면 wiring 정상.
- OCR / caption 부분 실패는 `errors` 카운터 미증가 — `kebab inspect doc <id>` 의 Provenance Warning 이벤트 또는 `--debug` 로그에서만 확인.
- (P7-3) `*.pdf` 자산을 워크스페이스에 두면 `kebab ingest` 출력에 PDF 도 `new` 카운터에 포함. `kebab inspect doc <pdf_doc_id>` 가 `parser_version = "pdf-text-v1"` + 페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`. 본문에 등장하는 단어로 `kebab search --mode hybrid` 시 PDF chunk 가 결과에 포함되고 `source_span.kind = "page"` 면 wiring 정상. 암호화 PDF 는 `errors+=1` 로 분류되며 `error` 필드에 `qpdf --decrypt` 안내 보존. 빈/스캔 페이지 (PDF 가 텍스트를 추출하지 못한 페이지) 는 0 chunk + `Provenance::Warning` ("scanned candidate") 로 표시 — P+ scanned-PDF OCR fallback 까지는 검색 불가.
- (P7-3) `*.pdf` 자산을 워크스페이스에 두면 `kebab ingest` 출력에 PDF 도 `new` 카운터에 포함. `kebab inspect doc <pdf_doc_id>` 가 `parser_version = "pdf-text-v2"` + 페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`. 본문에 등장하는 단어로 `kebab search --mode hybrid` 시 PDF chunk 가 결과에 포함되고 `source_span.kind = "page"` 면 wiring 정상. 암호화 PDF 는 `errors+=1` 로 분류되며 `error` 필드에 `qpdf --decrypt` 안내 보존. 빈/스캔 페이지 (PDF 가 텍스트를 추출하지 못한 페이지) 는 0 chunk + `Provenance::Warning` ("scanned candidate") 로 표시 — P+ scanned-PDF OCR fallback 까지는 검색 불가.
## config migrate (마이그레이션)

View File

@@ -26,7 +26,7 @@ classDiagram
parse_blocks(body) (Vec~ParsedBlock~, Warnings)
}
class PdfTextExtractor {
PARSER_VERSION = "pdf-text-v1"
PARSER_VERSION = "pdf-text-v2"
new() Self
}
class ImageExtractor {
@@ -101,7 +101,7 @@ flowchart LR
**PDF** (`kebab-parse-pdf`):
- `PdfTextExtractor``Extractor` 구현체. `lopdf::Document::load_mem` 로 한 번 파싱, encrypted 면 즉시 bail.
- `PARSER_VERSION = "pdf-text-v1"` — version cascade entry. (HOTFIXES P7-2 의 chunker_version `pdf-page-v1` 와 별개.)
- `PARSER_VERSION = "pdf-text-v2"` — version cascade entry (issue #232 에서 v1 → v2, 페이지 렌더링 도입으로 기존 색인 스캔본 재처리 유발). (HOTFIXES P7-2 의 chunker_version `pdf-page-v1` 와 별개.)
- 빈 페이지 / extract 실패 → `Block::Paragraph` 빈 inlines + `ProvenanceKind::Warning("scanned candidate")`. OCR fallback 미구현.
**Image** (`kebab-parse-image`):

View File

@@ -172,7 +172,7 @@
},
"failure_reason": {
"type": "string",
"description": "pdf_ocr_finished: why the page was skipped. \"no_renderer\" — no page renderer configured and the page is not a single DCTDecode image, so no raster could be produced (issue #232). \"render_error\" — a renderer was configured and rasterizing failed. \"ocr_error\" — the OCR engine itself failed. Absent when the page succeeded."
"description": "pdf_ocr_finished: why the page was skipped. \"no_renderer\" — no page renderer configured and the page is not a single DCTDecode image, so no raster could be produced (issue #232). \"render_error\" — a renderer was configured and rasterizing this page failed. \"unopenable_pdf\" — a renderer was configured but could not open the PDF at all. \"ocr_error\" — a raster was produced but the OCR engine failed on it. Absent when the page succeeded. The first three are the ones counted in ingest_report.ocr_skipped_pages."
},
"counts": {
"type": "object",

View File

@@ -217,7 +217,7 @@
"ocr_skipped_pages": {
"type": "integer",
"minimum": 0,
"description": "PDF pages the text gate classified as scans but which produced no raster to OCR, so their content is not indexed (issue #232). Additive; absent in pre-v0.33 output and read as 0."
"description": "PDF pages the text gate classified as scans but which produced no raster to OCR, so their content is not indexed (issue #232). Counts pages with no image to read (pdf_ocr_finished.failure_reason of no_renderer / render_error / unopenable_pdf), not pages the OCR engine failed on. Additive; absent in pre-v0.33 output and read as 0."
}
}
}

View File

@@ -47,6 +47,39 @@ git history.
스냅샷 두 개가 따라 움직였고, 바뀐 것이 파생 식별자뿐임을 확인했다: `vector_pdf_canonical.json``doc_id`/`block_id`/`parser_version`/provenance note 만 바뀌고 **본문 텍스트·inlines·source_span·metadata 는 동일**, `ingest_report.snapshot.json``ocr_skipped_pages` 키 하나만 추가.
### 리뷰가 잡은 것 — render_dpi 가 아무 일도 안 하고 있었다
초안은 `set_maximum_width` / `set_maximum_height` 만 걸었다. 그런데 pdfium-render 에서 `maximum_*`**초과할 때만 줄이는 클램프**이고 스케일이 아니다. 타깃도 배율도 없으면 `width_scale = height_scale = 1.0` 로 떨어져 **1 pt → 1 px, 즉 72 DPI** 로 렌더된다. `render_dpi` 를 300 으로 주든 1200 으로 주든 산출물이 같았다.
렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다. 신규 config 키가 선언한 해상도와 실제가 다르고 그만큼 인식률을 손해 보는 상태였다.
실측으로 확인했다 (govdocs1-000157-ccitt.pdf 5쪽):
| 설정 | 결과 |
|---|---|
| `maximum_*` 만 (초안) | 621×801 px — **72 DPI** |
| `target + maximum_*` (수정) | 1588×2048 px — **184 DPI** |
같은 뿌리의 문제가 하나 더 있었다. 클램프만 걸리는 경로는 `do_maintain_aspect_ratio = false` 를 함께 세팅해서 가로·세로가 **독립적으로** 잘린다. `ccitt.pdf`(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 기본값 `max_pixels = 2048` 이면 A4 는 175 DPI 언저리에서 잘린다. 기본값 300 이 그대로 나오지 않는다는 뜻이라 config·README·SMOKE 문구를 실제와 맞췄다.
### 리뷰가 잡은 것 — 렌더러가 있으면 오히려 손해 보는 경우
페이지 하나만 렌더에 실패하면 곧장 skip 이었고, DCTDecode 경로를 시도하지 않았다. "렌더러 우선 + 폴백" 이 렌더러 **유무** 수준에서만 성립했던 것이다. pdfium 을 설치한 쪽이 그 페이지에서는 손해를 보는 셈이라, 페이지 단위 폴백을 넣었다.
pdfium 이 PDF 자체를 못 열면 모든 페이지가 `no_renderer` 로 보고되면서 "`render_library` 를 지정하라" 고 안내했다. 이미 제대로 설정한 사용자에게는 오답이라 `unopenable_pdf` 로 갈랐다.
`ocr-skipped` 카운트는 래스터 실패만 세는데, OCR 엔진 실패도 화면에는 똑같이 `⊘` 로 찍혀서 줄 수와 카운트가 안 맞았다. 사유를 라벨에 적어 둘을 구분한다.
### `/MediaBox` 를 직접 파싱하지 않기로
초안은 lopdf 로 `/MediaBox` 를 읽어 페이지 크기를 구했다. 리뷰가 지적했듯 `/MediaBox` 는 **상속 속성**이고 대부분의 생산자가 `/Pages` 노드에 한 번만 쓴다 — lopdf 0.32 에는 상속 해석 헬퍼가 없어서 그런 PDF 는 전부 조용히 A4 폴백을 탄다. `/UserUnit` 도 미반영이었다.
pdfium 이 이미 페이지 크기를 알고 있으므로 거기서 받는다. 40여 줄이 사라졌고 상속·UserUnit 문제가 함께 없어졌으며, kebab-app 이 lopdf 딕셔너리를 뒤지던 레이어링도 정리됐다.
### 구현 중 발견한 것 — pdfium 은 동시 사용이 안전하지 않다
테스트를 병렬로 돌리자 `double free or corruption` 으로 프로세스가 죽었다. `pdfium-render``thread_safe` 기능만으로는 부족하다. 단일 스레드에서는 6개 테스트가 전부 통과한다.
@@ -68,7 +101,32 @@ ingest 는 PDF 를 한 번에 하나씩 처리하므로 오늘은 문제가 없
| 색인 글자 수 | 35,994 | **36,095** |
| 요약 | `ocr-skipped 1` | (없음) |
렌더링 자체는 스파이크에서 여섯 필터 계열 전부 확인했다 — CCITT / JBIG2 / Flate / JPX / 혼합(DCT+CCITT+JBIG2+Flate) / DCT, 300dpi 에서 페이지당 40~145 ms. OCR 호출(초 단위)에 묻히는 비용이다.
렌더링 자체는 스파이크에서 여섯 필터 계열 전부 확인했다 — CCITT / JBIG2 / Flate / JPX / 혼합(DCT+CCITT+JBIG2+Flate) / DCT, 페이지당 40~145 ms. OCR 호출(초 단위)에 묻히는 비용이다. (그 측정은 72 DPI 버그가 있던 상태라 실제 해상도가 요청보다 낮았다 — 수정 후에는 더 걸리지만 여전히 OCR 호출에 묻힌다.)
### 정답 있는 한국어 스캔으로 잰 인식률
도그푸딩 store 의 합성 픽스처(나무위키 문서를 조판→PDF→이미지로 구워 텍스트 레이어를 없앤 것, 정답 텍스트 동봉) 중 **CCITT 인코딩 3건**. 렌더러 없이는 전 페이지가 건너뛰어져 색인 내용이 0 이던 파일들이다. 엔진은 config 기본값인 `qwen2.5vl:3b`.
| 문서 | 페이지 | CER |
|---|---|---|
| namu-beulenda-me-ijeu-leoneo-silijeu | 8 | **15.65%** |
| namu-bihaengdae-seutoli | 8 | **12.55%** |
| namu-gu-anoli-en | 6 | **15.08%** |
8 페이지 전부 OCR 성공, 건너뜀 0. 수정 전에는 세 문서 모두 본문 0 자였으므로 비교 대상 CER 은 100% 다.
첫 문단 대조 (읽히는 수준인지 확인용):
```
OCR 브렌다(메이즈 러너 시리즈) / 개요 / 원작 소설과 영화 메이즈 러너의
등장인물이자 원작에서는 진 히로인. 갈색 긴 머리의 미소녀로, 영화에서는 손이…
정답 브렌다(메이즈 러너 시리즈) / 개요 / 원작 소설과 영화 메이즈 러너의
등장인물이자 원작에서는 진 히로인. 갈색 긴 머리의 미소녀로, 영화에서는 숏컷이…
```
**엔진 선택이 결과를 가른다.** 처음에는 이 머신에 있던 `gemma3:4b` 로 쟀는데, 래스터는 정상이었지만 출력이 원문과 무관한 환각이었다("이 문서에 스무어라가 포함되어 있습니다"). 게다가 해상도가 올라가자 밀집 한국어 페이지에서 180 초 타임아웃이 났다. 범용 멀티모달 모델은 OCR 엔진이 아니다 — config 기본값이 `qwen2.5vl:3b` 인 이유가 이것이고, 릴리스 노트에 적어 둘 만하다.
이때 새 라벨이 제 역할을 했다. 타임아웃 페이지가 "래스터 없음" 이 아니라 **"OCR 엔진 실패"** 로 찍혀서, 렌더링 문제가 아니라 엔진 문제라는 게 로그만 보고 갈렸다.
### 범위 밖