GITHUB 레포 딥다이브 · 2026-08-03 · FIRECRAWL/PDF-INSPECTOR · OCR를 부르기 전에 "부를 필요가 있는지"부터 20ms에 가려내는 Rust 문지기

pdf-inspector 딥다이브
스캔본이냐 진짜 텍스트냐, PDF의 정체를 밀리초에 판별한다

firecrawl/pdf-inspectorOCR 없이(no OCR) PDF가 "텍스트 기반인지 스캔 이미지인지"를 빠르게 분류하고, 텍스트 기반이면 위치 인식 추출 + 깔끔한 Markdown 변환까지 해내는 순수 Rust 라이브러리다. 만든 곳은 웹 스크래핑 스타트업 Firecrawl — 자사 문서 파이프라인에서 "OCR이 필요 없는 PDF에까지 비싼 OCR을 돌리는 낭비"를 없애려고 만들었다.

핵심 발상은 "싼 판별을 먼저, 비싼 처리는 나중에"다. 도착한 PDF를 ~20ms에 스캔/텍스트로 분류해서, 텍스트면 로컬에서 ~150ms에 처리하고(무료·빠름), 스캔이면 그때만 무거운 OCR 서비스(2~10초·유료)로 보낸다. 이 "스마트 라우팅(smart routing)" 하나로 대량 문서 처리 비용이 확 줄어든다. 게다가 코어 하나에서 CLI·Python·Node·브라우저 WASM 4가지 얼굴이 나온다.

