#!/usr/bin/env python3
"""
pdf2md.py - PDF를 Markdown으로 변환하는 단일 파일 스크립트

설치:
    pip install pymupdf            # 권장 (제목/굵게/이미지 추출 지원)
    pip install pypdf              # 대체 (텍스트만 추출)

사용법:
    python pdf2md.py input.pdf                  # input.md 생성
    python pdf2md.py input.pdf -o out.md        # 출력 파일 지정
    python pdf2md.py ./pdfs/ -o ./md/           # 폴더 일괄 변환
    python pdf2md.py input.pdf --images         # 이미지도 추출 (PyMuPDF 필요)
    python pdf2md.py input.pdf --page-breaks    # 페이지 구분선 삽입
    python pdf2md.py input.pdf --stdout         # 표준 출력으로 내보내기
    python pdf2md.py input.pdf --no-tables      # 표 인식 끄기
    python pdf2md.py scan.pdf --ocr             # 스캔 PDF: 텍스트 없는 페이지를 OCR (Tesseract 필요)
    python pdf2md.py scan.pdf --ocr --ocr-lang kor+eng

OCR 준비 (스캔 PDF용):
    PyMuPDF에 Tesseract 엔진이 내장되어 있어 별도 설치가 필요 없습니다.
    언어 데이터(kor/eng .traineddata)가 없으면 처음 한 번 자동으로 내려받아 ~/.cache/pdf2md/tessdata 에 저장합니다.
    이미 Tesseract가 설치되어 있으면 그 tessdata 폴더(또는 환경 변수 TESSDATA_PREFIX)를 우선 사용합니다.

변환 규칙 (휴리스틱):
    - 본문 글자 크기보다 큰 줄  -> 제목 (# / ## / ###)
    - 짧고 굵은 글씨 줄          -> ### 제목
    - •, ◦, ▪, -, – 로 시작하는 줄 -> 목록 항목
    - 여러 페이지에 반복되는 줄  -> 머리글/바닥글로 보고 제거
    - 줄 끝 하이픈(-)            -> 다음 줄과 이어 붙임
    - 여러 줄에 걸쳐 열을 이루는 조각 -> Markdown 표
    - 2단 레이아웃              -> 왼쪽 단 -> 오른쪽 단 순서
"""

from __future__ import annotations

import argparse
import math
import re
import sys
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable

# ---------------------------------------------------------------------------
# 공용 자료구조
# ---------------------------------------------------------------------------


@dataclass
class Line:
    text: str
    size: float  # 대표 글자 크기 (pt)
    bold: bool
    y: float  # 줄의 상단 y 좌표 (위에서 아래로 증가)
    x: float  # 줄의 시작 x 좌표
    height: float  # 줄 높이
    x_end: float = 0.0  # 줄의 끝 x 좌표
    kind: str = "text"  # "text" 또는 "table" (Markdown 표 원문)
    grid: list[list[str]] | None = None  # 표일 때 셀 내용
    row_tops: list[float] | None = None  # 표일 때 각 행의 상단 y


@dataclass
class Page:
    number: int
    height: float = 0.0  # 페이지 높이 (pt)
    width: float = 0.0  # 페이지 너비 (pt)
    lines: list[Line] = field(default_factory=list)
    images: list[str] = field(default_factory=list)  # 저장된 이미지 상대 경로
    ocr: bool = False  # OCR로 읽은 페이지 (글자 크기·굵기 정보가 부정확)


BULLET_RE = re.compile(r"^\s*([•◦▪▫●○■□‣⁃·\-–—*])\s+")
NUMBERED_RE = re.compile(r"^\s*(\d+|[a-zA-Z]|[ivxIVX]+)[.)]\s+")
PAGE_NUMBER_RE = re.compile(r"^\s*(page\s*)?[-–]?\s*\d{1,4}\s*((/|of)\s*\d{1,4})?\s*[-–]?\s*$", re.I)
# 가나/한자는 공백 없이 이어 붙인다 (한글은 띄어쓰기를 쓰므로 제외)
CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")  # 일부 글리프가 NUL 등으로 추출됨
CJK_NOSPACE_RE = re.compile(r"[぀-ヿ㐀-䶿一-鿿]")


# ---------------------------------------------------------------------------
# PDF 읽기: PyMuPDF (권장)
# ---------------------------------------------------------------------------


