#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import os
import re
from lxml import etree
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE

# matplotlib は任意依存。無ければチャートは Markdown 表のみ。
try:
    import matplotlib.pyplot as plt
    _HAVE_MPL = True
except Exception:
    _HAVE_MPL = False

# 名前空間
NAMESPACES = {
    'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
    'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
    'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
    'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
    'a14': 'http://schemas.microsoft.com/office/drawing/2010/main',
    'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart',
}

# 数学用Unicode→LaTeX/ASCII
MATH_UNICODE_MAP = {
    '𝑷': 'P', '𝑽': 'V', '𝑹': 'R', '𝑻': 'T',
    '𝒂': 'a', '𝒃': 'b', '𝒏': 'n', '𝒙': 'x', '𝒇': 'f',
    '𝒅': '\\mathrm{d}',  # 微分の d はローマン体
    '𝟐': '2', '𝟏': '1', '𝟎': '0',
    '−': '-', '＋': '+', '÷': '/', '×': '*', '⋅': '\\cdot',
    '…': '...', '∞': '\\infty',
    '∑': '\\sum', '∏': '\\prod',
    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
    'α': '\\alpha', 'β': '\\beta', 'γ': '\\gamma', 'δ': '\\delta', 'ε': '\\epsilon',
    'λ': '\\lambda', 'μ': '\\mu', 'π': '\\pi', 'ρ': '\\rho', 'σ': '\\sigma', 'τ': '\\tau',
    'φ': '\\phi', 'χ': '\\chi', 'ψ': '\\psi', 'ω': '\\omega',
    'Γ': '\\Gamma', 'Δ': '\\Delta', 'Θ': '\\Theta', 'Λ': '\\Lambda',
    'Ξ': '\\Xi', 'Π': '\\Pi', 'Σ': '\\Sigma', 'Φ': '\\Phi', 'Ψ': '\\Psi', 'Ω': '\\Omega',
    '′': "'", '°': '\\degree', '℃': '\\degree C'
}

NARY_TO_LATEX = {
    '∑': '\\sum', '∏': '\\prod',
    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
    '⋀': '\\bigwedge', '⋁': '\\bigvee', '⋂': '\\bigcap', '⋃': '\\bigcup',
}

OPERATOR_CHARS = set(['∑', '∏', '∫', '∬', '∭', '∮'])

def get_slide_title(slide):
    # タイトル候補（プレースホルダ優先）
    for shape in slide.shapes:
        if getattr(shape, "has_text_frame", False) and shape.is_placeholder and shape.placeholder_format.type == 1:
            if shape.text:
                return shape.text.strip()
    for shape in slide.shapes:
        if getattr(shape, "has_text_frame", False):
            t = (shape.text or "").strip()
            if t:
                return t.split('\n')[0]
    return "無題のスライド"

def _safe_text_replace_math_unicode(text: str) -> str:
    if not text:
        return ""
    for u, ltx in MATH_UNICODE_MAP.items():
        text = text.replace(u, ltx)
    return text

def _find_first(element, candidates):
    for cand in candidates:
        x = element.find(cand, NAMESPACES)
        if x is not None:
            return x
    return None

def _detect_nary_op_char(element):
    # 1) 通常: naryPr/chr@m:val
    op_tag = element.find('m:naryPr/m:chr', NAMESPACES)
    if op_tag is not None:
        val = op_tag.get(f"{{{NAMESPACES['m']}}}val", "")
        if val:
            return val
    # 2) フォールバック（opEmu ケース）：e/sub/sup の外側にあるテキストから検出
    # FIX: BaseOxmlElement.xpath は namespaces kw を受けない → 事前コンパイル
    compiled = etree.XPath('.//m:t[not(ancestor::m:e) and not(ancestor::m:sub) and not(ancestor::m:sup)]',
                           namespaces=NAMESPACES)
    ts = compiled(element)
    for t in ts:
        s = t.text or ""
        for ch in s:
            if ch in OPERATOR_CHARS:
                return ch
    return ""

ACCENT_TO_LATEX = {
    '^': '\\hat',
    'ˇ': '\\check',
    '˘': '\\breve',
    '˜': '\\tilde',
    '¯': '\\bar',
    '˙': '\\dot',
    '¨': '\\ddot',
}

