Files
doc2md/converters/pdf.py
minsung 2ec2759a20 feat: Implement full conversion pipeline (PDF/HWP/HWPX/HML/HTML)
- convert.py: 통합 CLI, --json 출력, --scan 폴더 모드
- converters/pdf.py: 페이지별 분류(text/diagram/mixed) + marker-pdf + PNG 렌더링
- converters/hwp.py: COM 자동화 + pyhwp fallback
- converters/hwpx.py: ZIP+XML 직접 파싱, 이미지 추출
- converters/hml.py: XML 파싱, Base64 이미지 추출, colspan/rowspan HTML 표
- converters/html.py: html2text (body_width=0)
- requirements.txt: 최소 의존성
- .env.example: 환경변수 템플릿

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 09:06:34 +09:00

241 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""
PDF → Markdown 변환기 (페이지별 분류 + 라우팅)
페이지 타입:
text - 텍스트 위주 → marker-pdf 추출
text-with-photo - 텍스트 + 사진 → marker-pdf + 이미지 크롭
diagram - 다이어그램/도면 → 페이지 PNG 렌더링 (에이전트가 Vision으로 처리)
image-heavy - 텍스트 거의 없음 → 페이지 PNG 렌더링
"""
from __future__ import annotations
import io
import re
from pathlib import Path
import fitz # PyMuPDF
from PIL import Image
# ── 페이지 분류 ───────────────────────────────────────────────────────────────
def _pix_to_pil(pix: fitz.Pixmap) -> Image.Image:
"""PyMuPDF Pixmap → PIL Image."""
mode = "RGBA" if pix.alpha else "RGB"
return Image.frombytes(mode, (pix.width, pix.height), pix.samples)
def _is_diagram_image(img: Image.Image) -> bool:
"""
래스터 이미지가 다이어그램인지 판별.
다이어그램 특성: 제한된 색상 팔레트 + 높은 흰 배경 비율.
"""
# 너무 작은 이미지(로고, 아이콘)는 스킵
if img.width < 100 or img.height < 100:
return False
# 색상 수 (64색으로 양자화 후 실제 사용 색상)
small = img.resize((200, 200), Image.LANCZOS).convert("RGB")
quantized = small.quantize(colors=64)
color_count = len(set(quantized.getdata()))
# 흰 배경 비율
gray = small.convert("L")
pixels = list(gray.getdata())
white_ratio = sum(1 for p in pixels if p > 240) / len(pixels)
return color_count < 32 and white_ratio > 0.35
def classify_page(page: fitz.Page, doc: fitz.Document) -> str:
"""
페이지를 분류한다.
반환값: 'text' | 'text-with-photo' | 'diagram' | 'image-heavy'
"""
text = page.get_text().strip()
text_len = len(text)
page_area = page.rect.width * page.rect.height
drawings = page.get_drawings()
images = page.get_images(full=True)
text_density = text_len / page_area * 10_000 # 면적 대비 문자 수
# 벡터 드로잉 밀도 (flowchart, CAD export 등은 수백 개 드로잉 포함)
drawing_density = len(drawings) / page_area * 10_000
# 1) 텍스트가 충분하면 텍스트 계열
if text_density > 4:
if not images:
return "text"
# 이미지가 있어도 작은 이미지(로고 등)면 text
large_images = [
img for img in images
if doc.extract_image(img[0])["width"] > 150
and doc.extract_image(img[0])["height"] > 150
]
return "text-with-photo" if large_images else "text"
# 2) 벡터 드로잉이 많으면 다이어그램
if drawing_density > 1.5:
return "diagram"
# 3) 래스터 이미지가 있으면 다이어그램 여부 분석
if images:
for img_info in images[:3]: # 최대 3개만 검사 (속도)
try:
xref = img_info[0]
pix = fitz.Pixmap(doc, xref)
if pix.colorspace and pix.colorspace.n > 1:
pil = _pix_to_pil(pix)
if _is_diagram_image(pil):
return "diagram"
except Exception:
pass
return "text-with-photo" if text_len > 50 else "image-heavy"
# 4) 텍스트도 이미지도 거의 없음
return "image-heavy" if not text_len else "text"
# ── 페이지 PNG 렌더링 ─────────────────────────────────────────────────────────
def _render_page_png(page: fitz.Page, output_path: Path, scale: float = 2.0) -> None:
"""페이지를 고해상도 PNG로 렌더링."""
mat = fitz.Matrix(scale, scale)
pix = page.get_pixmap(matrix=mat)
pix.save(str(output_path))
# ── 메인 변환 함수 ────────────────────────────────────────────────────────────
def convert_pdf(pdf_path: Path, output_dir: Path) -> dict:
"""
PDF → MD 변환. AGENT_GUIDE.md 스펙의 JSON 구조를 dict로 반환.
반환 dict:
status : "ok" | "error"
input : str
output : str (md 파일 경로)
format : "pdf"
pages : list of {n, type, image?}
has_diagrams : bool
diagram_pages : list[int]
images : list[str]
error? : str
"""
pdf_path = Path(pdf_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
images_dir = output_dir / f"{pdf_path.stem}_images"
md_path = output_dir / f"{pdf_path.stem}.md"
result: dict = {
"status": "ok",
"input": str(pdf_path),
"output": str(md_path),
"format": "pdf",
"pages": [],
"has_diagrams": False,
"diagram_pages": [],
"images": [],
}
try:
doc = fitz.open(str(pdf_path))
# ── 1) 각 페이지 분류 ──────────────────────────────────────────────
page_types: list[str] = []
for page in doc:
ptype = classify_page(page, doc)
page_types.append(ptype)
result["pages"].append({"n": page.number + 1, "type": ptype})
diagram_page_nums = [
i + 1 for i, t in enumerate(page_types)
if t in ("diagram", "image-heavy")
]
result["has_diagrams"] = bool(diagram_page_nums)
result["diagram_pages"] = diagram_page_nums
# ── 2) 텍스트 추출 (marker-pdf) ────────────────────────────────────
text_sections: list[str] = []
try:
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.output import text_from_rendered
converter = PdfConverter(artifact_dict=create_model_dict())
rendered = converter(str(pdf_path))
full_text, _, marker_images = text_from_rendered(rendered)
# marker 추출 이미지 저장
if marker_images:
images_dir.mkdir(exist_ok=True)
for img_name, img_data in marker_images.items():
try:
img_dest = images_dir / img_name
if isinstance(img_data, Image.Image):
img_data.save(str(img_dest))
elif isinstance(img_data, bytes) and img_data:
img_dest.write_bytes(img_data)
result["images"].append(str(img_dest))
except Exception:
pass
# 이미지 경로 prefix 수정
full_text = re.sub(
r'!\[([^\]]*)\]\((?!http)([^)]+)\)',
rf'![\1]({pdf_path.stem}_images/\2)',
full_text,
)
text_sections.append(full_text)
except ImportError:
# marker-pdf 없으면 PyMuPDF 텍스트 추출로 fallback
pages_text = []
for page in doc:
t = page.get_text().strip()
if t:
pages_text.append(t)
text_sections.append("\n\n---\n\n".join(pages_text))
# ── 3) 다이어그램 페이지 PNG 렌더링 ────────────────────────────────
if diagram_page_nums:
images_dir.mkdir(exist_ok=True)
diagram_section_lines = ["\n\n---\n\n## 다이어그램 페이지\n"]
for page_num in diagram_page_nums:
page = doc[page_num - 1]
img_name = f"page_{page_num}.png"
img_path = images_dir / img_name
_render_page_png(page, img_path)
result["images"].append(str(img_path))
diagram_section_lines.append(
f"\n### Page {page_num}\n"
f"![Page {page_num} — 다이어그램]"
f"({pdf_path.stem}_images/{img_name})\n"
)
# pages 항목에 image 경로 추가
for p in result["pages"]:
if p["n"] == page_num:
p["image"] = str(img_path)
text_sections.append("".join(diagram_section_lines))
doc.close()
# ── 4) MD 파일 저장 ────────────────────────────────────────────────
final_md = re.sub(r'\n{3,}', '\n\n', "\n\n".join(text_sections)).strip()
md_path.write_text(final_md, encoding="utf-8")
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
import traceback
traceback.print_exc()
return result