def read_with_pymupdf(pdf_path: Path, image_dir: Path | None, tables: bool = True, ocr: str | None = None) -> list[Page]:
    import fitz  # type: ignore

    doc = fitz.open(pdf_path)
    pages: list[Page] = []
    tessdata = _find_tessdata(ocr) if ocr else None
    ocr_pages: list[int] = []
    for pno, page in enumerate(doc, start=1):
        p = Page(number=pno, height=float(page.rect.height), width=float(page.rect.width))
        textpage = None
        if ocr and len(page.get_text("text").strip()) < 20:  # 텍스트 레이어가 없는(스캔) 페이지만 OCR
            textpage = _ocr_textpage(page, ocr, tessdata, pno)
            ocr_pages.append(pno)
            p.ocr = True
        data = page.get_text("dict", textpage=textpage) if textpage is not None else page.get_text("dict")
        for block in data.get("blocks", []):
            if block.get("type") != 0:  # 0 = 텍스트 블록
                continue
            for ln in block.get("lines", []):
                spans = [s for s in ln.get("spans", []) if s.get("text", "").strip()]
                if not spans:
                    continue
                text = CONTROL_RE.sub("", "".join(s["text"] for s in ln["spans"])).strip()
                if not text:
                    continue
                # 글자 수 가중 평균이 아닌 "가장 많은 글자를 차지하는 크기"를 대표값으로
                size_counter: Counter[float] = Counter()
                bold_chars = total_chars = 0
                for s in spans:
                    n = len(s["text"].strip())
                    size_counter[round(s["size"], 1)] += n
                    total_chars += n
                    is_bold = bool(s.get("flags", 0) & 16) or "bold" in s.get("font", "").lower()
                    if is_bold:
                        bold_chars += n
                size = size_counter.most_common(1)[0][0]
                x0, y0, x1, y1 = ln["bbox"]
                p.lines.append(
                    Line(
                        text=text,
                        size=size,
                        bold=total_chars > 0 and bold_chars / total_chars > 0.6,
                        y=y0,
                        x=x0,
                        height=y1 - y0,
                        x_end=x1,
                    )
                )
        p.lines = _finish_lines(p.lines, p.width, tables)

        if image_dir is not None:
            image_dir.mkdir(parents=True, exist_ok=True)
            for idx, img in enumerate(page.get_images(full=True), start=1):
                xref = img[0]
                try:
                    pix = fitz.Pixmap(doc, xref)
                    if pix.n - pix.alpha >= 4:  # CMYK -> RGB
                        pix = fitz.Pixmap(fitz.csRGB, pix)
                    if pix.width < 32 or pix.height < 32:
                        continue
                    name = f"{pdf_path.stem}_p{pno}_{idx}.png"
                    pix.save(image_dir / name)
                    p.images.append(f"{image_dir.name}/{name}")
                except Exception as exc:  # 손상된 이미지 등은 건너뜀
                    print(f"  [warn] p{pno} 이미지 {idx} 추출 실패: {exc}", file=sys.stderr)
        pages.append(p)
    doc.close()
    if ocr:
        print(f"[ocr] {len(ocr_pages)}/{len(pages)} 페이지를 OCR 처리했습니다" + (f": {ocr_pages}" if 0 < len(ocr_pages) <= 20 else ""), file=sys.stderr)
    return pages


TESSDATA_CANDIDATES = [
    "/opt/homebrew/share/tessdata",
    "/usr/local/share/tessdata",
    "/usr/share/tesseract-ocr/5/tessdata",
    "/usr/share/tesseract-ocr/4.00/tessdata",
    "/usr/share/tessdata",
    r"C:\\Program Files\\Tesseract-OCR\\tessdata",
]
TESSDATA_CACHE = Path.home() / ".cache" / "pdf2md" / "tessdata"
TESSDATA_URL = "https://github.com/tesseract-ocr/tessdata_fast/raw/main/{lang}.traineddata"


def _find_tessdata(lang: str) -> str | None:
    """OCR 언어 데이터 폴더를 찾고, 없으면 필요한 언어 파일을 내려받는다.

    PyMuPDF 휠에는 Tesseract 엔진이 내장되어 있으므로 언어 데이터(.traineddata)만 있으면 된다.
    """
    import os

    langs = [l for l in lang.replace(",", "+").split("+") if l]
    env = os.environ.get("TESSDATA_PREFIX")
    for cand in ([env] if env else []) + TESSDATA_CANDIDATES + [str(TESSDATA_CACHE)]:
        if cand and all(Path(cand, f"{l}.traineddata").exists() for l in langs):
            return cand
    # 자동 다운로드 (처음 한 번)
    TESSDATA_CACHE.mkdir(parents=True, exist_ok=True)
    import urllib.request

    for l in langs:
        dest = TESSDATA_CACHE / f"{l}.traineddata"
        if dest.exists():
            continue
        url = TESSDATA_URL.format(lang=l)
        print(f"[ocr] 언어 데이터 내려받는 중: {l} <- {url}", file=sys.stderr)
        try:
            urllib.request.urlretrieve(url, dest)
        except Exception as exc:
            sys.exit(
                f"OCR 언어 데이터({l})를 내려받지 못했습니다: {exc}\n"
                f"직접 {url} 을 받아 {TESSDATA_CACHE} 에 두거나, Tesseract 언어팩을 설치한 뒤\n"
                "환경 변수 TESSDATA_PREFIX 를 tessdata 폴더로 설정하세요."
            )
    return str(TESSDATA_CACHE)