def omml_to_latex(element):
    """OMML → LaTeX"""
    tag = etree.QName(element).localname

    if tag == 'oMath':
        return "".join(omml_to_latex(child) for child in element)

    elif tag == 'oMathPara':
        # 複数式→aligned
        lines = []
        for child in element:
            if etree.QName(child).localname == 'oMath':
                lines.append(omml_to_latex(child))
            elif etree.QName(child).localname == 'eqArr':
                # eqArr は各行 m:e
                for e in child.findall('.//m:e', NAMESPACES):
                    lines.append(omml_to_latex(e))
        if len(lines) > 1:
            return "\\begin{aligned}" + " \\\\ ".join(lines) + "\\end{aligned}"
        # 1行なら中身をそのまま
        return "".join(omml_to_latex(child) for child in element)

    elif tag == 'f':
        num = element.find('m:num', NAMESPACES)
        den = element.find('m:den', NAMESPACES)
        return f"\\frac{{{omml_to_latex(num)}}}{{{omml_to_latex(den)}}}"

    elif tag == 'rad':
        deg = element.find('m:deg', NAMESPACES)
        e = element.find('m:e', NAMESPACES)
        if deg is not None:
            return f"\\sqrt[{omml_to_latex(deg)}]{{{omml_to_latex(e)}}}"
        return f"\\sqrt{{{omml_to_latex(e)}}}"

    elif tag == 'sSup':
        e = element.find('m:e', NAMESPACES)
        sup = element.find('m:sup', NAMESPACES)
        return f"{omml_to_latex(e)}^{{{omml_to_latex(sup)}}}"

    elif tag == 'sSub':
        e = element.find('m:e', NAMESPACES)
        sub = element.find('m:sub', NAMESPACES)
        return f"{omml_to_latex(e)}_{{{omml_to_latex(sub)}}}"

    elif tag == 'sSubSup':
        e = element.find('m:e', NAMESPACES)
        sub = element.find('m:sub', NAMESPACES)
        sup = element.find('m:sup', NAMESPACES)
        base = omml_to_latex(e)
        sub_l = omml_to_latex(sub) if sub is not None else ""
        sup_l = omml_to_latex(sup) if sup is not None else ""
        return f"{base}_{{{sub_l}}}^{{{sup_l}}}"

    elif tag == 'd':  # 括弧
        beg_chr = element.find('m:dPr/m:begChr', NAMESPACES)
        end_chr = element.find('m:dPr/m:endChr', NAMESPACES)
        beg = beg_chr.get(f"{{{NAMESPACES['m']}}}val") if beg_chr is not None else "("
        end = end_chr.get(f"{{{NAMESPACES['m']}}}val") if end_chr is not None else ")"
        content = "".join(omml_to_latex(child) for child in element if etree.QName(child).localname != 'dPr')
        return f"{beg}{content}{end}"

    elif tag == 'bar':  # overline/underline
        pos = element.find('m:barPr/m:pos', NAMESPACES)
        pos_val = pos.get(f"{{{NAMESPACES['m']}}}val") if pos is not None else 'top'
        e = element.find('m:e', NAMESPACES)
        if pos_val == 'bot':
            return f"\\underline{{{omml_to_latex(e)}}}"
        return f"\\overline{{{omml_to_latex(e)}}}"

    elif tag == 'groupChr':  # overbrace/underbrace
        pos = element.find('m:groupChrPr/m:pos', NAMESPACES)
        pos_val = pos.get(f"{{{NAMESPACES['m']}}}val") if pos is not None else 'top'
        e = element.find('m:e', NAMESPACES)
        if pos_val == 'bot':
            return f"\\underbrace{{{omml_to_latex(e)}}}"
        return f"\\overbrace{{{omml_to_latex(e)}}}"

    elif tag == 'acc':  # アクセント
        ch = element.find('m:accPr/m:chr', NAMESPACES)
        op = ch.get(f"{{{NAMESPACES['m']}}}val") if ch is not None else '^'
        e = element.find('m:e', NAMESPACES)
        macro = ACCENT_TO_LATEX.get(op, '\\hat')
        return f"{macro}{{{omml_to_latex(e)}}}"

    elif tag == 'm':  # 行列
        rows = element.findall('m:mr', NAMESPACES)
        row_strs = []
        for r in rows:
            cells = r.findall('m:mc', NAMESPACES)
            cs = []
            for c in cells:
                e = c.find('m:e', NAMESPACES)
                cs.append(omml_to_latex(e))
            row_strs.append(" & ".join(cs))
        return "\\begin{bmatrix}" + " \\\\ ".join(row_strs) + "\\end{bmatrix}"

    elif tag == 'limLow':
        base = omml_to_latex(element.find('m:e', NAMESPACES))
        low = omml_to_latex(element.find('m:lim', NAMESPACES))
        return f"{base}_{{{low}}}"

    elif tag == 'limUpp':
        base = omml_to_latex(element.find('m:e', NAMESPACES))
        upp = omml_to_latex(element.find('m:lim', NAMESPACES))
        return f"{base}^{{{upp}}}"

    elif tag == 'r':
        return "".join(omml_to_latex(child) for child in element)

    elif tag == 't':
        return _safe_text_replace_math_unicode(element.text or "")

    elif tag == 'int':
        lower = _find_first(element, ['m:sub', 'm:low'])
        upper = _find_first(element, ['m:sup', 'm:up'])
        e = element.find('m:e', NAMESPACES)
        lower_ltx = omml_to_latex(lower) if lower is not None else ""
        upper_ltx = omml_to_latex(upper) if upper is not None else ""
        e_ltx = omml_to_latex(e) if e is not None else ""
        if lower_ltx or upper_ltx:
            return f"\\int_{{{lower_ltx}}}^{{{upper_ltx}}}{e_ltx}"
        else:
            return f"\\int {e_ltx}"

    elif tag == 'nary':
        op_char = _detect_nary_op_char(element)
        lower_tag = _find_first(element, ['m:sub', 'm:low'])
        upper_tag = _find_first(element, ['m:sup', 'm:up'])
        content_tag = element.find('m:e', NAMESPACES)
        lower_ltx = omml_to_latex(lower_tag) if lower_tag is not None else ''
        upper_ltx = omml_to_latex(upper_tag) if upper_tag is not None else ''
        content_ltx = omml_to_latex(content_tag) if content_tag is not None else ''
        op_ltx = NARY_TO_LATEX.get(op_char, _safe_text_replace_math_unicode(op_char))
        if not op_ltx and (lower_ltx or upper_ltx):
            op_ltx = '\\int'
        if lower_ltx or upper_ltx:
            return f"{op_ltx}_{{{lower_ltx}}}^{{{upper_ltx}}}{content_ltx}"
        else:
            return f"{op_ltx} {content_ltx}".rstrip()

    # 既知以外は子を連結
    return "".join(omml_to_latex(child) for child in element)