(저장소 firecrawl/pdf-inspector · 라이선스 MIT · 언어 Rust(edition 2021) · PDF 파싱은 lopdf 단일 의존, 네이티브 pdfium/ML 없음 · src/7.5만 줄 · 추적 파일 281개 · 분류 4종 TextBased/Scanned/ImageBased/Mixed · 벤치 Overall 0.875(1위) · 최신 커밋 2026-07-31 · TrendShift Daily #1)
목차
  1. 한 줄 정체
  2. 왜 주목받는가 — "판별이 먼저"라는 각도
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 — 분류 결정 트리와 휴리스틱
  5. 디렉토리 구조 해부
  6. 학습 포인트 — 기술별 배울 것
  7. 시스템 / 빌드 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 로드맵 (주차별)
  10. 핵심 키워드 사전
  11. 참고 링크

1한 줄 정체

이 프로젝트가 정확히 무엇인지 한 문장으로

pdf-inspector는 "OCR을 돌리기 전에, 이 PDF가 OCR이 필요한 스캔본인지 아니면 이미 텍스트가 들어있는 문서인지를 밀리초 단위로 판별해 주고, 텍스트 문서라면 위치까지 아는 추출과 Markdown 변환을 로컬에서 끝내 주는 순수 Rust 라이브러리"다. 한마디로 PDF 처리 파이프라인의 "문지기(gatekeeper)"다. 값비싼 방(OCR)에 아무나 들여보내지 않고, 표를 먼저 검사해서 정말 필요한 문서만 통과시킨다.

한 장의 비유

"응급실 트리아지(triage) 간호사 — 모두를 수술실로 보내지 않는다"

큰 병원 응급실에는 트리아지 간호사가 입구에 앉아 있다. 도착한 환자를 30초 안에 훑어보고 "이 사람은 붕대만, 저 사람은 즉시 수술"을 가른다. 모두를 값비싼 수술실(=OCR)로 보내면 병원이 마비되기 때문이다.

pdf-inspector가 딱 그 간호사다. PDF가 도착하면 본문 전체를 열지 않고 몇 페이지만 빠르게 훑어서 — "글자 명령이 충분히 있네? → 텍스트 기반, 로컬에서 바로 처리" 또는 "온통 커다란 이미지뿐이네? → 스캔본, OCR로 보내" 를 20ms에 판정한다. Firecrawl 말로는 실제 PDF의 약 54%가 OCR이 필요 없는데, 이걸 걸러내지 못하면 그 절반에까지 초 단위 OCR 비용을 태우게 된다.

용어
OCR (광학 문자 인식, Optical Character Recognition)
이미지(사진·스캔) 속의 글자를 "그림"이 아니라 "문자 데이터"로 읽어내는 기술. 정확하지만 느리고(문서당 초 단위) 비싸다(GPU·유료 API). 스캔한 계약서처럼 글자가 픽셀로만 존재하는 PDF엔 반드시 필요하지만, 워드에서 "PDF로 내보내기"한 문서는 이미 글자 데이터가 들어있어 OCR이 전혀 필요 없다. pdf-inspector의 존재 이유는 이 둘을 미리 가려내는 것이다.
용어
텍스트 기반 vs 스캔 PDF
텍스트 기반(text-based) = PDF 안에 "여기에 이 글자를 그려라"라는 문자 명령(Tj/TJ 연산자)이 들어있는 문서. 복사·검색이 된다. 스캔(scanned) = 종이를 사진 찍어 이미지 한 장(Do 연산자)으로 박아 넣은 문서. 겉보기엔 똑같아 보여도 내부 구조가 완전히 다르다. pdf-inspector는 바로 이 "안에 글자 명령이 있나, 이미지뿐인가"를 세어서 판별한다.

사용자(또는 상위 파이프라인)가 PDF 한 개를 던졌을 때 pdf-inspector 안에서 벌어지는 일을 압축하면 이렇다:

PDF 1개 도착 (예: 분기보고서.pdf, 320페이지) │ ▼ ① 뼈대만 파싱 xref 표 + 페이지 트리만 읽음 (전체 객체 로드 안 함) ② 페이지 표본 선정 ScanStrategy::Sample(8) → 앞/중간/뒤 8페이지만 ③ 연산자 세기 각 표본 페이지의 content stream에서 Tj·TJ(글자 그리기) / Do(이미지 그리기) 개수 집계 ④ 페이지 판정 "이 페이지는 텍스트 페이지인가?" (연산자 임계 + 이미지 지배 여부) ⑤ 문서 분류 텍스트 페이지 비율 ≥ 0.6 → TextBased 글자 0 + 이미지만 → Scanned (신뢰도 0.95) 섞임 → Mixed / ImageBased (+ OCR 권고 플래그) ⑥ 라우팅 반환 PdfType + confidence + pages_needing_ocr(몇 페이지가 OCR 필요) │ ├─ TextBased & 고신뢰 → 그대로 로컬 추출 → Markdown (~150ms, 무료) └─ Scanned/Mixed → 해당 페이지만 외부 OCR 서비스로 (2~10초, 유료)

여기서 결정적인 건 ⑥에서 반환하는 정보의 정밀도다. 단순히 "스캔이다/아니다"가 아니라, "문서 전체 중 3, 7, 12페이지만 OCR이 필요하다"는 페이지 단위 라우팅(pages_needing_ocr)까지 준다. 300페이지 문서에서 스캔된 3페이지만 골라 OCR하면 되니, 비용이 100배 차이 난다.

2왜 주목받는가 — "판별이 먼저"라는 각도

공개 직후 TrendShift Daily #1에 오른 이유

첫째, 발상의 전환이다. PDF에서 텍스트를 뽑는 도구(pymupdf4llm, markitdown, marker 등)는 이미 많다. 그런데 그 도구들은 대부분 "일단 다 처리하고 본다". pdf-inspector는 그 앞단에 "이 문서를 처리할 가치가 있나(=OCR이 필요한가)"를 먼저 묻는 얇고 빠른 판별기를 끼운다. 대량 문서 파이프라인을 굴리는 회사(Firecrawl 자신, RAG 서비스, 문서 검색 스타트업)에게 이건 곧바로 비용 절감으로 이어져서 실용 가치가 즉시 와닿는다.

둘째, 속도 숫자가 압도적이다. README의 2026-07-31 벤치마크(Apple M4 Pro, opendataloader-bench 200개 PDF, OCR 끄고 로컬 엔진만 비교):

도구종합 점수표(TEDS)200문서 처리 시간
pdf-inspector0.875 (1위)0.814 (1위)0.470초 (1위)
pymupdf4llm그 아래17.1초
markitdown그 아래16.2초

같은 코퍼스를 35배 이상 빠르게 처리하면서 정확도(읽기 순서 0.915, 표 0.814)까지 1위다. "빠른데 부정확"이 아니라 "빠르면서 정확"이라, 벤치마크를 본 개발자들이 곧장 관심을 보였다.

비유

공항 보안검색과 같다. 모든 승객의 가방을 일일이 손으로 열어보면(=모든 PDF에 OCR) 줄이 끝없이 길어진다. 그래서 먼저 X-ray 벨트에 통과시켜 대부분을 빠르게 흘려보내고, 수상한 가방만 골라 정밀 검사한다. pdf-inspector가 그 X-ray다 — 초저비용으로 대량을 훑어, 진짜 정밀 처리(OCR)가 필요한 소수만 집어낸다.

셋째, 순수 Rust + 단일 의존이라는 공학적 매력이다. PDF 파싱을 lopdf 크레이트 하나로만 하고 네이티브 pdfium/mupdf FFI도, 머신러닝 모델도 쓰지 않는다. 덕분에 빌드가 가볍고, 크로스 컴파일이 쉽고, 브라우저 WASM으로도 나간다. "무거운 C++ 라이브러리에 링크하지 않는 PDF 도구"라는 점이 Rust 커뮤니티에 특히 어필했다.

이 레포의 강점
코드가 모델을 이긴다 — 규칙 기반 판별의 재발견

요즘은 뭐든 "AI로 분류"하려 들지만, pdf-inspector는 PDF 내부 구조를 직접 세는 규칙(휴리스틱)만으로 판별한다. ML 추론이 없으니 결정적(같은 입력 → 같은 출력)이고, GPU가 필요 없고, 밀리초에 끝난다. "문제를 잘 이해하면 무거운 모델 없이도 풀린다"는 걸 보여 주는 좋은 사례다.

3기술 스택 전체 지도

Rust 코어 → 4개 언어 바인딩까지, 무엇으로 만들었나

코어: 순수 Rust (edition 2021)

본체는 pdf_inspector라는 이름의 Rust 라이브러리 크레이트다. Cargo.tomlcrate-type["lib", "cdylib"] — Rust에서 쓸 lib과, 다른 언어(Python/Node)에서 링크할 C 호환 동적 라이브러리(cdylib)를 동시에 뽑는다. PDF를 실제로 뜯어보는 유일한 의존이 lopdf다.

용어
lopdf
순수 Rust로 짜인 PDF 파서/조작 라이브러리. C 라이브러리(pdfium, poppler)에 링크하지 않고 Rust만으로 PDF의 객체·xref·content stream을 읽는다. pdf-inspector는 네이티브 빌드에서 features=["rayon"](병렬 파싱)을 켜고, WASM 빌드에선 default-features=false, features=["wasm_js"]로 싱글스레드·JS 랜덤을 쓴다 — 같은 크레이트를 타깃에 맞춰 다르게 조립하는 좋은 예.
크레이트버전용도
lopdf0.41유일한 PDF 파서. 네이티브=rayon 병렬, WASM=wasm_js 싱글스레드
thiserror2.0에러 타입(PdfError) 정의 — 보일러플레이트 없이 enum으로
regex1.10텍스트 후처리(페이지 번호·URL·하이픈 재결합 등)
ttf-parser0.25TrueType 폰트 파싱 — Identity-H CID 폰트의 cmap을 읽어 글자 복원
unicode-normalization0.1추출 텍스트 NFKC 정규화(합자·전각 문자 정리)
once_cell1.19지연 초기화 static(글리프 이름 표 등 큰 상수 데이터)
log0.4로깅 파사드(RUST_LOG으로 모듈별 디버그)
pyo30.25 (옵션)Python 바인딩. features=["python"]일 때만
rayon / env_logger1.10 / 0.11네이티브 전용 — 병렬화, CLI 로깅
include_dir0.7 (WASM 전용)CJK CMap을 바이너리에 임베드(WASM엔 파일시스템 없음)

하나의 코어, 네 개의 얼굴 (바인딩)

이 저장소의 가장 배울 만한 설계는 Rust 코어 하나를 4가지 배포 형태로 내보내는 구조다. 로직은 한 곳에 있고, 각 언어의 얇은 포장만 따로 있다.

┌───────────────────────────────┐ │ src/ (순수 Rust 코어 ~7.5만 줄) │ │ detector · extractor · tables · │ │ markdown … 실제 알고리즘 전부 │ └───────────────┬───────────────────┘ ┌──────────────┬───────────┼────────────┬──────────────┐ ▼ ▼ ▼ ▼ ▼ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ CLI 3종 │ │ Python │ │ Node/Bun │ │ 브라우저 │ │ Rust │ │ pdf2md │ │ pyo3 │ │ napi-rs │ │ WASM │ │ crate │ │ detect-pdf │ │ maturin │ │ .node │ │ wasm- │ │ (직접 │ │ dump_ops │ │ .pyi 스텁 │ │ 프리빌트 │ │ bindgen │ │ 링크) │ └───────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ src/bin/ src/python.rs napi/ wasm/ lib.rs 공개 API (0.1.7) (0.2.6) (1.11.2) (0.1.3) process_pdf() 등
함정
버전 번호가 패키지마다 완전히 다르다

Rust crate는 0.1.7, Python은 0.2.6, Node는 1.11.2, WASM은 0.1.3. 하나로 착각하면 안 된다. README 벤치마크가 말하는 "pdf-inspector 0.2.6"은 Python 패키지 버전 기준이다. 배포 채널마다 릴리스 주기가 독립적이라 이렇게 갈린다.

각 바인딩의 진입점은 이렇게 대응된다:

CI / 배포 파이프라인

.github/workflows/에 6개 워크플로가 있다: ci(테스트+fmt+clippy), pages(랜딩 사이트), 그리고 채널별 배포 4종(publish-crate·publish-pypi·publish-wasm·publish[npm]). 특히 clippy를 -D warnings로 강제해서 경고 하나만 있어도 CI가 깨진다 — Rust 프로젝트의 품질 규율을 볼 수 있는 대목이다.

4아키텍처 심화 — 분류 결정 트리와 휴리스틱

이 레포의 진짜 알맹이. "어떻게 스캔/텍스트를 가르는가"를 코드로

pdf-inspector의 심장은 src/detector.rs(약 3,645줄)다. 핵심 아이디어는 "PDF 전체를 렌더링하지 말고, content stream의 연산자만 세라"는 것. PDF 페이지는 결국 "글자 그려라(Tj/TJ), 이미지 붙여라(Do), 선 그어라(path)" 같은 명령의 목록이다. 이 명령의 비율만 보면 렌더링 없이도 문서의 정체가 드러난다.

용어
content stream & 연산자(operator)
PDF 페이지의 실제 "그리기 대본". BT ... Tj ... ET는 텍스트 블록, Do는 외부 객체(주로 이미지) 그리기, path 연산자(m·l·re)는 선/도형. pdf-inspector는 이 대본에서 Tj/TJ(글자) 개수와 Do(이미지) 개수의 균형을 읽어 판별한다 — 사람이 그림을 안 보고 대본만 읽고 "이건 연극이다/영화다"를 맞히는 셈.

1단계 — 페이지마다 신호를 수집한다 (PageAnalysis)

표본으로 뽑힌 각 페이지에서 PageAnalysis 구조체에 십수 가지 신호를 채운다. 그중 판별에 쓰이는 핵심만:

신호
text_operator_countTj/TJ(글자 그리기) 연산자 개수 — 많을수록 텍스트 문서
image_count / has_imagesDo(이미지) 연산자 개수 / 이미지 존재 여부
has_template_image페이지 절반 이상을 덮는 대형 배경 이미지(=스캔 의심)
unique_text_chars서로 다른 글자 종류 수 — 너무 적으면 진짜 텍스트가 아님
path_op_count / has_vector_text선 그리기 명령 수 / 글자를 도형으로 그린 "벡터 아웃라인 텍스트" 여부
has_identity_h_no_tounicodeCID 폰트인데 유니코드 매핑표가 없음(→글자 복원 불가 의심)
font_change_count폰트 전환 횟수 — 신문 다단 레이아웃 감지에 사용

2단계 — "이 페이지는 텍스트 페이지인가?" 판정

단순히 "글자 명령이 있으면 텍스트"가 아니다. 이미지가 있으면 기준을 높이는 등, 실전 함정을 피하는 조건이 겹쳐 있다(실제 코드):

// 이미지가 있는 페이지는 글자 명령을 더 많이(10개↑) 요구 — 
// 로고 옆 캡션 몇 글자만 있는 스캔본을 '텍스트'로 오판하지 않기 위해
let effective_min_ops = if analysis.has_images || analysis.image_count > 0 {
    config.min_text_ops_per_page.max(10)   // 이미지 有 → 최소 10
} else {
    config.min_text_ops_per_page           // 이미지 無 → 최소 3 (기본값)
};

if analysis.text_operator_count >= effective_min_ops
    && !is_image_dominated                 // image_count>10 && image>text*3 이면 이미지 지배
    && analysis.unique_text_chars >= 5     // 글자 종류가 5개는 넘어야
    && !analysis.has_vector_text           // 도형으로 그린 가짜 글자 아니고
    && !analysis.has_only_type3_fonts      // 비트맵 글꼴만 있는 것도 아니면
{
    pages_with_text += 1;                   // → 진짜 '텍스트 페이지'로 카운트
}
비유

"글자가 있다"와 "읽을 수 있다"는 다르다. 간판에 로고와 함께 "Since 1998" 같은 장식 글자 몇 개가 박혀 있다고 그 가게가 "책"은 아니다. pdf-inspector는 그래서 이미지가 끼어 있으면 "진짜 본문이려면 글자 명령이 최소 10개는 돼야 한다"고 기준을 올린다. 스캔 표지 한 장에 저작권 문구 몇 줄 얹힌 문서를 텍스트로 착각하지 않으려는 방어다.

3단계 — 문서 전체 분류 결정 트리

페이지별 카운트가 모이면, 아래 순서대로 흘러가며 최종 PdfType과 신뢰도(confidence, 0~1)를 정한다(실제 코드):

// 위에서부터 순서대로 검사, 처음 걸리는 가지가 결정
let (pdf_type, confidence) = if has_template_images && pages_with_text > 0 {
    ocr_recommended = true;                          // 큰 배경이미지 + 약간의 글자
    (PdfType::Mixed, 0.5 + (0.3 * (1.0 - template_ratio)))
} else if text_ratio >= config.text_page_ratio_threshold {   // 텍스트 페이지 비율 ≥ 0.6
    ocr_recommended = false;
    (PdfType::TextBased, text_ratio)                 // ★ 로컬 추출로 라우팅
} else if pages_with_text == 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
    ocr_recommended = true;
    if total_text_ops == 0 && pages_with_vector_text == 0 {
        (PdfType::Scanned, 0.95)                     // 글자 0 + 이미지뿐 → 확실한 스캔
    } else {
        (PdfType::ImageBased, 0.8)
    }
} else if pages_with_text > 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
    ocr_recommended = true;
    (PdfType::Mixed, 0.7)                            // 글자도 이미지도 섞임
} else if total_text_ops == 0 {
    ocr_recommended = true;
    (PdfType::Scanned, 0.9)
} else {
    ocr_recommended = false;
    (PdfType::TextBased, text_ratio.max(0.5))
};