def _ocr_textpage(page, lang: str, tessdata: str | None, pno: int):
    try:
        return page.get_textpage_ocr(language=lang, dpi=300, full=True, tessdata=tessdata)
    except Exception as exc:
        sys.exit(
            f"OCR 실패 (p{pno}): {exc}\n"
            "PyMuPDF 최신 버전인지 확인하세요: pip install -U pymupdf\n"
            f"언어 데이터 폴더: {tessdata or '(환경 변수 TESSDATA_PREFIX)'}"
        )


# ---------------------------------------------------------------------------
# PDF 읽기: pypdf (대체, 텍스트만)
# ---------------------------------------------------------------------------


def read_with_pypdf(pdf_path: Path, tables: bool = True) -> list[Page]:
    from pypdf import PdfReader  # type: ignore

    reader = PdfReader(str(pdf_path))
    pages: list[Page] = []
    for pno, page in enumerate(reader.pages, start=1):
        height = float(page.mediabox.height)
        p = Page(number=pno, height=height, width=float(page.mediabox.width))
        lines: list[Line] = []

        def visitor(text, cm, tm, font_dict, font_size):
            t = CONTROL_RE.sub("", text).strip()
            if not t:
                return
            # 실제 글자 크기 = 폰트 크기 x 텍스트 행렬 배율 x CTM 배율
            scale = (math.hypot(tm[0], tm[1]) * math.hypot(cm[0], cm[1])) or 1.0
            size = float(font_size or 10) * scale
            # 페이지 좌표로 변환하고, pypdf의 y는 아래에서 위로 증가하므로 위에서부터의 거리로 바꾼다
            x_pt = tm[4] * cm[0] + tm[5] * cm[2] + cm[4]
            y_pt = tm[4] * cm[1] + tm[5] * cm[3] + cm[5]
            font_name = ""
            if font_dict is not None:
                font_name = str(font_dict.get("/BaseFont", ""))
            # pypdf는 글리프 폭을 주지 않으므로 글자 수로 줄 끝을 어림한다
            x_end = x_pt + len(t) * size * 0.5
            lines.append(
                Line(
                    text=t, size=round(size, 1), bold="bold" in font_name.lower(),
                    y=height - y_pt - size, x=x_pt, height=size, x_end=x_end,
                )
            )

        try:
            page.extract_text(visitor_text=visitor)
        except Exception:
            lines = []
        if not lines:  # visitor가 비어 있으면 일반 텍스트로 대체
            for raw in (page.extract_text() or "").splitlines():
                if raw.strip():
                    lines.append(Line(text=raw.strip(), size=10.0, bold=False, y=len(lines) * 12.0, x=0, height=12.0))
        p.lines = _finish_lines(lines, p.width, tables)
        pages.append(p)
    return pages


# ---------------------------------------------------------------------------
# 줄 정리: 같은 기준선 조각 병합, 표 인식, 다단 읽기 순서
# ---------------------------------------------------------------------------


def _finish_lines(lines: list[Line], page_width: float, tables: bool) -> list[Line]:
    lines = merge_same_baseline(lines)
    if tables:
        lines = detect_tables(lines, page_width)
    return reorder_columns(lines, page_width)


