- 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>
241 lines
9.1 KiB
Python
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'',
|
|
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
|