읽는 법: 위에서 아래로 순서대로 검사해서 처음 참이 되는 가지가 결과다. 가장 중요한 지름길은 두 번째 가지 — "텍스트 페이지 비율이 60% 이상이면 무조건 TextBased, OCR 불필요". 반대로 "글자 명령이 아예 0이고 이미지만 있으면" 신뢰도 0.95로 Scanned. 애매한 중간(글자+이미지 혼재)은 전부 ocr_recommended=true로 안전하게 OCR을 권한다 — "확실할 때만 OCR을 건너뛴다"는 보수적 설계다.

실전 함정을 잡는 특수 휴리스틱들

기본 결정 트리 위에, "겉보기론 텍스트인데 실은 못 읽는" 위장 케이스를 잡는 규칙이 얹혀 있다. 이게 정확도의 비결이다.

휴리스틱 A — 템플릿/타일드 스캔 이미지

단일 이미지가 TEMPLATE_IMAGE_THRESHOLD = 500,000 픽셀(반 페이지 at 150DPI)을 넘으면 "배경 스캔"으로 본다. 교묘하게 스캔본을 여러 조각(타일)으로 쪼갠 경우(JBIG2 strip 스캔)엔 개별 타일은 작아도, total_area ≥ 임계 × 4(=200만 픽셀)면 template로 승격시켜 잡아낸다.