def merge_same_baseline(lines: list[Line]) -> list[Line]:
    """같은 기준선에 놓인 조각(절 번호 + 제목, 위 첨자 등)을 한 줄로 합친다.

    가로 간격이 1.2em(짧은 조각은 3em)을 넘는 조각은 합치지 않으므로 단 사이 여백은 그대로 남고,
    2단 문서의 좌우 단이 한 줄로 섞이지 않는다.
    """
    def can_join(g: dict, h: dict) -> float:
        """h 를 g 에 붙일 수 있으면 가로 간격을, 아니면 inf 를 돌려준다."""
        overlap = min(g["bottom"], h["bottom"]) - max(g["top"], h["top"])
        if overlap <= 0.5 * min(g["bottom"] - g["top"], h["bottom"] - h["top"]):
            return math.inf
        if h["x"] >= g["x_end"]:
            gap = h["x"] - g["x_end"]
        elif h["x_end"] <= g["x"]:
            gap = g["x"] - h["x_end"]
        else:
            gap = 0.0  # 가로로 겹침
        em = max(g["size"], h["size"])
        limit = (3.0 if (h["chars"] <= 4 or g["chars"] <= 4) else 1.2) * em
        return gap if gap <= limit else math.inf

    def absorb(g: dict, h: dict) -> None:
        g["pieces"].extend(h["pieces"])
        g["top"], g["bottom"] = min(g["top"], h["top"]), max(g["bottom"], h["bottom"])
        g["center"] = (g["top"] + g["bottom"]) / 2
        g["x"], g["x_end"] = min(g["x"], h["x"]), max(g["x_end"], h["x_end"])
        if h["chars"] > g["chars"]:
            g["size"] = h["size"]
        g["chars"] += h["chars"]

    groups: list[dict] = [
        {"pieces": [l], "top": l.y, "bottom": l.y + l.height, "center": l.y + l.height / 2,
         "x": l.x, "x_end": l.x_end, "size": l.size, "chars": len(l.text)}
        for l in lines
    ]
    for _ in range(4):  # 더 이상 합쳐지지 않을 때까지 반복 (위 첨자 사이에 끼인 조각 등)
        groups.sort(key=lambda g: (g["center"], g["x"]))
        out: list[dict] = []
        merged_any = False
        for h in groups:
            best = None
            best_gap = math.inf
            for g in reversed(out):
                if g["center"] < h["center"] - 2 * (h["bottom"] - h["top"]):  # 너무 위에 있는 줄
                    break
                gap = can_join(g, h)
                if gap < best_gap:
                    best, best_gap = g, gap
            if best is None:
                out.append(h)
            else:
                absorb(best, h)
                merged_any = True
        groups = out
        if not merged_any:
            break

    merged: list[Line] = []
    for g in groups:
        pieces = sorted(g["pieces"], key=lambda l: l.x)
        text = pieces[0].text
        for a, b in zip(pieces, pieces[1:]):
            text = _join_pieces(text, b.text, b.x - a.x_end, max(a.size, b.size))
        size_chars: Counter[float] = Counter()
        bold_chars = total = 0
        for pc in pieces:
            k = len(pc.text)
            size_chars[pc.size] += k
            total += k
            if pc.bold:
                bold_chars += k
        merged.append(
            Line(
                text=text,
                size=size_chars.most_common(1)[0][0],
                bold=total > 0 and bold_chars / total > 0.6,
                y=g["top"],
                x=g["x"],
                height=g["bottom"] - g["top"],
                x_end=g["x_end"],
            )
        )
    return merged


def _join_pieces(left: str, right: str, gap: float, em: float) -> str:
    if left.endswith(" ") or right.startswith(" ") or gap > em * 0.12:
        return (left + " " + right).strip()
    return (left + right).strip()


# ---------------------------------------------------------------------------
# 표 인식
# ---------------------------------------------------------------------------


def detect_tables(lines: list[Line], page_width: float) -> list[Line]:
    """표를 찾되, 2단 레이아웃이면 각 단 안에서만 찾는다 (왼쪽 단의 표와 오른쪽 단의 본문이 섞이지 않도록)."""
    kinds = column_kinds(lines, page_width)
    if kinds is None:
        return _detect_tables_in(lines, page_width)
    groups: dict[str, list[Line]] = {"left": [], "right": [], "span": []}
    for l, k in zip(lines, kinds):
        groups[k].append(l)
    left = _detect_tables_in(groups["left"], page_width)
    right = _detect_tables_in(groups["right"], page_width)
    left, right = _pair_side_tables(left, right)
    out = left + right + groups["span"]
    out.sort(key=lambda l: (l.y + min(l.height, l.size) / 2, l.x))
    return out


def _pair_side_tables(left: list[Line], right: list[Line]) -> tuple[list[Line], list[Line]]:
    """양쪽 단에 세로로 나란히 놓인 표는 행을 맞춰 페이지 전체 너비의 한 표로 합친다."""
    for i, tl in enumerate(left):
        if tl.kind != "table" or not tl.grid or not tl.row_tops:
            continue
        for j, tr in enumerate(right):
            if tr.kind != "table" or not tr.grid or not tr.row_tops:
                continue
            tol = max(tl.size, tr.size)
            overlap = min(tl.y + tl.height, tr.y + tr.height) - max(tl.y, tr.y)
            if overlap < 0.8 * min(tl.height, tr.height):
                continue
            wl, wr = len(tl.grid[0]), len(tr.grid[0])
            grid: list[list[str]] = []
            tops: list[float] = []
            a = b = matched = 0
            while a < len(tl.grid) or b < len(tr.grid):
                if b >= len(tr.grid) or (a < len(tl.grid) and tl.row_tops[a] < tr.row_tops[b] - tol):
                    grid.append(tl.grid[a] + [""] * wr)
                    tops.append(tl.row_tops[a])
                    a += 1
                elif a >= len(tl.grid) or tr.row_tops[b] < tl.row_tops[a] - tol:
                    grid.append([""] * wl + tr.grid[b])
                    tops.append(tr.row_tops[b])
                    b += 1
                else:
                    grid.append(tl.grid[a] + tr.grid[b])
                    tops.append(min(tl.row_tops[a], tr.row_tops[b]))
                    a += 1
                    b += 1
                    matched += 1
            if matched < 0.7 * min(len(tl.grid), len(tr.grid)):
                continue
            top = min(tl.y, tr.y)
            left[i] = Line(
                text=_render_table(grid), size=tl.size, bold=False, y=top, x=min(tl.x, tr.x),
                height=max(tl.y + tl.height, tr.y + tr.height) - top, x_end=max(tl.x_end, tr.x_end),
                kind="table", grid=grid, row_tops=tops,
            )
            del right[j]
            break
    return left, right


