#!/usr/bin/env python3
"""check_pptx.py — AIが作ったPPTXを「本当に直せるか」6つの観点で調べる。

  1 文字   : 文字が文字として入っているか（画像に焼かれていないか）
  2 表     : 本物の表（行を足せる）か、文字の格子か
  3 グラフ : 数値が埋め込みブックにつながった本物のグラフか
  4 図形   : カードや枠が、中の文字と一緒に動くか
  5 全体設定: タイトル枠とテーマのフォントで、まとめて直せる作りか
  6 他のPC : 保存し直しても壊れず、指定フォントが他のPCにもあるか

使い方:
    python check_pptx.py deck.pptx            # 結果をJSONで表示
    python check_pptx.py deck.pptx --render   # LibreOfficeでPDF化して開けるかも確かめる
    --table-slides=5,7 --chart-slides=3,4     # 表・グラフがあるはずのスライド番号（初期値）

必要なもの: python-pptx, openpyxl, lxml  (pip install python-pptx openpyxl)
--render を使うときは LibreOffice の soffice コマンド（環境変数 SOFFICE でパス指定可）。

PowerPointそのものは使いません。ファイルの中身（OOXML）を読み、python-pptxで
編集操作を試して、直せる形になっているかを判定します。
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from collections import Counter

from lxml import etree
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.util import Emu

NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
TABLE_URI = "http://schemas.openxmlformats.org/drawingml/2006/table"
CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"


# ---------- 図形の列挙 ----------
def walk(shapes, in_group=False):
    """(shape, in_group) を再帰的に返す。"""
    for sh in shapes:
        yield sh, in_group
        if sh.shape_type == MSO_SHAPE_TYPE.GROUP:
            yield from walk(sh.shapes, True)


def kind_of(sh):
    el = sh._element
    tag = etree.QName(el).localname
    if tag == "pic":
        return "picture"
    if tag == "graphicFrame":
        uri = el.find(".//a:graphicData", NS).get("uri")
        if uri == TABLE_URI:
            return "table"
        if uri == CHART_URI:
            return "chart"
        return "other_frame"
    if tag == "grpSp":
        return "group"
    if tag == "cxnSp":
        return "connector"
    if tag == "sp":
        cnv = el.find("p:nvSpPr/p:cNvSpPr", NS)
        if el.find("p:nvSpPr/p:nvPr/p:ph", NS) is not None:
            return "placeholder"
        if cnv is not None and cnv.get("txBox") == "1":
            return "textbox"
        return "autoshape"
    return tag


def text_of(sh):
    if not getattr(sh, "has_text_frame", False):
        return ""
    return sh.text_frame.text.strip()


def area(sh):
    return max(sh.width or 0, 0) * max(sh.height or 0, 0)


def inside_ratio(inner, outer):
    """inner の面積のうち outer に入っている割合。"""
    l = max(inner.left, outer.left)
    t = max(inner.top, outer.top)
    r = min(inner.left + inner.width, outer.left + outer.width)
    b = min(inner.top + inner.height, outer.top + outer.height)
    if r <= l or b <= t:
        return 0.0
    a = area(inner)
    return ((r - l) * (b - t) / a) if a else 0.0


# ---------- 観点ごとの検査 ----------
def check_text(prs):
    """観点1: 文字が文字として入っているか（画像に焼かれていないか）。"""
    sw, sh_ = prs.slide_width, prs.slide_height
    slide_area = sw * sh_
    per_slide = []
    for i, slide in enumerate(prs.slides, 1):
        chars = 0
        big_pic = 0
        pics = 0
        for sh, _ in walk(slide.shapes):
            k = kind_of(sh)
            if k == "picture":
                pics += 1
                if area(sh) >= 0.5 * slide_area:
                    big_pic += 1
            t = text_of(sh)
            chars += len(t)
            if k == "table":
                for row in sh.table.rows:
                    for cell in row.cells:
                        chars += len(cell.text.strip())
        per_slide.append({"slide": i, "text_chars": chars, "pictures": pics, "big_pictures": big_pic})
    image_slides = [s["slide"] for s in per_slide if s["big_pictures"] > 0 or s["text_chars"] < 10]
    return {"pass": not image_slides, "image_slides": image_slides, "per_slide": per_slide}


def is_filled(sh):
    sp = sh._element.find("p:spPr", NS)
    if sp is None:
        return False
    return any(sp.find("a:%s" % t, NS) is not None for t in ("solidFill", "gradFill", "pattFill", "blipFill"))


def check_shapes(prs):
    """観点2: カードや枠が、文字と一緒に動くか。
    色の付いた中身が空の図形の上に、別のテキストボックスが重なっている（グループ化もされていない）ものを
    「バラバラのカード」と数える。文字が図形の中に入っているカードは「一緒に動く」。"""
    sw, sh_ = prs.slide_width, prs.slide_height
    slide_area = sw * sh_
    loose = 0
    self_contained = 0
    grouped = 0
    per_slide = []
    for i, slide in enumerate(prs.slides, 1):
        top_level = list(slide.shapes)
        for sh in top_level:
            if kind_of(sh) == "group":
                grouped += 1
        s_loose = 0
        for sh in top_level:
            k = kind_of(sh)
            if k not in ("autoshape", "textbox") or not is_filled(sh):
                continue
            if area(sh) >= 0.7 * slide_area:  # 背景は除く
                continue
            if text_of(sh):
                self_contained += 1
                continue
            overlay = [t for t in top_level if t is not sh and kind_of(t) in ("textbox", "placeholder", "autoshape") and text_of(t) and inside_ratio(t, sh) >= 0.8]
            if overlay:
                loose += 1
                s_loose += 1
        per_slide.append({"slide": i, "loose_cards": s_loose})
    return {
        "pass": loose == 0,
        "loose_cards": loose,
        "self_contained_shapes": self_contained,
        "groups": grouped,
        "per_slide": per_slide,
    }


def looks_like_fake_table(slide):
    """表がないのに、文字が格子状に並んでいるか。"""
    xs, ys = set(), set()
    n = 0
    for sh in slide.shapes:
        k = kind_of(sh)
        if k in ("textbox", "autoshape", "placeholder") and text_of(sh) and len(text_of(sh)) < 25:
            xs.add(round(sh.left / 91440 / 0.15))
            ys.add(round(sh.top / 91440 / 0.15))
            n += 1
    return n >= 9 and len(xs) >= 3 and len(ys) >= 3


def check_tables(prs, tmp, table_slides):
    """観点3: 本物の表か。行を足して保存できるか。
    table_slides は「表のはずのスライド」の番号。そこに本物の表があるかを見る。"""
    native = 0
    fake_slides = []
    missing = []
    op_ok = True
    for i, slide in enumerate(prs.slides, 1):
        has_tbl = False
        for sh, _ in walk(slide.shapes):
            if kind_of(sh) == "table":
                has_tbl = True
                native += 1
                try:
                    from copy import deepcopy

                    tbl = sh.table._tbl
                    last = tbl.tr_lst[-1]
                    tbl.append(deepcopy(last))
                    sh.table.cell(0, 0).text = sh.table.cell(0, 0).text + "（追記）"
                except Exception as e:  # noqa: BLE001
                    op_ok = False
        if i in table_slides and not has_tbl:
            missing.append(i)
            if looks_like_fake_table(slide):
                fake_slides.append(i)
    return {
        "pass": not missing,
        "native_tables": native,
        "table_slides_without_table": missing,
        "fake_table_slides": fake_slides,
        "add_row_and_edit_ok": op_ok if native else None,
    }


def read_embedded_workbook(chart):
    try:
        xp = chart.part.chart_workbook.xlsx_part
        if xp is None:
            return None
        import io

        import openpyxl

        wb = openpyxl.load_workbook(io.BytesIO(xp.blob), data_only=True)
        ws = wb.worksheets[0]
        return [[c for c in row] for row in ws.iter_rows(values_only=True)]
    except Exception:  # noqa: BLE001
        return None



def check_chart_links(chart):
    """グラフの数値が、埋め込みブックのセルとつながっているか。
    系列ごとに「参照式（Sheet1!$B$2:$B$4 など）」を読み、その範囲のセルの値が
    グラフに保存された値と同じかを確かめる。値が直書き（numLit）のときは False。"""
    import io, re
    import openpyxl
    try:
        xp = chart.part.chart_workbook.xlsx_part
        if xp is None:
            return False
        wb = openpyxl.load_workbook(io.BytesIO(xp.blob), data_only=True)
    except Exception:  # noqa: BLE001
        return False
    root = chart.part._element
    sers = root.findall(".//c:ser", NS)
    if not sers:
        return False
    for ser in sers:
        ref = ser.find("c:val/c:numRef", NS)
        if ref is None:
            return False
        f = ref.find("c:f", NS)
        if f is None or not f.text:
            return False
        cached = [float(v.text) for v in ref.findall("c:numCache/c:pt/c:v", NS)]
        m = re.match(r"'?([^'!]+)'?!\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)", f.text)
        if not m or m.group(1) not in wb.sheetnames:
            return False
        ws = wb[m.group(1)]
        cells = [c.value for row in ws["%s%s:%s%s" % (m.group(2), m.group(3), m.group(4), m.group(5))] for c in row]
        try:
            vals = [float(v) for v in cells]
        except (TypeError, ValueError):
            return False
        if cached and any(abs(a - b) > 1e-9 for a, b in zip(vals, cached)) or len(vals) != len(cached):
            return False
    return True


def check_charts(prs, chart_slides):
    """観点4: 本物のグラフか（データを持つか）。数値を1つ変えて、棒が連動するか。"""
    charts = []
    picture_slides = []
    shape_bar_slides = []
    for i, slide in enumerate(prs.slides, 1):
        has_chart = False
        for sh, _ in walk(slide.shapes):
            if kind_of(sh) == "chart":
                has_chart = True
                ch = sh.chart
                info = {"slide": i, "type": str(ch.chart_type).split(".")[-1].split(" ")[0], "series": []}
                try:
                    for plot in ch.plots:
                        cats = [str(c) for c in plot.categories]
                        for s in plot.series:
                            info["series"].append({"name": s.name, "categories": cats, "values": list(s.values)})
                except Exception:  # noqa: BLE001
                    pass
                wb = read_embedded_workbook(ch)
                info["has_embedded_workbook"] = wb is not None
                info["data_linked_to_workbook"] = check_chart_links(ch)
                info["workbook_head"] = wb[:4] if wb else None
                # 操作: 最初の系列の2番目の値を+1して差し替え → ワークブックに反映されるか
                try:
                    from pptx.chart.data import CategoryChartData

                    plot = ch.plots[0]
                    cd = CategoryChartData()
                    cd.categories = list(plot.categories)
                    for s in plot.series:
                        vals = list(s.values)
                        if s is plot.series[0] and len(vals) > 1 and vals[1] is not None:
                            vals[1] = vals[1] + 1
                        cd.add_series(s.name, vals)
                    ch.replace_data(cd)
                    wb2 = read_embedded_workbook(ch)
                    info["replace_data_ok"] = wb2 is not None
                except Exception as e:  # noqa: BLE001
                    info["replace_data_ok"] = False
                    info["replace_error"] = str(e)[:80]
                charts.append(info)
        if not has_chart and i in chart_slides:
            pics = [s for s, _ in walk(slide.shapes) if kind_of(s) == "picture"]
            if pics:
                picture_slides.append(i)
            plain = [s for s, _ in walk(slide.shapes) if kind_of(s) == "autoshape" and not text_of(s)]
            bottoms = Counter(round((s.top + s.height) / 91440 / 0.1) for s in plain)
            if bottoms and bottoms.most_common(1)[0][1] >= 3:
                shape_bar_slides.append(i)
    native_with_wb = [c for c in charts if c["has_embedded_workbook"] and c.get("data_linked_to_workbook")]
    have = {c["slide"] for c in native_with_wb}
    missing = [i for i in chart_slides if i not in have]
    return {
        "pass": not missing and all(c.get("replace_data_ok") for c in native_with_wb),
        "chart_slides_without_editable_chart": missing,
        "native_charts": len(charts),
        "with_embedded_workbook": len(native_with_wb),
        "charts": charts,
        "slides_with_pictures_but_no_chart": picture_slides,
        "slides_with_bar_like_shapes": shape_bar_slides,
    }


def check_theme(prs, pptx_path):
    """観点5: 全体をまとめて直せる作りか（タイトル枠・テーマのフォント・レイアウト）。"""
    title_ok = 0
    layouts = []
    runs = 0
    hard = 0
    for slide in prs.slides:
        layouts.append(slide.slide_layout.name)
        has_title = False
        for sh in slide.shapes:
            if sh.is_placeholder and sh.placeholder_format.type is not None:
                t = str(sh.placeholder_format.type)
                if ("TITLE" in t) and text_of(sh):
                    has_title = True
        title_ok += has_title
        for r in slide._element.iter("{%s}r" % NS["a"]):
            runs += 1
            rpr = r.find("a:rPr", NS)
            if rpr is None:
                continue
            for tag in ("latin", "ea", "cs"):
                f = rpr.find("a:%s" % tag, NS)
                if f is not None and not f.get("typeface", "").startswith("+"):
                    hard += 1
                    break
    n = len(prs.slides)
    ratio = (hard / runs) if runs else 0.0
    return {
        "pass": title_ok == n and ratio <= 0.2,
        "slides_with_title_placeholder": title_ok,
        "slides": n,
        "layouts_used": dict(Counter(layouts)),
        "text_runs": runs,
        "runs_with_hardcoded_font": hard,
        "hardcoded_font_ratio": round(ratio, 3),
    }


def check_handoff(prs, pptx_path, render, tmp):
    """観点6: 保存して開き直せる／別のアプリで開ける／はみ出しがない。"""
    res = {}
    try:
        with zipfile.ZipFile(pptx_path) as z:
            res["zip_ok"] = z.testzip() is None
    except Exception:  # noqa: BLE001
        res["zip_ok"] = False
    # python-pptxで保存→開き直し
    try:
        p2 = os.path.join(tmp, "resaved.pptx")
        prs.save(p2)
        Presentation(p2)
        res["resave_ok"] = True
    except Exception:  # noqa: BLE001
        res["resave_ok"] = False
    # スライド外へのはみ出し
    sw, sh_ = prs.slide_width, prs.slide_height
    off = []
    for i, slide in enumerate(prs.slides, 1):
        for sh, _ in walk(slide.shapes):
            if sh.width is None:
                continue
            if sh.left < -Emu(45720) or sh.top < -Emu(45720) or sh.left + sh.width > sw + 45720 or sh.top + sh.height > sh_ + 45720:
                off.append(i)
                break
    res["slides_with_shape_outside"] = off
    fonts = Counter()
    for slide in prs.slides:
        for tag in ("latin", "ea"):
            for f in slide._element.iter("{%s}%s" % (NS["a"], tag)):
                tf = f.get("typeface", "")
                if tf and not tf.startswith("+"):
                    fonts[tf] += 1
    # テーマが決めているフォントも見る（スライドで直指定していない文字はこれに従う）
    theme_fonts = set()
    with zipfile.ZipFile(pptx_path) as z:
        names = z.namelist()
        for n in names:
            if n.startswith("ppt/theme/") and n.endswith(".xml"):
                root = etree.fromstring(z.read(n))
                for grp in ("majorFont", "minorFont"):
                    g = root.find(".//a:%s" % grp, NS)
                    if g is None:
                        continue
                    for tag in ("latin", "ea"):
                        e = g.find("a:%s" % tag, NS)
                        if e is not None and e.get("typeface"):
                            theme_fonts.add(e.get("typeface"))
        res["embedded_fonts"] = any("fonts/" in n and n.startswith("ppt/") for n in names)
    res["fonts_used"] = dict(fonts)
    res["theme_fonts"] = sorted(theme_fonts)
    # WindowsのPowerPointに標準で入っている（Microsoft 365 を含む）主なフォント
    portable = {"Yu Gothic", "游ゴシック", "Yu Mincho", "游明朝", "Meiryo", "メイリオ", "MS Gothic", "MS PGothic", "MS Mincho", "MS PMincho",
                "Arial", "Calibri", "Calibri Light", "Aptos", "Aptos Display", "Segoe UI", "Times New Roman", "Verdana", "Tahoma",
                "Cambria", "Georgia", "Consolas", "Courier New", "BIZ UDPGothic", "BIZ UDGothic", "Century Gothic", "Trebuchet MS", "Arial Black"}
    all_fonts = set(fonts) | theme_fonts
    res["non_portable_fonts"] = sorted(f for f in all_fonts if f not in portable)
    if render:
        so = os.environ.get("SOFFICE") or shutil.which("soffice")
        if so:
            src = os.path.join(tmp, "orig.pptx")
            shutil.copy(pptx_path, src)
            r = subprocess.run([so, "--headless", "--convert-to", "pdf", "--outdir", tmp, src], capture_output=True, timeout=240)
            pdf = os.path.join(tmp, "orig.pdf")
            if os.path.exists(pdf):
                out = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout
                pages = [l for l in out.splitlines() if l.startswith("Pages:")]
                res["libreoffice_pdf_pages"] = int(pages[0].split()[1]) if pages else None
            else:
                res["libreoffice_pdf_pages"] = None
        else:
            res["libreoffice_pdf_pages"] = "soffice not found"
    ok = res["zip_ok"] and res["resave_ok"] and not off and not res["non_portable_fonts"]
    if render and isinstance(res.get("libreoffice_pdf_pages"), int):
        ok = ok and res["libreoffice_pdf_pages"] == len(prs.slides)
    res["pass"] = bool(ok)
    return res


def run(path, render=False, table_slides=(5, 7), chart_slides=(3, 4)):
    tmp = tempfile.mkdtemp()
    prs = Presentation(path)
    out = {"file": os.path.basename(path), "slides": len(prs.slides)}
    # 先に（破壊的な操作の前に）読むだけの検査
    out["1_text"] = check_text(prs)
    out["4_shapes"] = check_shapes(prs)
    out["5_theme"] = check_theme(prs, path)
    out["6_handoff"] = check_handoff(prs, path, render, tmp)
    # 以降は表・グラフを書き換える操作を含む
    out["2_tables"] = check_tables(prs, tmp, table_slides)
    out["3_charts"] = check_charts(prs, chart_slides)
    shutil.rmtree(tmp, ignore_errors=True)
    out["summary"] = {k: out[k]["pass"] for k in sorted(k for k in out if k[0].isdigit())}
    return out


if __name__ == "__main__":
    argv = sys.argv[1:]
    opts = {}
    for a in list(argv):
        if a.startswith("--") and "=" in a:
            k, v = a[2:].split("=", 1)
            opts[k] = tuple(int(x) for x in v.split(",") if x)
            argv.remove(a)
    args = [a for a in argv if not a.startswith("--")]
    render = "--render" in argv
    if not args:
        print(__doc__)
        sys.exit(1)
    results = [run(a, render, opts.get("table-slides", (5, 7)), opts.get("chart-slides", (3, 4))) for a in args]
    print(json.dumps(results if len(results) > 1 else results[0], ensure_ascii=False, indent=2, default=str))