휴리스틱 B — 벡터 아웃라인 텍스트 (글자를 "도형"으로 그린 PDF)

일부 PDF는 글자를 폰트가 아니라 선(path)으로 직접 그린다. 겉보기엔 글자지만 복사·검색이 안 된다. 이걸 잡는 조건:

let has_vector_text =
    path_ops >= 1000
    && path_ops > text_ops.saturating_mul(200)   // 글자 대비 선이 200배↑
    && unique_alphanum_chars < 30;               // 실제 글자 종류는 30 미만

아웃라인 글자 하나엔 path 명령이 10~30개 붙으니, 페이지 전체가 수천 개 path인데 진짜 글자 종류는 적으면 "도형으로 위장한 텍스트"로 판정 → OCR 권고.

휴리스틱 C — 신문 다단 레이아웃 (WSJ·NYT)

추출은 되지만 다단이 뒤섞여 품질이 나쁜 케이스. text_operator_count ≥ 1500 && font_change_count ≥ 50 && (폰트전환/글자) < 0.15인 페이지가 절반 이상이면 OCR 권고. 소스 주석엔 실제 캘리브레이션 데이터(WSJ 비율 0.02~0.06 vs 일반 계약서 0.25~0.35)까지 적혀 있다.

휴리스틱 D — 폰트 디코딩 불가 (깨진 글자)