def normalize_differentials(latex: str) -> str:
    r"""
    \mathrm{d} の前に薄いスペース \, を付与（重複防止）。
    例: \int f(x)\mathrm{d}x -> \int f(x)\,\mathrm{d}x
    """
    # すでに \, が付いていなければ付与
    s = re.sub(r'(?<!\\,)\s*\\mathrm\{d\}', r'\\,\\mathrm{d}', latex)
    # 連続する \, を縮約
    s = re.sub(r'(\\,)+', r'\\,', s)
    return s

def shape_iter(shape):
    """グループを再帰的に辿る"""
    if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
        for s in shape.shapes:
            yield from shape_iter(s)
    else:
        yield shape

def extract_text_blocks(slide):
    """
    スライドXML全体からテキストボックス(p:sp with p:txBody)を集める。
    まず「数式の外」のテキストだけで段落ごとに復元（<a:br> は改行）。
    それで空なら段落内で a:t / m:oMath(Para) / <a:br> を順序通りに再構成し、
    数式は $...$ でインライン挿入。各テキストボックスを (y,x) でソートして返す。
    """
    blocks = []

    root = etree.fromstring(slide.part.blob)

    # p:txBody を持つ全テキストボックス（AlternateContent含む）
    sp_nodes = root.xpath('//p:sp[p:txBody]', namespaces=NAMESPACES)

    # 位置取得
    find_off = etree.XPath(
        './/p:spPr/a:xfrm/a:off | .//a:xfrm/a:off',
        namespaces=NAMESPACES
    )
    # 段落
    paras_in_sp = etree.XPath('.//p:txBody//a:p', namespaces=NAMESPACES)

    # 段落内の「数式の外の a:t」と改行<a:br>（非OMMLパス）
    inline_nonmath_nodes = etree.XPath(
        './/a:t[not(ancestor::m:oMath) and not(ancestor::m:oMathPara)] | .//a:br',
        namespaces=NAMESPACES
    )
    # 段落内の a:t / OMML / <a:br>（フォールバック：数式も含める）
    inline_all_nodes = etree.XPath(
        './/a:t | .//a:br | .//m:oMathPara | .//m:oMath[not(ancestor::m:oMathPara)]',
        namespaces=NAMESPACES
    )

    for sp in sp_nodes:
        # 位置 (y,x)
        y, x = 10**9, 10**9
        off_list = find_off(sp)
        if off_list:
            off = off_list[0]
            try:
                x = int(off.get('x', '0')); y = int(off.get('y', '0'))
            except Exception:
                pass

        # 1) 非OMMLのみ（段落ごとに復元 + <a:br>を改行）
        lines = []
        for p in paras_in_sp(sp):
            parts = []
            for node in inline_nonmath_nodes(p):
                lname = etree.QName(node).localname
                if lname == 't':
                    if node.text:
                        parts.append(node.text)
                elif lname == 'br':
                    parts.append('\n')
            # 連続改行の整理（不要ならこの2行は外してOK）
            line = "".join(parts)
            line = re.sub(r'\n{3,}', r'\n\n', line).strip()
            if line:
                lines.append(line)
        text_only = "\n".join(lines).strip()

        # 2) 空ならフォールバック：数式も含めて段落再構成（$...$）+ <a:br>改行
        if not text_only:
            lines = []
            for p in paras_in_sp(sp):
                parts = []
                for node in inline_all_nodes(p):
                    lname = etree.QName(node).localname
                    if lname == 't':
                        if node.text:
                            parts.append(node.text)
                    elif lname == 'br':
                        parts.append('\n')
                    else:  # m:oMath / m:oMathPara
                        latex = normalize_differentials(omml_to_latex(node))
                        if latex.strip():
                            parts.append(f"${latex}$")
                line = "".join(parts)
                line = re.sub(r'\n{3,}', r'\n\n', line).strip()
                if line:
                    lines.append(line)
            text_final = "\n".join(lines).strip()
        else:
            text_final = text_only

        if text_final:
            blocks.append((y, x, text_final))

    blocks.sort(key=lambda v: (v[0], v[1]))
    return blocks

