#!/usr/bin/env python
"""python-pptx 기하정보 + PIL 폰트 메트릭 기반 렌더링 QA.

LibreOffice가 없는 환경에서 (1) 슬라이드 근사 렌더 PNG 생성,
(2) 텍스트 오버플로 · 슬라이드 경계 이탈 · 텍스트 박스 겹침 자동 검출.
"""
import sys, os, glob, re
from pptx import Presentation
from pptx.util import Emu
from PIL import Image, ImageDraw, ImageFont

import zipfile
from pptx.enum.dml import MSO_THEME_COLOR as TC

SRC = sys.argv[1]
OUTDIR = sys.argv[2] if len(sys.argv) > 2 else "qa_render"
SCALE = 110  # px per inch
KO = "/System/Library/Fonts/AppleSDGothicNeo.ttc"
_cache = {}


def font(sz, bold=False):
    key = (round(sz, 1), bold)
    if key not in _cache:
        # index 0 = Regular, 6 = Bold 계열
        idx = 6 if bold else 0
        try:
            _cache[key] = ImageFont.truetype(KO, max(int(round(sz * SCALE / 72.0)), 1), index=idx)
        except Exception:
            _cache[key] = ImageFont.truetype(KO, max(int(round(sz * SCALE / 72.0)), 1), index=0)
    return _cache[key]


THEME = {}
_TCMAP = {
    TC.TEXT_1: "dk1", TC.DARK_1: "dk1",
    TC.BACKGROUND_1: "lt1", TC.LIGHT_1: "lt1",
    TC.TEXT_2: "dk2", TC.DARK_2: "dk2",
    TC.BACKGROUND_2: "lt2", TC.LIGHT_2: "lt2",
    TC.ACCENT_1: "accent1", TC.ACCENT_2: "accent2", TC.ACCENT_3: "accent3",
    TC.ACCENT_4: "accent4", TC.ACCENT_5: "accent5", TC.ACCENT_6: "accent6",
    TC.HYPERLINK: "hlink", TC.FOLLOWED_HYPERLINK: "folHlink",
}


def load_theme(src):
    z = zipfile.ZipFile(src)
    t = z.read("ppt/theme/theme1.xml").decode()
    blk = re.search(r"<a:clrScheme.*?</a:clrScheme>", t, re.S).group(0)
    for name, val in re.findall(r"<a:(\w+)>\s*<a:srgbClr val=\"([0-9A-Fa-f]{6})\"\s*/>", blk):
        THEME[name] = "#" + val
    THEME.setdefault("dk1", "#000000")
    THEME.setdefault("lt1", "#FFFFFF")


def resolve(colorfmt):
    """ColorFormat -> '#RRGGBB' 또는 None (테마색/직접색 모두 처리)."""
    if colorfmt is None:
        return None
    try:
        if colorfmt.type is None:
            return None
    except Exception:
        return None
    try:
        if colorfmt.rgb is not None:
            return "#" + str(colorfmt.rgb)
    except Exception:
        pass
    try:
        key = _TCMAP.get(colorfmt.theme_color)
        if key:
            base = THEME.get(key)
            if base is None:
                return None
            try:
                b = colorfmt.brightness
            except Exception:
                b = 0.0
            if b:
                r, g, bl = (int(base[i:i + 2], 16) for i in (1, 3, 5))
                if b > 0:
                    r, g, bl = (int(v + (255 - v) * b) for v in (r, g, bl))
                else:
                    r, g, bl = (int(v * (1 + b)) for v in (r, g, bl))
                return "#%02X%02X%02X" % (r, g, bl)
            return base
    except Exception:
        pass
    return None


def inch(v):
    return Emu(v).inches if v is not None else 0.0


def rect(x, y, w, h):
    x0, x1 = sorted((px(x), px(x + w)))
    y0, y1 = sorted((px(y), px(y + h)))
    return [x0, y0, x1, y1]


def px(v):
    return int(round(v * SCALE))


def wrap(draw, text, fnt, maxw_px):
    """문자 단위 래핑 (한글/영문 혼용). 반환: 줄 리스트."""
    lines = []
    for para in text.split("\n"):
        if not para:
            lines.append("")
            continue
        cur = ""
        for ch in para:
            t = cur + ch
            if draw.textlength(t, font=fnt) > maxw_px and cur:
                lines.append(cur)
                cur = ch
            else:
                cur = t
        lines.append(cur)
    return lines