Identity-H/V CID 폰트인데 ToUnicode 매핑이 없거나(CID→유니코드 불가), Type3(비트맵 글꼴)만 있으면 suspected_garbled_text로 플래그. 단, ToUnicode/W-array가 유니코드처럼 보이거나 임베드된 TrueType cmap이 있으면 fallback 디코딩 가능으로 처리해 무리하게 OCR로 보내지 않는다.

ScanStrategy — "몇 페이지를 볼 것인가"

초대형 PDF에서 모든 페이지를 검사하면 느리다. 그래서 표본 전략을 고른다(기본은 Sample(8)):

전략동작용도
EarlyExit앞에서부터 스캔, 첫 비텍스트 페이지에서 중단TextBased를 빠르게 확정
Full전 페이지, 조기 종료 없음Mixed vs Scanned 정확 판정
Sample(n)앞/중간/뒤 균등 n페이지만(기본 8)초대형 PDF (기본값)
Pages(vec)지정한 1-index 페이지만호출자가 대상 페이지를 알 때
설계 주석에서 배우는 것
왜 기본이 EarlyExit이 아니라 Sample(8)일까

소스 주석 왈: "EarlyExit is too aggressive for PDFs with an image-only cover followed by text-heavy pages (e.g., annual reports)." — 표지만 이미지이고 본문은 텍스트인 연차보고서 같은 문서에서, 첫 페이지(이미지)만 보고 조기 종료하면 "스캔본"으로 오판한다. 그래서 앞·중간·뒤를 골고루 표본하는 Sample을 기본으로 둔다. "엣지 케이스가 기본값을 정한다"는 실전 감각.

detection과 extraction은 문서를 한 번만 로드한다

판별(detection)과 추출(extraction)이 각각 PDF를 열면 I/O가 두 배다. pdf-inspector는 load_document_from_path/load_document_from_mem한 번 로드한 문서를 둘이 공유한다. 추출 파이프라인은 이렇게 이어진다:

content_stream.rs → fonts.rs → layout.rs → reading_order.rs → markdown/ (연산자 상태기계) (폰트 폭· (다단 감지· (읽기 순서 복원) (Markdown Tj/TJ/Td/Tm/q/Q → 인코딩·cmap 투영 히스토그램 변환·heading TextItem+PdfRect) fallback) valley detect) tier 판정) │ tables/ (표 감지 3전략, 아래) rect-based → line-based → heuristic (첫 유효 결과 승)