def extract_math_blocks(slide):
    """
    スライド全体のXMLから数式(OMML)を収集し、所属図形の座標(EMU)で (y,x) ソートして返す。
    祖先図形が見つからない場合は末尾扱い（大きな座標）でDOM順に並ぶ。
    """
    blocks = []

    # スライド生XML
    root = etree.fromstring(slide.part.blob)

    # スライド全体から oMathPara と（Para外の）oMath を収集
    math_elems = root.xpath(
        '//m:oMathPara | //m:oMath[not(ancestor::m:oMathPara)]',
        namespaces=NAMESPACES
    )

    # 祖先の図形と座標（EMU: English Metric Units）を取得するXPathを事前コンパイル
    find_anc = etree.XPath(
        'ancestor::p:sp | ancestor::p:pic | ancestor::p:graphicFrame',
        namespaces=NAMESPACES
    )
    find_off = etree.XPath(
        './/p:spPr/a:xfrm/a:off | .//a:xfrm/a:off',
        namespaces=NAMESPACES
    )

    for me in math_elems:
        latex = normalize_differentials(omml_to_latex(me))
        if not latex.strip():
            continue

        # 既定は末尾並び用の大きな座標
        y, x = 10**9, 10**9

        # 祖先図形を見つけて座標を読む（見つかったものの先頭を使用）
        anc_list = find_anc(me)
        if anc_list:
            off_list = find_off(anc_list[0])
            if off_list:
                off = off_list[0]
                try:
                    x = int(off.get('x', '0'))
                    y = int(off.get('y', '0'))
                except Exception:
                    pass

        blocks.append((y, x, latex, me))

    # (y,x) で読み順ソート
    blocks.sort(key=lambda v: (v[0], v[1]))
    return blocks

