#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""PPT 排版体检 —— 交付前强制自检（宣讲/汇报类 PPTX/PDF 通用）

用法：
    /usr/bin/python3.12 ppt_layout_audit.py <file.pptx|file.pdf> [--json out.json]

六项检查（全部数学判定，不依赖 vision，可进流水线）：
  1. 竖排/拆字   —— enumerate(字符串) 类 bug 的典型症状（字宽≈单字、每行1-2字）
  2. 文字出界     —— 块 bbox 超出页面
  3. 图片拉伸     —— 显示宽高比 vs 像素宽高比（用户对"横着拉过"极敏感）
  4. 超小字号     —— 正文小于阈值（默认 10pt；手机上 <12pt 就吃力）
  5. 文字重叠     —— 两个文本块交集 > 较小块面积的 45%（"压字"的机器判据）
  6. 页码一致性   —— 角落页码 / 页眉 P 编号 vs 实际页序（拆分页/插页后高频出错）

退出码：0 = 全部通过；非 0 = 有问题（流水线可卡住不发布）

⚠️ 本脚本查不出"版面空洞"（大片留白 / 内容量与页面容量不匹配）——那项必须靠 vision 抽查，
   见 SKILL.md「体检两项都做」。
"""
import sys, os, re, json, subprocess, tempfile

if len(sys.argv) < 2:
    print(__doc__); sys.exit(2)

SRC = os.path.abspath(sys.argv[1])
JSON_OUT = None
if "--json" in sys.argv:
    JSON_OUT = sys.argv[sys.argv.index("--json") + 1]

# ---------- 取 PDF（pptx 自动转） ----------
pdf = SRC
tmpd = None
if SRC.lower().endswith((".pptx", ".ppt")):
    tmpd = tempfile.mkdtemp(prefix="pptaudit_")
    subprocess.run(["/usr/bin/soffice", "--headless",
                    f"-env:UserInstallation=file://{tmpd}/lo",
                    "--convert-to", "pdf", "--outdir", tmpd, SRC],
                   check=True, capture_output=True, timeout=300)
    cand = [f for f in os.listdir(tmpd) if f.lower().endswith(".pdf")]
    if not cand:
        print("✗ soffice 转 PDF 失败"); sys.exit(2)
    pdf = os.path.join(tmpd, cand[0])

import pymupdf as fitz

doc = fitz.open(pdf)
PW, PH = doc[0].rect.width, doc[0].rect.height
MIN_PT = 10.0          # 正文最小字号阈值
OVERLAP = 0.45         # 重叠判定比例
STRETCH = 0.06         # 拉伸判定容差

NAME_RE = re.compile(r"^\s*(?:P\s*(\d+)\s*[A-Za-z]?|(\d+)\s*[A-Za-z]?)\s*$")
issues_all, page_report = [], []

for i, page in enumerate(doc, 1):
    issues, d = [], page.get_text("dict")
    texts = [b for b in d["blocks"] if b.get("type") == 0]

    def btext(b):
        return "".join(sp["text"] for ln in b.get("lines", []) for sp in ln["spans"]).strip()

    # 1) 竖排/拆字
    for b in texts:
        lines = b.get("lines", [])
        if len(lines) >= 3:
            lens = [len("".join(sp["text"] for sp in ln["spans"]).strip()) for ln in lines]
            shorty = sum(1 for L in lens if 0 < L <= 2)
            if shorty >= 3 and shorty >= len(lens) * 0.6:
                issues.append({"k": "竖排拆字", "d": f"{len(lines)}行中{shorty}行≤2字: {btext(b)[:38]}"})
                break

    # 2) 文字出界
    for b in texts:
        x0, y0, x1, y1 = b["bbox"]
        ov = []
        if x0 < -2: ov.append(f"左{x0:.0f}")
        if y0 < -2: ov.append(f"上{y0:.0f}")
        if x1 > PW + 2: ov.append(f"右+{x1-PW:.0f}")
        if y1 > PH + 2: ov.append(f"下+{y1-PH:.0f}")
        if ov:
            issues.append({"k": "文字出界", "d": f"{'/'.join(ov)}: {btext(b)[:34]}"})

    # 3) 图片拉伸
    for img in page.get_images(full=True):
        try:
            info = doc.extract_image(img[0])
        except Exception:
            continue
        pr = info["width"] / info["height"]
        for r in page.get_image_rects(img[0]):
            if r.width < 5 or r.height < 5:
                continue
            dr = r.width / r.height
            if abs(pr - dr) / pr > STRETCH:
                issues.append({"k": "图片拉伸", "d": f"像素{pr:.2f} vs 显示{dr:.2f} ({r.width:.0f}x{r.height:.0f}pt)"})

    # 4) 超小字号
    small = [(sp["text"].strip()[:16], sp["size"]) for b in texts
             for ln in b.get("lines", []) for sp in ln["spans"]
             if sp["text"].strip() and 2 < sp["size"] < MIN_PT]
    if small:
        issues.append({"k": "超小字号",
                       "d": f"最小 {min(s for _, s in small):.1f}pt × {len(small)} 处：{[t for t, _ in small[:4]]}"})

    # 5) 文字重叠
    for a in range(len(texts)):
        for c in range(a + 1, len(texts)):
            r1 = fitz.Rect(texts[a]["bbox"]); r2 = fitz.Rect(texts[c]["bbox"])
            inter = r1 & r2
            if inter.is_empty:
                continue
            small_a = min(r1.get_area(), r2.get_area())
            if small_a > 0 and inter.get_area() / small_a > OVERLAP:
                issues.append({"k": "文字重叠",
                               "d": f"{inter.get_area()/small_a*100:.0f}% {btext(texts[a])[:16]!r} ↔ {btext(texts[c])[:16]!r}"})

    # 6) 页码一致性（角落 + 页眉）
    marks = []
    for b in texts:
        x0, y0, x1, y1 = b["bbox"]
        t = btext(b)
        if not t:
            continue
        if (y0 > PH - 60) or (y0 > PH - 130 and x0 > PW * 0.72) or (y1 < 90):
            m = NAME_RE.match(t)
            if m:
                # 记 (页码数字, 是否有字母后缀)：带后缀的是「插页」(如 7A / 10A)，允许 num <= i
                marks.append((int(m.group(1) or m.group(2)), bool(re.search(r"[A-Za-z]", t))))
    if marks:
        bad = [n for n, sfx in marks if not (n == i or (sfx and n <= i))]
        if bad:
            issues.append({"k": "页码不符",
                           "d": f"实际第 {i} 页，标注 {sorted(set(n for n, _ in marks))}"})
    else:
        issues.append({"k": "无页码", "d": "本页未见数字页码标注"})

    page_report.append({"page": i, "issues": issues})
    issues_all.extend([(i, x) for x in issues])

# ---------- 汇总 ----------
kinds = {}
for _, x in issues_all:
    kinds[x["k"]] = kinds.get(x["k"], 0) + 1

print(f"\n{'='*76}")
print(f"PPT 排版体检：{os.path.basename(SRC)}")
print(f"共 {len(doc)} 页，尺寸 {PW:.0f}x{PH:.0f}pt")
print(f"{'='*76}\n")

if not issues_all:
    print("✅ 六项检查全部通过\n")
else:
    byp = {}
    for p, x in issues_all:
        byp.setdefault(p, []).append(x)
    for p in sorted(byp):
        print(f"━━ 第 {p} 页 " + "─" * 56)
        for x in byp[p]:
            print(f"   [{x['k']}] {x['d']}")
        print()
    print(f"{'='*76}")
    print(f"问题页 {len(byp)}/{len(doc)} ｜ 总条目 {len(issues_all)}")
    print("分类：" + " ｜ ".join(f"{k} {v}" for k, v in sorted(kinds.items(), key=lambda x: -x[1])))
    if kinds.get("无页码"):
        print(f"   ⚠️ {kinds['无页码']} 页无页码标注 —— 封面/扉页/大图页可豁免，正文页不可")

if JSON_OUT:
    json.dump({"file": SRC, "pages": len(doc), "size": [PW, PH],
               "summary": kinds, "detail": page_report},
              open(JSON_OUT, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    print(f"\nJSON → {JSON_OUT}")

sys.exit(1 if issues_all else 0)