def _render_table(grid: list[list[str]]) -> str:
    def esc(s: str) -> str:
        return s.replace("|", "\\|")

    def line(row: list[str]) -> str:
        return "| " + " | ".join(esc(c) for c in row) + " |"

    return "\n".join([line(grid[0]), "|" + " --- |" * len(grid[0]), *(line(r) for r in grid[1:])])


def _detect_tables_in(lines: list[Line], page_width: float) -> list[Line]:
    """가로로 크게 떨어진 조각들이 여러 줄에 걸쳐 열을 이루면 하나의 Markdown 표 줄로 바꾼다.

    같은 기준선의 조각은 merge_same_baseline 에서 1.2em 이하 간격만 합쳐지므로,
    그보다 넓게 떨어진 조각(표의 셀)은 여기서 행/열로 묶인다.
    """
    if len(lines) < 4:
        return lines
    rows: list[list[Line]] = []
    for ln in sorted(lines, key=lambda l: (l.y + l.height / 2, l.x)):
        if rows:
            r = rows[-1]
            top, bottom = _row_top(r), _row_bottom(r)
            overlap = min(bottom, ln.y + ln.height) - max(top, ln.y)
            if overlap > 0.5 * min(bottom - top, ln.height):
                r.append(ln)
                continue
        rows.append([ln])

    out: list[Line] = []
    i = 0
    while i < len(rows):
        if len(rows[i]) < 2:
            out.extend(rows[i])
            i += 1
            continue
        j = i
        while j + 1 < len(rows):
            nxt = rows[j + 1]
            gap = _row_top(nxt) - _row_bottom(rows[j])
            if len(nxt) >= 2 and gap <= 1.5 * _row_height(rows[j]):
                j += 1
                continue
            # 셀 안에서 줄바꿈된 한 줄짜리 행: 바로 다음 행이 다시 여러 셀이면 표에 포함
            if (
                len(nxt) == 1
                and gap <= 0.8 * _row_height(rows[j])
                and j + 2 < len(rows)
                and len(rows[j + 2]) >= 2
                and _row_top(rows[j + 2]) - _row_bottom(nxt) <= 1.5 * _row_height(nxt)
            ):
                j += 2
                continue
            break
        table = _rows_to_table(rows[i : j + 1], page_width)
        if table is not None:
            out.append(table)
            i = j + 1
        else:
            out.extend(rows[i])
            i += 1
    return out


def _row_top(r: list[Line]) -> float:
    return min(l.y for l in r)


def _row_bottom(r: list[Line]) -> float:
    return max(l.y + l.height for l in r)


def _row_height(r: list[Line]) -> float:
    return _row_bottom(r) - _row_top(r)