def chart_to_png_and_md(shape, slide_idx, chart_idx, image_dir):
    """
    chart データを抽出し、matplotlib があれば PNG 出力、常に Markdown 表も返す
    """
    ch = shape.chart
    series = ch.series
    # カテゴリ
    categories = []
    try:
        categories = [str(c) for c in ch.categories]
    except Exception:
        # series長から生成
        max_len = 0
        for s in series:
            max_len = max(max_len, len(list(s.values)))
        categories = [str(i+1) for i in range(max_len)]

    data = []  # (series_name, [values...])
    for s in series:
        name = s.name if s.name is not None else "series"
        vals = [float(v) if v is not None else float('nan') for v in list(s.values)]
        data.append((str(name), vals))

    md_table = []
    # Markdown 表（1行目ヘッダ）
    header = "| Category | " + " | ".join([sn for sn, _ in data]) + " |"
    sep = "|" + "---|" * (len(data) + 1)
    md_table.append(header)
    md_table.append(sep)
    rows = zip(*([categories] + [vals for _, vals in data]))
    for row in rows:
        md_table.append("| " + " | ".join(str(x) for x in row) + " |")
    md_table_str = "\n".join(md_table)

    png_path = None
    if _HAVE_MPL:
        try:
            xs = list(range(len(categories)))
            plt.figure()
            for sn, vals in data:
                v = list(vals) + [float('nan')] * (len(categories) - len(vals))
                plt.plot(xs, v, marker="o", label=sn)
            plt.xticks(xs, categories, rotation=45, ha='right')
            plt.legend()
            plt.tight_layout()
            png_name = f"slide{slide_idx+1}_chart{chart_idx+1}.png"
            png_path = os.path.join(image_dir, png_name)
            plt.savefig(png_path, dpi=180)
            plt.close()
        except Exception:
            png_path = None

    return png_path, md_table_str

def extract_tables(slide):
    tables = []
    for shape in slide.shapes:
        for s in shape_iter(shape):
            if getattr(s, "has_table", False):
                top, left = int(s.top), int(s.left)
                tbl = s.table
                # Markdown 表
                rows = []
                for r in tbl.rows:
                    row = []
                    for c in r.cells:
                        txt = c.text.strip().replace("\n", " ")
                        row.append(txt)
                    rows.append(row)
                if rows:
                    header = rows[0]
                    md = []
                    md.append("| " + " | ".join(header) + " |")
                    md.append("| " + " | ".join(["---"] * len(header)) + " |")
                    for row in rows[1:]:
                        md.append("| " + " | ".join(row) + " |")
                    tables.append((top, left, "\n".join(md)))
    tables.sort(key=lambda v: (v[0], v[1]))
    return tables

def extract_charts(slide, slide_idx, image_dir):
    """
    チャート：PNG（可能なら）とMD表を返す。出力順は座標順。
    """
    charts = []
    cidx = 0
    for shape in slide.shapes:
        for s in shape_iter(shape):
            if hasattr(s, "has_chart") and s.has_chart:
                top, left = int(s.top), int(s.left)
                png_path, md_table = chart_to_png_and_md(s, slide_idx, cidx, image_dir)
                charts.append((top, left, png_path, md_table))
                cidx += 1
    charts.sort(key=lambda v: (v[0], v[1]))
    return charts

def extract_images(slide, slide_idx, image_dir):
    """
    画像（ピクチャ）を抽出しファイル保存。返り値は (caption_md) の配列。
    """
    outputs = []
    pidx = 0
    for shape in slide.shapes:
        for s in shape_iter(shape):
            if s.shape_type == MSO_SHAPE_TYPE.PICTURE:
                image = s.image
                ext = image.ext or "png"
                fname = f"slide{slide_idx+1}_image{pidx+1}.{ext}"
                fpath = os.path.join(image_dir, fname)
                with open(fpath, "wb") as f:
                    f.write(image.blob)
                outputs.append(f"![スライド{slide_idx+1}の図{pidx+1}]({os.path.join(os.path.basename(image_dir), fname)})")
                pidx += 1
    return outputs