def shape_text(sh):
    """런 단위로 (텍스트, 크기pt, bold, color) 수집 + 문단 구조 유지."""
    paras = []
    for p in sh.text_frame.paragraphs:
        runs = []
        for r in p.runs:
            sz = r.font.size.pt if r.font.size else None
            runs.append((r.text, sz, bool(r.font.bold)))
        paras.append(runs)
    return paras


def render(prs, outdir):
    os.makedirs(outdir, exist_ok=True)
    W, H = inch(prs.slide_width), inch(prs.slide_height)
    issues = []

    for si, slide in enumerate(prs.slides, 1):
        img = Image.new("RGB", (px(W), px(H)), "white")
        d = ImageDraw.Draw(img)
        boxes = []  # (x,y,w,h,label) 텍스트 박스 겹침 검사용

        # 배경: 레이아웃의 첫 큰 도형 색 근사 (다크 슬라이드 감지)
        dark = False
        bgc = None
        for src_ in (slide, slide.slide_layout):
            try:
                if src_.background.fill.type == 1:
                    bgc = resolve(src_.background.fill.fore_color)
                    if bgc:
                        break
            except Exception:
                pass
        if bgc:
            dark = sum(int(bgc[i:i + 2], 16) for i in (1, 3, 5)) < 330
            d.rectangle([0, 0, px(W), px(H)], fill=bgc)

        def draw_shape(sh, dx=0.0, dy=0.0):
            nonlocal dark
            if sh.shape_type == 6:  # GROUP
                for sub in sh.shapes:
                    draw_shape(sub, dx, dy)
                return
            x, y = inch(sh.left) + dx, inch(sh.top) + dy
            w, h = inch(sh.width), inch(sh.height)

            # 경계 이탈
            bx0, bx1 = sorted((x, x + w)); by0, by1 = sorted((y, y + h))
            if bx0 < -0.02 or by0 < -0.02 or bx1 > W + 0.02 or by1 > H + 0.02:
                issues.append((si, "OUT-OF-BOUNDS", f"{sh.shape_name if hasattr(sh,'shape_name') else sh.name} @({x:.2f},{y:.2f}) {w:.2f}x{h:.2f}"))

            # 도형 채움/테두리
            fillc, linec = None, None
            try:
                if sh.fill.type == 1:
                    fillc = resolve(sh.fill.fore_color)
            except Exception:
                pass
            try:
                linec = resolve(sh.line.color)
            except Exception:
                pass

            if sh.shape_type == 13:  # PICTURE
                d.rectangle(rect(x, y, w, h), outline="#999999", width=1)
            elif sh.has_text_frame or fillc or linec:
                if sh.shape_type == 9 or (h < 0.02 and w > 0.02):  # LINE
                    d.line([px(x), px(y), px(x + w), px(y + h)], fill=linec or "#888888", width=1)
                elif fillc or linec:
                    d.rectangle(rect(x, y, w, h), fill=fillc, outline=linec, width=1)

            # 표
            if sh.has_table:
                tbl = sh.table
                ch = h / max(len(tbl.rows), 1)
                cx = x
                for ci, col in enumerate(tbl.columns):
                    cw = inch(col.width)
                    cy = y
                    for ri, row in enumerate(tbl.rows):
                        rh = inch(row.height) or ch
                        cell = tbl.cell(ri, ci)
                        cf = None
                        try:
                            if cell.fill.type == 1:
                                cf = resolve(cell.fill.fore_color)
                        except Exception:
                            pass
                        d.rectangle(rect(cx, cy, cw, rh), fill=cf, outline="#C6CCD3", width=1)
                        txt = cell.text
                        if txt:
                            sz = 8.5
                            col_ = "#24292F"
                            bold_ = False
                            for p in cell.text_frame.paragraphs:
                                for r in p.runs:
                                    if r.font.size: sz = r.font.size.pt
                                    bold_ = bool(r.font.bold)
                                    c2 = resolve(r.font.color)
                                    if c2: col_ = c2
                                    break
                                break
                            f = font(sz, bold_)
                            avail = px(cw) - 8
                            ls = wrap(d, txt, f, avail)
                            lh = f.size * 1.2
                            need = lh * len(ls) / SCALE
                            if need > rh + 0.02:
                                issues.append((si, "TABLE-CELL-OVERFLOW",
                                               f"r{ri}c{ci} '{txt[:24]}' 필요 {need:.2f}\" > 셀 {rh:.2f}\""))
                            for li, ln in enumerate(ls):
                                d.text((px(cx) + 4, px(cy) + 2 + li * lh), ln, font=f, fill=col_)
                        cy += rh
                    cx += cw
                return

            # 차트
            if sh.has_chart:
                d.rectangle(rect(x, y, w, h), outline="#2a78d6", width=2)
                d.text((px(x) + 8, px(y) + 8), "[CHART] 모델 계열별 AP", font=font(10, True), fill="#2a78d6")
                return

            # 텍스트
            if not sh.has_text_frame:
                return
            full = sh.text_frame.text
            if not full.strip():
                return

            paras = shape_text(sh)
            avail_w = px(w) - 2  # margin 0 기준
            total_h = 0.0
            rendered = []
            for runs in paras:
                if not runs:
                    total_h += 12 * SCALE / 72.0
                    rendered.append((" ", font(9), "#24292F"))
                    continue
                sz = runs[0][1] or 11.0
                bold = runs[0][2]
                col_ = None
                for pp in sh.text_frame.paragraphs:
                    for rr in pp.runs:
                        col_ = resolve(rr.font.color)
                        break
                    if col_:
                        break
                if col_ is None:
                    col_ = "#D9DEE4" if dark else "#24292F"
                f = font(sz, bold)
                txt = "".join(rt for rt, _, _ in runs)
                for ln in wrap(d, txt, f, avail_w):
                    rendered.append((ln, f, col_))
                    total_h += f.size * 1.22

            need_in = total_h / SCALE
            if need_in > h + 0.06 and h > 0.05:
                issues.append((si, "TEXT-OVERFLOW",
                               f"'{full.strip()[:40]}...' 필요 {need_in:.2f}\" > 박스 {h:.2f}\" (x{x:.2f} y{y:.2f} w{w:.2f})"))

            cy = px(y) + 1
            for ln, f, col_ in rendered:
                d.text((px(x) + 1, cy), ln, font=f, fill=col_)
                cy += f.size * 1.22

            if full.strip():
                boxes.append((x, y, w, h, full.strip()[:26]))

        # 레이아웃 요소(푸터 · 페이지번호 등) 먼저 렌더 → 본문과의 충돌 확인
        for sh in slide.slide_layout.shapes:
            try:
                if sh.is_placeholder and sh.placeholder_format.idx in {
                    ph.placeholder_format.idx for ph in slide.placeholders
                }:
                    continue  # 슬라이드가 채운 placeholder는 중복 렌더 금지
            except Exception:
                pass
            try:
                draw_shape(sh)
            except Exception:
                pass

        for sh in slide.shapes:
            draw_shape(sh)

        # 텍스트 박스 겹침 (면적 25% 이상)
        for i in range(len(boxes)):
            for j in range(i + 1, len(boxes)):
                a, b = boxes[i], boxes[j]
                ox = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
                oy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
                ov = ox * oy
                small = min(a[2] * a[3], b[2] * b[3])
                if small > 0.01 and ov / small > 0.5:
                    issues.append((si, "TEXT-OVERLAP", f"'{a[4]}' ↔ '{b[4]}' ({ov/small*100:.0f}%)"))

        img.save(os.path.join(outdir, f"slide-{si:02d}.png"), quality=90)

    return issues


load_theme(SRC)
prs = Presentation(SRC)
for f in glob.glob(os.path.join(OUTDIR, "slide-*.png")):
    os.remove(f)
iss = render(prs, OUTDIR)
print(f"슬라이드 {len(prs.slides)}장 렌더 → {OUTDIR}/")
if not iss:
    print("문제 없음 ✓")
else:
    from collections import Counter
    print(f"\n검출 {len(iss)}건: {dict(Counter(k for _, k, _ in iss))}\n")
    for s_, k, m in iss:
        print(f"  S{s_:02d} [{k}] {m}")