def _rows_to_table(run: list[list[Line]], page_width: float) -> Line | None:
    cells = [c for r in run for c in r]
    # 열 경계: 셀 가로 구간의 합집합 (왼쪽/오른쪽/가운데 정렬 모두 처리)
    cols: list[list[float]] = []
    for a, b in sorted((c.x, c.x_end) for c in cells):
        if cols and a < cols[-1][1]:
            cols[-1][1] = max(cols[-1][1], b)
        else:
            cols.append([a, b])
    k_cols = len(cols)
    if k_cols < 2 or k_cols > 12:
        return None

    def col_of(c: Line) -> int:
        m = (c.x + c.x_end) / 2
        for idx, (a, b) in enumerate(cols):
            if a <= m <= b:
                return idx
        return -1

    grid: list[list[str]] = []
    row_tops: list[float] = []
    for r in run:
        if len(r) == 1 and grid:  # 이어지는 줄은 이전 행의 해당 열에 붙인다
            k = col_of(r[0])
            if k < 0:
                return None
            grid[-1][k] = (grid[-1][k] + " " + r[0].text).strip()
            continue
        row = [""] * k_cols
        for c in r:
            k = col_of(c)
            if k < 0:
                return None
            row[k] = (row[k] + " " + c.text).strip()
        grid.append(row)
        row_tops.append(_row_top(r))
    if len(grid) < 2:
        return None

    filled_cells = [c for row in grid for c in row if c]
    filled, total = len(filled_cells), len(grid) * k_cols
    if filled < 0.6 * total:  # 너무 성긴 격자
        return None
    if len(grid) < 3 and filled < 6:
        return None
    numeric = sum(1 for c in filled_cells if re.search(r"\d", c))
    if len(grid) < 4 and numeric < 0.25 * filled:  # 작은 격자는 숫자가 있어야 표로 본다
        return None
    lens = sorted(len(c) for c in filled_cells)
    median_len = lens[len(lens) // 2]
    if median_len > 45:  # 2단 본문처럼 긴 줄이 나란한 경우는 표가 아니다
        return None
    if k_cols == 2 and (median_len > 30 or numeric < 0.4 * filled):  # 2열은 짧은 셀 + 숫자가 있어야 표로 본다
        return None
    widths = sorted(c.x_end - c.x for c in cells)
    if page_width > 0 and widths[len(widths) // 2] > 0.35 * page_width:
        return None
    # 한 열의 셀들이 오른쪽 끝에 맞춰져 있고 글이 길면 양쪽 정렬된 본문 단이다
    em = run[0][0].size or 10.0
    for k in range(k_cols):
        col_cells = [c for c in cells if col_of(c) == k]
        if len(col_cells) < 3:
            continue
        right = max(c.x_end for c in col_cells)
        flush = sum(1 for c in col_cells if c.x_end >= right - 0.5 * em) / len(col_cells)
        col_lens = sorted(len(c.text) for c in col_cells)
        col_median = col_lens[len(col_lens) // 2]
        if col_median > 45 or (flush >= 0.7 and col_median > 30):
            return None

    return Line(
        text=_render_table(grid),
        grid=grid,
        row_tops=row_tops,
        size=run[0][0].size,
        bold=False,
        y=_row_top(run[0]),
        x=min(c.x for c in cells),
        height=_row_bottom(run[-1]) - _row_top(run[0]),
        x_end=max(c.x_end for c in cells),
        kind="table",
    )


def column_kinds(lines: list[Line], page_width: float) -> list[str] | None:
    """2단 레이아웃이면 각 줄의 위치("left"/"right"/"span") 목록을, 아니면 None 을 돌려준다."""
    if page_width <= 0 or len(lines) < 10:
        return None
    mid = page_width / 2
    tol = page_width * 0.08

    def kind(l: Line) -> str:
        if l.x < mid - tol and l.x_end > mid + tol:
            return "span"
        return "left" if (l.x + l.x_end) / 2 < mid else "right"

    kinds = [kind(l) for l in lines]
    # 단 판정에는 본문처럼 충분히 긴 줄만 쓴다 (표의 짧은 셀들이 2단으로 오인되지 않도록)
    wide = [k for l, k in zip(lines, kinds) if l.x_end - l.x >= 0.2 * page_width]
    n_left, n_right = wide.count("left"), wide.count("right")
    if n_left < 5 or n_right < 5 or n_right < 0.2 * (n_left + n_right):
        return None
    return kinds


def reorder_columns(lines: list[Line], page_width: float) -> list[Line]:
    """2단 레이아웃이면 '왼쪽 단 -> 오른쪽 단' 순서로 재배열한다.

    페이지 가운데를 가로지르는 줄(제목, 그림 설명 등)은 구분자로 보고 그 사이 구간마다 단을 정렬한다.
    """
    kinds = column_kinds(lines, page_width)
    if kinds is None:
        return lines

    out: list[Line] = []
    left: list[Line] = []
    right: list[Line] = []
    for l, k in zip(lines, kinds):
        if k == "span":
            out.extend(left)
            out.extend(right)
            left, right = [], []
            out.append(l)
        elif k == "left":
            left.append(l)
        else:
            right.append(l)
    out.extend(left)
    out.extend(right)
    return out


# ---------------------------------------------------------------------------
# Markdown 변환
# ---------------------------------------------------------------------------


def body_font_size(pages: Iterable[Page]) -> float:
    counter: Counter[float] = Counter()
    for p in pages:
        for ln in p.lines:
            counter[round(ln.size, 1)] += len(ln.text)
    return counter.most_common(1)[0][0] if counter else 10.0


EDGE_BAND = 0.12  # 페이지 상/하단 12% 영역만 머리글/바닥글 후보로 본다


def in_edge_band(line: Line, page: Page) -> bool:
    if page.height <= 0:
        return True
    return line.y < page.height * EDGE_BAND or line.y > page.height * (1 - EDGE_BAND)


def find_repeated_lines(pages: list[Page]) -> set[str]:
    """여러 페이지의 상/하단에 동일하게 등장하는 줄(머리글/바닥글)을 찾는다."""
    if len(pages) < 3:
        return set()
    seen: Counter[str] = Counter()
    for p in pages:
        for t in {normalize_for_repeat(l.text) for l in p.lines if in_edge_band(l, p)}:
            seen[t] += 1
    threshold = max(3, int(len(pages) * 0.5))
    return {t for t, n in seen.items() if n >= threshold and len(t) < 120}


def normalize_for_repeat(text: str) -> str:
    # 페이지 번호처럼 숫자만 다른 줄도 같은 것으로 취급
    return re.sub(r"\d+", "#", text.strip().lower())


def heading_level(line: Line, body: float, ocr: bool = False) -> int:
    ratio = line.size / body if body else 1.0
    if ocr:  # OCR 크기는 들쭉날쭉하고 굵기 정보가 없으므로 뚜렷하게 큰 줄만 제목으로 본다
        if ratio >= 1.8:
            return 1
        if ratio >= 1.4:
            return 2
        return 0
    if ratio >= 1.6:
        return 1
    if ratio >= 1.3:
        return 2
    if ratio >= 1.12:
        return 3
    if line.bold and len(line.text) <= 80 and not line.text.rstrip().endswith((".", ",", ";", ":")):
        return 3
    return 0


def escape_md(text: str) -> str:
    # 문장 시작의 '#'이 제목으로 해석되는 것만 막는다 (과도한 이스케이프 방지)
    return re.sub(r"^(\s*)#", r"\1\\#", text)


def join_text(prev: str, nxt: str) -> str:
    if prev.endswith("-") and not prev.endswith("--") and len(prev) > 1 and prev[-2].isalpha():
        return prev[:-1] + nxt  # 하이픈 분철 복원
    if CJK_NOSPACE_RE.search(prev[-1:]) and CJK_NOSPACE_RE.search(nxt[:1]):
        return prev + nxt  # 한자/가나 사이는 공백 없이 연결
    return prev + " " + nxt


def convert_pages(pages: list[Page], page_breaks: bool = False, title: str | None = None) -> str:
    body = body_font_size(pages)
    repeated = find_repeated_lines(pages)
    out: list[str] = []
    if title:
        out.append(f"# {title}")
        out.append("")

    for p in pages:
        if page_breaks and p.number > 1:
            out.extend(["", "---", "", f"<!-- page {p.number} -->", ""])

        para: list[str] = []  # 현재 문단 조각
        prev: Line | None = None
        last_kind: str | None = None  # 직전에 내보낸 블록 종류
        last_item_x = 0.0  # 직전 목록 항목의 시작 x (들여쓰기 판단용)

        def emit(kind: str, line: str) -> None:
            nonlocal last_kind
            # 블록 종류가 바뀌면 빈 줄로 경계를 분명히 한다 (목록 뒤 문단이 항목에 붙는 것 방지)
            if out and out[-1] != "" and kind != last_kind:
                out.append("")
            out.append(line)
            if kind in ("heading", "para", "table"):
                out.append("")
            last_kind = kind

        def flush() -> None:
            if para:
                emit("para", escape_md(" ".join(para)))
                para.clear()

        for ln in p.lines:
            text = ln.text.strip()
            if not text:
                continue
            if ln.kind == "table":
                flush()
                emit("table", text)
                prev = ln
                continue
            if in_edge_band(ln, p) and (normalize_for_repeat(text) in repeated or PAGE_NUMBER_RE.match(text)):
                continue

            level = heading_level(ln, body, p.ocr)
            bullet = BULLET_RE.match(text)
            numbered = NUMBERED_RE.match(text)

            # 문단 경계 판단: 줄 간격이 크거나, 글자 크기가 바뀌었거나, 첫 줄 들여쓰기가 있을 때
            gap = (ln.y - prev.y) if prev is not None else 0.0
            indent = (ln.x - prev.x) if prev is not None else 0.0
            big_gap = prev is not None and gap > max(ln.height, prev.height) * 1.6
            # OCR 페이지는 크기 추정이 들쭉날쭉하므로 본문 크기의 30% 이상 차이만 문단 경계로 본다
            size_change = prev is not None and abs(ln.size - prev.size) > (0.3 * body if p.ocr else 0.6)
            indented = 0.8 * ln.size < indent < 4 * ln.size
            new_block = prev is None or big_gap or size_change or indented

            if level:
                flush()
                emit("heading", f"{'#' * level} {text}")
            elif bullet:
                flush()
                emit("bullet", f"- {text[bullet.end():].strip()}")
                last_item_x = ln.x
            elif numbered and (new_block or not para):
                flush()
                num = numbered.group(1)
                item = text[numbered.end():].strip()
                emit("numbered", f"{num}. {item}" if num.isdigit() else f"- {num}. {item}")
                last_item_x = ln.x
            else:
                # 직전 줄이 목록 항목이고, 이 줄이 들여쓰기된 채 바로 이어지면 항목 본문으로 붙인다
                continues_item = (
                    last_kind in ("bullet", "numbered") and not para and not big_gap and not size_change
                    and ln.x > last_item_x + 1.0
                )
                if continues_item:
                    out[-1] = join_text(out[-1], text)
                    prev = ln
                    continue
                if new_block:
                    flush()
                if para:
                    para[-1] = join_text(para[-1], text)
                else:
                    para.append(text)
            prev = ln
        flush()
        last_kind = None

        for img in p.images:
            out.append(f"![page {p.number}]({img})")
            out.append("")

    # 연속 빈 줄 정리
    md = re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip() + "\n"
    return md


# ---------------------------------------------------------------------------
# 진입점
# ---------------------------------------------------------------------------


def convert_file(
    pdf_path: Path,
    out_path: Path | None,
    *,
    images: bool,
    page_breaks: bool,
    to_stdout: bool,
    tables: bool = True,
    ocr: str | None = None,
) -> Path | None:
    image_dir = None
    if images:
        base = out_path.parent if out_path else pdf_path.parent
        image_dir = base / f"{pdf_path.stem}_images"

    try:
        import fitz  # noqa: F401

        pages = read_with_pymupdf(pdf_path, image_dir, tables=tables, ocr=ocr)
    except ImportError:
        if images:
            print("[warn] --images 옵션은 PyMuPDF가 필요합니다: pip install pymupdf", file=sys.stderr)
        if ocr:
            print("[warn] --ocr 옵션은 PyMuPDF가 필요합니다: pip install pymupdf", file=sys.stderr)
        try:
            pages = read_with_pypdf(pdf_path, tables=tables)
        except ImportError:
            sys.exit("PDF 라이브러리가 없습니다. 다음 중 하나를 설치하세요:\n  pip install pymupdf\n  pip install pypdf")

    md = convert_pages(pages, page_breaks=page_breaks)
    if to_stdout:
        sys.stdout.write(md)
        return None
    out_path = out_path or pdf_path.with_suffix(".md")
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(md, encoding="utf-8")
    return out_path


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="PDF -> Markdown 변환기", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
    ap.add_argument("input", help="PDF 파일 또는 PDF가 들어 있는 폴더")
    ap.add_argument("-o", "--output", help="출력 .md 파일 (입력이 폴더면 출력 폴더)")
    ap.add_argument("--images", action="store_true", help="이미지를 <이름>_images/ 폴더로 추출하고 링크 삽입")
    ap.add_argument("--page-breaks", action="store_true", help="페이지 사이에 구분선 삽입")
    ap.add_argument("--stdout", action="store_true", help="파일 대신 표준 출력으로 내보내기")
    ap.add_argument("--no-tables", action="store_true", help="표 인식을 끄고 셀 텍스트를 일반 문장으로 내보내기")
    ap.add_argument("--ocr", action="store_true", help="텍스트 레이어가 없는(스캔) 페이지를 Tesseract OCR로 읽기 (PyMuPDF + Tesseract 필요)")
    ap.add_argument("--ocr-lang", default="kor+eng", help="OCR 언어 (기본 kor+eng, 예: eng, jpn+eng)")
    args = ap.parse_args(argv)

    src = Path(args.input).expanduser()
    if not src.exists():
        sys.exit(f"입력을 찾을 수 없습니다: {src}")

    if src.is_dir():
        pdfs = sorted(src.rglob("*.pdf"))
        if not pdfs:
            sys.exit(f"PDF 파일이 없습니다: {src}")
        out_dir = Path(args.output).expanduser() if args.output else src
        for pdf in pdfs:
            rel = pdf.relative_to(src).with_suffix(".md")
            result = convert_file(pdf, out_dir / rel, images=args.images, page_breaks=args.page_breaks, to_stdout=False, tables=not args.no_tables, ocr=args.ocr_lang if args.ocr else None)
            print(f"{pdf} -> {result}")
        return 0

    out = Path(args.output).expanduser() if args.output else None
    if out is not None and (out.is_dir() or str(args.output).endswith(("/", "\\"))):
        out = out / src.with_suffix(".md").name
    result = convert_file(src, out, images=args.images, page_breaks=args.page_breaks, to_stdout=args.stdout, tables=not args.no_tables, ocr=args.ocr_lang if args.ocr else None)
    if result:
        print(f"{src} -> {result}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