def extract_notes(slide):
    try:
        if slide.has_notes_slide:
            ns = slide.notes_slide
            if ns and ns.notes_text_frame and ns.notes_text_frame.text:
                return ns.notes_text_frame.text.strip()
    except Exception:
        pass
    return ""

def extract_content_to_markdown(input_pptx, output_md, image_dir, include_xml=False):
    try:
        prs = Presentation(input_pptx)
    except Exception as e:
        print(f"エラー: ファイル '{input_pptx}' を開けませんでした。{e}")
        return

    os.makedirs(image_dir, exist_ok=True)
    md = []

    for i, slide in enumerate(prs.slides):
        title = get_slide_title(slide)
        md.append(f"# スライド {i + 1}　{title}\n")

        """
        # テキスト（読み順ソート）
        text_blocks = extract_text_blocks(slide)
        if text_blocks:
            md.append("## テキスト\n")
            for _, _, t in text_blocks:
                md.append(t + "\n")
            md.append("\n")
        """

        # テキスト（読み順ソート）
        text_blocks = extract_text_blocks(slide)
        if text_blocks:
            md.append("## テキスト\n\n")
            # すべてのテキストボックスを (y,x) 順に処理し、行ごとに "- " を付与
            for _, _, t in text_blocks:
                for line in t.splitlines():
                    line = line.strip()
                    if not line:
                        continue
                    md.append(f"- {line}\n")
            md.append("\n")

        # 数式（読み順ソート）
        math_blocks = extract_math_blocks(slide)
        if math_blocks:
            md.append("## 数式\n")
            for _, _, latex, me in math_blocks:
                md.append(f"$$ {latex} $$\n\n")
                if include_xml:
                    omml_string = etree.tostring(me, pretty_print=True, encoding='unicode')
                    md.append(f"**元のOMML XML:**\n```xml\n{omml_string.strip()}\n```\n\n")

        # 表
        tables = extract_tables(slide)
        if tables:
            md.append("## 表\n\n")
            for _, _, tmd in tables:
                md.append(tmd + "\n\n")

        # グラフ
        charts = extract_charts(slide, i, image_dir)
        if charts:
            md.append("## グラフ\n\n")
            for _, _, png_path, md_table in charts:
                if png_path is not None:
                    rel = os.path.join(os.path.basename(image_dir), os.path.basename(png_path))
                    md.append(f"![スライド{i+1}のグラフ]({rel})\n\n")
                md.append("<details><summary>データ表</summary>\n\n")
                md.append(md_table + "\n\n</details>\n\n")

        # 画像
        pics = extract_images(slide, i, image_dir)
        if pics:
            md.append("## 図\n\n")
            for p in pics:
                md.append(p + "\n\n")

        # Notes
        notes = extract_notes(slide)
        if notes:
            md.append("## Notes\n\n")
            md.append(notes + "\n\n")

        md.append("---\n\n")

    with open(output_md, "w", encoding="utf-8") as f:
        f.write("".join(md))

    print(f"抽出が完了しました。結果は '{output_md}' に保存されました。")

def main():
    parser = argparse.ArgumentParser(description="PowerPointからテキスト/数式/表/グラフ/画像/ノートを抽出しMarkdown化します。")
    parser.add_argument("-i", "--input", required=True, help="入力 .pptx")
    parser.add_argument("-o", "--output", required=True, help="出力 .md")
    parser.add_argument("--xml", action="store_true", help="数式の元OMML XMLも併記")
    parser.add_argument("--imagedir", default="images", help="画像/グラフPNGの保存先ディレクトリ")
    args = parser.parse_args()

    if not os.path.exists(args.input):
        print(f"エラー: 指定された入力ファイル '{args.input}' が見つかりません。")
        return
    extract_content_to_markdown(args.input, args.output, args.imagedir, include_xml=args.xml)

if __name__ == "__main__":
    main()