표 감지(tables/)는 3가지 전략을 우선순위대로 시도해 처음 유효한 결과가 이긴다: ① rect-based(셀 사각형을 union-find로 클러스터링) → ② line-based(수평·수직 선으로 격자 복원) → ③ heuristic(선이 없는 표를 간격·정렬로 추론). Markdown 변환은 폰트 크기를 0.5pt 단위로 클러스터링해 본문 대비 크기로 H1~H4 heading을 판정하고, 태그드 PDF면 structure tree의 역할(H1~H6·P·L·Code)을 우선 사용한다.

5디렉토리 구조 해부

7.5만 줄이 어디에 어떻게 나뉘어 있나
pdf-inspector/ ├─ Cargo.toml 루트 crate 설정 (버전 0.1.7, lopdf 단일 의존) ├─ LICENSE (MIT) © 2026 Firecrawl ├─ README.md 개요·벤치마크·CLI 사용법 ├─ AGENTS.md / CLAUDE.md AI 에이전트용 개발 지침(관례·주의) ├─ pyproject.toml Python 빌드(maturin), 버전 0.2.6 ├─ pdf_inspector.pyi Python 타입 스텁 │ ├─ src/ ★ 코어 (Rust 총 ~75,119줄) │ ├─ lib.rs (6,764) 공개 API 진입점 (process_pdf 등) │ ├─ detector.rs (3,645) ★ 스캔/텍스트 분류 두뇌 │ ├─ types.rs TextItem·PdfRect 등 공용 타입 │ ├─ tounicode.rs (3,162) CID→유니코드 매핑 │ ├─ glyph_names.rs (4,590) 글리프 이름 표 │ ├─ adobe_korea1.rs (17,073) CJK 코드맵 데이터(최대 파일) │ ├─ python.rs pyo3 바인딩 │ ├─ bin/ CLI 3종: pdf2md · detect_pdf · dump_ops │ ├─ extractor/ content_stream · fonts · layout · │ │ reading_order · links · underline · xobjects │ ├─ tables/ detect_rects(4,671) · detect_lines · │ │ detect_heuristic · grid · format · financial │ └─ markdown/ analysis · preprocess · convert · classify · postprocess │ ├─ napi/ Node/Bun 바인딩(napi-rs) — 버전 1.11.2 ├─ wasm/ 브라우저 WASM(wasm-bindgen) — 버전 0.1.3 ├─ external/bcmaps/ ~130개 .bcmap (CJK CMap, 런타임 로드) ├─ tests/ integration_tests.rs + fixtures/*.pdf(22) + snapshots/*.md(6) ├─ examples/ basic_usage.py ├─ docs/ rust-api · python · benchmarking · publishing · debugging ├─ scripts/ bench_opendataloader.py (벤치 하네스) ├─ site/ GitHub Pages 랜딩 └─ .github/workflows/ ci · pages · publish · publish-pypi/-wasm/-crate (6개)

구조에서 읽히는 힌트 두 가지. 첫째, 가장 큰 파일이 adobe_korea1.rs(1.7만 줄)인데 이건 로직이 아니라 CJK(한중일) 문자 코드맵 데이터다 — 한국어·일본어 PDF의 CID 폰트를 유니코드로 복원하려면 이 방대한 표가 필요하다. 즉 이 라이브러리는 처음부터 동아시아 문서를 진지하게 다룬다. 둘째, detector.rs는 3,645줄로 크지 않다 — 판별 로직 자체는 "얇고 빠른" 설계 의도가 파일 크기에도 드러난다. 무게는 오히려 추출·표·폰트 쪽에 실려 있다.

6학습 포인트 — 기술별 배울 것

이 레포를 뜯어보며 실제로 익힐 수 있는 것들

① PDF 내부 구조 (가장 값진 배움)

대부분의 개발자는 PDF를 "그냥 열리는 파일"로만 안다. 이 레포는 PDF가 사실은 객체 그래프 + content stream(그리기 대본)이라는 진실을 코드로 가르쳐 준다. Tj/TJ(텍스트), Do(XObject/이미지), path 연산자, CID 폰트와 ToUnicode 매핑, Identity-H 인코딩 — 이 개념들을 실전 판별 로직 안에서 만나니 교과서보다 훨씬 잘 붙는다.

② 규칙 기반(휴리스틱) 분류 설계

ML 없이 신호를 세고 임계값과 결정 트리로 분류하는 법. 특히 "엣지 케이스가 기본값과 임계값을 정한다"는 실전 감각(연차보고서 표지, 타일드 스캔, 신문 다단)을 소스 주석의 캘리브레이션 데이터와 함께 볼 수 있다. 데이터 없이도 견고한 분류기를 짜는 사고법의 표본.

③ Rust 크로스 플랫폼 배포 (1 코어 → 4 언어)

cdylib + pyo3(Python) + napi-rs(Node) + wasm-bindgen(브라우저)로 하나의 로직을 네 채널에 내보내는 전체 그림. cfg(target_arch="wasm32")같은 코드를 타깃별로 다르게 조립(rayon 병렬 vs 싱글스레드, 파일 로드 vs include_dir 임베드)하는 조건부 컴파일도 좋은 교재다.

④ 성능을 의식한 파싱 설계

"전체를 렌더링하지 않고 표본 연산자만 센다", "detection과 extraction이 문서를 한 번만 로드해 공유한다", "ScanStrategy로 검사량을 조절한다" — 일을 덜 하도록 설계해서 속도를 얻는 사고를 구체적 코드로 배운다. 35배 속도차의 정체가 마법이 아니라 이 절약들의 합이라는 걸 확인할 수 있다.

⑤ 라이브러리 품질 규율

clippy -D warnings 강제, thiserror 에러 설계, 손으로 조립한 최소 PDF 픽스처로 하는 결정적 테스트(858개+), 스냅샷 회귀 테스트, 비밀번호를 Debug에서 [REDACTED]로 가리는 보안 습관 — 프로덕션 Rust 크레이트의 위생을 통째로 훑을 수 있다.

실습 아이디어(가볍게)
"내 PDF들"로 판별기를 돌려 보기

내 컴퓨터의 잡다한 PDF(영수증·논문·스캔한 서류·워드 출력본)를 모아 detect-pdf로 돌려 보면, 어떤 게 TextBased고 어떤 게 Scanned인지, confidence가 어떻게 갈리는지 내 데이터로 체감된다. 오판이 나오면 그 PDF의 dump_ops 출력을 보며 "왜 그렇게 판정했나"를 역추적하는 게 최고의 공부다.

7시스템 / 빌드 요구사항

돌려 보려면 무엇이 필요한가
쓰는 방식필요한 것
Rust 라이브러리/CLIRust 툴체인(1.7x+, edition 2021). cargo build --release 또는 cargo run --bin pdf2md -- 파일.pdf
Python 패키지Python 3.8+ (abi3-py38). pip install 시 프리빌트 휠, 소스 빌드 땐 maturin + Rust
Node/BunNode.js. 프리빌트 바이너리(linux-x64-gnu / darwin-arm64 / win32-x64-msvc) 제공 → Rust 툴체인 불필요
브라우저 WASM모던 브라우저. init() 후 동기 실행. 대형 문서는 Web Worker 권장(싱글스레드)

8직접 해볼 수 있는 실습 과제

난이도별로. 위에서 아래로 갈수록 어렵다
과제 1 난이도 ●○○○○

CLI로 PDF 3종을 분류해 보기

워드 출력본, 스캔한 서류, 논문 PDF를 하나씩 준비해 detect-pdf 파일.pdf --json을 돌려 보자. pdf_type·confidence·pages_needing_ocr가 어떻게 다른지 비교하면, 이 도구가 반환하는 "라우팅 정보"의 실체가 잡힌다. 그다음 pdf2md 파일.pdf로 텍스트 PDF가 Markdown으로 얼마나 깔끔하게 나오는지 확인.

과제 2 난이도 ●●○○○

스마트 라우팅 스크립트 흉내 내기

Python 바인딩으로 폴더 안 PDF를 전부 classify_pdf한 뒤, TextBased면 extract_pages_markdown으로 로컬 처리, 아니면 "OCR 대기열에 넣음"이라고 출력만 하는 라우터를 20줄로 짜 보자. Firecrawl이 실제로 하는 일의 축소판이다. 처리 시간을 로그로 찍어 로컬 경로가 얼마나 빠른지 체감할 것.

과제 3 난이도 ●●●○○

휴리스틱 임계값을 바꿔 오판을 만들어 보기

DetectionConfigtext_page_ratio_threshold(기본 0.6)나 min_text_ops_per_page(기본 3)를 극단값으로 바꿔 빌드한 뒤, 같은 PDF 세트를 다시 분류해 보자. 어떤 문서가 어느 임계에서 분류가 뒤집히는지를 관찰하면, 저자가 왜 하필 0.6과 3을 골랐는지, 임계값 튜닝이 정확도에 어떻게 작동하는지 몸으로 이해된다.

과제 4 난이도 ●●●●○

손으로 최소 PDF를 조립해 detector를 속여 보기

tests/integration_tests.rsmake_minimal_text_pdf() 헬퍼를 참고해, 바이트 단위로 "글자 명령 2개 + 커다란 이미지 1개"짜리 PDF를 직접 만들어 보자. 이게 Mixed로 잡히는지 Scanned로 잡히는지 예측하고 실제로 돌려 검증. PDF 포맷을 바닥부터 이해하게 되는, 이 레포에서 가장 남는 실습이다.

과제 5 난이도 ●●●●●

새 휴리스틱 하나를 추가하고 회귀 테스트로 지키기

예: "가로 방향 스캔(landscape 스캔본)" 같은 미탐 케이스를 하나 정해, detector.rs에 신호와 조건을 추가한다. 그다음 tests/fixtures/에 해당 PDF를 넣고 스냅샷 테스트를 작성해, 기존 858개+ 테스트가 하나도 깨지지 않으면서 새 케이스가 통과하게 만든다. 기존 정확도를 해치지 않고 규칙을 늘리는 것이 얼마나 조심스러운 일인지 배우는 과제.

9관련 기술 심화 로드맵 (주차별)

이 레포를 계기로 공부 계획을 짠다면
주차주제구체적으로
1주PDF 포맷의 진실객체·xref·content stream·연산자, 폰트(Type1/TrueType/Type3/CID), Identity-H와 ToUnicode. detector.rs·extractor/content_stream.rs를 표본으로
2주텍스트 레이아웃 복원다단(column) 감지(투영 히스토그램·valley detection), 읽기 순서, 표 감지 3전략(rect/line/heuristic). extractor/layout.rs·tables/
3주Rust FFI & 크로스 빌드cdylib, pyo3(Python), napi-rs(Node), wasm-bindgen(브라우저). 조건부 컴파일 cfg(target_arch)로 타깃별 조립
4주규칙 기반 분류 & 캘리브레이션휴리스틱 설계, 임계값 튜닝, 엣지 케이스 주도 개발. 소스 주석의 실제 캘리브레이션 데이터 읽기
5주(선택)OCR 파이프라인 통합이 판별기 뒤에 붙일 OCR(Tesseract·PaddleOCR·클라우드 OCR API), 하이브리드 라우팅, RAG 문서 인제스트 파이프라인 설계

10핵심 키워드 사전

이 문서에 나온 개념 압축 정리
용어
스마트 라우팅(smart PDF routing)
도착한 PDF를 먼저 싸게 분류해서, 텍스트면 로컬 추출(빠름·무료)로, 스캔이면 OCR(느림·유료)로 보내는 전략. pdf-inspector의 존재 이유이자 핵심 유스케이스.
용어
content stream / 연산자(Tj·TJ·Do)
PDF 페이지의 그리기 대본. Tj/TJ=글자 그리기, Do=이미지/외부객체 그리기. pdf-inspector는 이 연산자의 비율만 세어 렌더링 없이 문서를 판별한다.
용어
PdfType (4종)
분류 결과. TextBased(글자 명령 충분→로컬 처리), Scanned(이미지뿐→OCR), ImageBased(이미지 중심), Mixed(글자+이미지 혼재→부분 OCR). 각각 confidence(0~1)가 붙는다.
용어
confidence & pages_needing_ocr
분류 신뢰도와, "몇 번째 페이지가 OCR을 필요로 하는가"의 페이지 단위 목록. 300페이지 중 스캔된 3페이지만 골라 OCR하게 해 비용을 100배 아낀다.
용어
ScanStrategy
몇 페이지를 검사할지 정하는 전략. EarlyExit/Full/Sample(n)/Pages(vec). 기본은 Sample(8) — 표지만 이미지인 연차보고서 오판을 피하기 위한 선택.
용어
템플릿 이미지 / 타일드 스캔
페이지 절반 이상을 덮는 대형 배경 이미지(스캔 의심). 스캔본을 여러 조각으로 쪼갠 경우도 총면적으로 잡아낸다(JBIG2 strip 대응).
용어
벡터 아웃라인 텍스트
글자를 폰트가 아니라 선(path)으로 직접 그린 PDF. 겉보기엔 글자지만 복사·검색 불가. path가 글자 대비 200배↑ + 글자 종류 30 미만이면 감지→OCR 권고.
용어
Identity-H / ToUnicode
CID 폰트의 인코딩과, CID를 유니코드로 되돌리는 매핑표. ToUnicode가 없으면 글자를 복원 못 해(깨진 텍스트) OCR로 보내야 한다. CJK PDF에서 특히 중요.
용어
lopdf
pdf-inspector의 유일한 PDF 파싱 의존. 순수 Rust라 C 라이브러리(pdfium 등) 링크가 없다. 그래서 빌드가 가볍고 WASM으로도 나간다.
용어
cdylib + pyo3 / napi-rs / wasm-bindgen
Rust 코어 하나를 C 호환 동적 라이브러리로 만들어(Python·Node) 또는 WASM으로 컴파일해, 4개 언어에 같은 로직을 배포하는 바인딩 도구 묶음.
용어
조건부 컴파일 cfg(target_arch)
같은 소스를 타깃에 따라 다르게 조립하는 Rust 기능. 네이티브는 rayon 병렬·파일 로드, WASM은 싱글스레드·include_dir 임베드로 갈라진다.
용어
코드가 모델을 이긴다
ML 없이 PDF 구조를 직접 세는 규칙만으로 판별한다는 철학. 결정적·무GPU·밀리초. "문제를 잘 이해하면 무거운 모델이 필요 없다"의 사례.

11참고 링크

더 파고들 때