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:
@@ -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 경로).
|
||||
|
||||
@@ -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})"
|
||||
)),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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))),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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. 비우면 로더 경로 탐색."
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (마이그레이션)
|
||||
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 엔진 실패"** 로 찍혀서, 렌더링 문제가 아니라 엔진 문제라는 게 로그만 보고 갈렸다.
|
||||
|
||||
### 범위 밖
|
||||
|
||||
|
||||
Reference in New Issue
Block a user