#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""2026Q3 销售汇报 PPT → Markdown 完整转写（含表格 / 图表系列 / OLE 附件 / 备注）。

按技能 pptx-full-transcription 的坑位写：
  · 页序取 presentation.xml 的 sldIdLst→rels 映射，不用文件名编号
  · 文本形状两个命名空间都试（p:txBody / a:txBody），空元素用 is None 判定
  · rels 路径 = 'ppt/slides/_rels/' + basename(sp) + '.rels'（不要再拼 .xml）
  · 表格 p:graphicFrame→a:tbl；OLE p:oleObj(r:id,progId)；图表 ppt/charts/chartN.xml

用法：env -u PYTHONPATH /opt/homebrew/bin/python3 脚本/pptx_to_md.py [--only 关键词]
产物：对外物料/2026Q3汇报_MD/<部门>/<汇报人>.md ＋ _合并版.md ＋ _转写统计.json
"""
import json
import os
import re
import sys
import zipfile

ROOT = '/Users/liuxinyuan/Desktop/hermas输入-工作台数据库/2026年3季度汇报/2026年3季度汇报'
CACHE = os.path.expanduser('~/.hermes/profiles/zone-b/cache/documents')
EXTRA = [('销售拓展部/6-徐',
          os.path.join(CACHE, 'doc_e440ed352618_诊断原料-2026年三季度销售工作汇报（销售人员版本）.pptx'))]
OUT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类/对外物料/2026Q3汇报_MD'
FOOTER = ('守护生命健康', 'BIORI', '宝锐生物', 'www.biori.com', 'marketing@biori')

P_NS = 'http://schemas.openxmlformats.org/presentationml/2006/main'
A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'


NOISE_RE = re.compile(r'^(守护生命健康|缔造美好生活|BIORI|宝锐生物|www\.biori\.com|marketing@biori\.com'
                         r'|守护生命健康[·・]?缔造美好生活|\d{1,3})$')


def is_noise(t):
    """页脚/口号/纯页码：整行匹配即丢（技能 §2 要求页脚不进 md）。"""
    s = re.sub(r'[\s·・|\-—]+', '', t)
    if not s:
        return True
    if NOISE_RE.match(s):
        return True
    if re.fullmatch(r'\d{1,3}', s):
        return True
    if s in ('守护生命健康缔造美好生活', '宝锐生物BIORI'):
        return True
    return False


def txt_of(xml):
    return ''.join(re.findall(r'<a:t>(.*?)</a:t>', xml, re.S))


def unesc(s):
    return (s.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
             .replace('&quot;', '"').replace('&apos;', "'").replace('\xa0', ' ')).strip()


def slide_order(z):
    pres = z.read('ppt/presentation.xml').decode('utf-8', 'ignore')
    rid = re.findall(r'<p:sldId[^>]*r:id="(rId\d+)"', pres)
    rels = z.read('ppt/_rels/presentation.xml.rels').decode('utf-8', 'ignore')
    r2t = dict(re.findall(r'Id="(rId\d+)"[^>]*Target="([^"]+)"', rels))
    return ['ppt/' + r2t[r].replace('../', '') for r in rid if r in r2t]


def rel_map(z, sp):
    rels = 'ppt/slides/_rels/' + os.path.basename(sp) + '.rels'
    if rels not in z.namelist():
        return {}
    x = z.read(rels).decode('utf-8', 'ignore')
    return {m[0]: 'ppt/' + m[1].replace('../', '')
            for m in re.findall(r'Id="(rId\d+)"[^>]*Target="([^"]+)"', x)}


def shapes(frag):
    """按文档顺序切出顶层形状块。"""
    out, i = [], 0
    while True:
        m = re.search(r'<p:(sp|grpSp|graphicFrame|pic)\b', frag[i:])
        if not m:
            break
        start = i + m.start()
        end = len(frag)
        for nxt in re.finditer(r'<p:(?:sp|grpSp|graphicFrame|pic)\b', frag[start + 5:]):
            end = start + 5 + nxt.start()
            break
        out.append(frag[start:end])
        i = end
    return out


def table_md(frag):
    rows = []
    for tr in re.findall(r'<a:tr\b.*?</a:tr>', frag, re.S):
        cells = [unesc(txt_of(tc).replace('</a:t>', '\n')) for tc in re.findall(r'<a:tc\b.*?</a:tc>', tr, re.S)]
        cells = [re.sub(r'\s+', ' ', c) for c in cells]
        rows.append(cells)
    if not rows:
        return None
    w = max(len(r) for r in rows)
    rows = [r + [''] * (w - len(r)) for r in rows]
    md = ['| ' + ' | '.join(rows[0]) + ' |', '|' + '---|' * w]
    for r in rows[1:]:
        md.append('| ' + ' | '.join(r) + ' |')
    return '\n'.join(md)


def chart_md(z, target):
    if target not in z.namelist():
        return None
    x = z.read(target).decode('utf-8', 'ignore')
    title = unesc(txt_of(re.search(r'<c:title>.*?</c:title>', x, re.S).group(0))) if '<c:title>' in x else ''
    ser = re.findall(r'<c:ser>.*?</c:ser>', x, re.S)
    cats = []
    mcat = re.search(r'<c:cat>.*?</c:cat>', x, re.S)
    if mcat:
        cats = [unesc(v) for v in re.findall(r'<c:pt idx="\d+"><c:v>(.*?)</c:v>', mcat.group(0), re.S)]
    lines = ['> 📊 **图表**（%s）%s' % (re.findall(r'<c:(\w+Chart)>', x)[0] if re.findall(r'<c:(\w+Chart)>', x) else 'chart',
                                     ('｜' + title) if title else '')]
    for s in ser:
        nm = unesc(txt_of(s.split('</c:tx>')[0])) if '<c:tx>' in s else ''
        vals = re.findall(r'<c:pt idx="\d+"><c:v>(.*?)</c:v>', s, re.S)
        lines.append('- 系列「%s」：%s' % (nm or '（无名）', ' ｜ '.join(unesc(v) for v in vals)))
    if cats:
        lines.append('- 类目：%s' % ' ｜ '.join(cats))
    return '\n'.join(lines)


def size_of(frag):
    m = re.search(r'<a:rPr[^>]*sz="(\d+)"', frag)
    return int(m.group(1)) if m else 0


def notes_of(z, rm):
    """该页的演讲者备注文本（无则返回 ''；备注页为空 ≠ 抽取失败）。"""
    for tgt in rm.values():
        if 'notesSlides/notesSlide' in tgt and tgt in z.namelist():
            x = z.read(tgt).decode('utf-8', 'ignore')
            txt = unesc(re.sub(r'\s+', ' ', ''.join(re.findall(r'<a:t>(.*?)</a:t>', x))))
            txt = re.sub(r'\s*\d+\s*$', '', txt)
            return txt.strip()
    return ''


def transcribe(path, dept, who):
    z = zipfile.ZipFile(path)
    slides = slide_order(z)
    md, stats = [], {'部门': dept, '汇报人': who, '文件': os.path.basename(path), '页数': len(slides),
                     '表格': 0, '图表': 0, 'OLE': 0, '备注页': 0, '图片': 0, 'run数': 0, '未转写': []}
    md.append('# %s · 2026 年三季度销售工作汇报（完整转写）\n' % who)
    md.append('> 源文件：`%s`　｜　部门：%s　｜　共 %d 页　｜　转写方式：XML 直读（文本/表格/图表/OLE/备注）\n' % (
        os.path.basename(path), dept, len(slides)))
    for i, sp in enumerate(slides, 1):
        x = z.read(sp).decode('utf-8', 'ignore')
        rm = rel_map(z, sp)
        runs = re.findall(r'<a:t>(.*?)</a:t>', x)
        stats['run数'] += len(runs)
        body, title, title_sz = [], '', 0
        for frag in shapes(x):
            if '<a:tbl>' in frag:
                t = table_md(frag)
                if t:
                    stats['表格'] += 1
                    body.append(t)
                continue
            if '<c:chart' in frag or '<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"' in frag:
                rid = re.search(r'r:id="(rId\d+)"', frag)
                if rid and rm.get(rid.group(1), '').endswith('.xml'):
                    cm = chart_md(z, rm[rid.group(1)])
                    if cm:
                        stats['图表'] += 1
                        body.append(cm)
                continue
            if '<p:pic' in frag or '<a:blip' in frag:
                stats['图片'] += 0  # 逐页媒体在 rels 里统计
                continue
            t = unesc(re.sub(r'\s+', ' ', txt_of(frag)))
            if not t:
                continue
            if is_noise(t):
                continue
            fs = size_of(frag)
            if fs > title_sz and 4 < len(t) < 60 and not any(f in t for f in FOOTER):
                title, title_sz = t, fs
            body.append(t)
        imgs = []
        for m in re.finditer(r'<a:blip[^>]*r:embed="(rId\d+)"', x):
            t = rm.get(m.group(1), '')
            if t.startswith('ppt/media/') and os.path.basename(t) not in imgs:
                imgs.append(os.path.basename(t))
        stats['图片'] += len(imgs)
        note = ''
        if z.namelist():
            pass
        nsl = [n for n in z.namelist() if re.match(r'ppt/notesSlides/notesSlide\d+\.xml$', n)]
        srel = rm.get('rId1', '')
        md.append('---\n\n## 第 %d 页%s\n' % (i, ('　·　' + title) if title else ''))
        for b in body:
            if b.startswith('>'):
                md.append(b)
            elif b.startswith('|') or '\n' in b:
                md.append(b)
            else:
                md.append('- %s' % b)
        seen_ole = {}
        for m in re.finditer(r'<p:oleObj[^>]*?r:id="(rId\d+)"[^>]*?progId="([^"]*)"', x, re.S):
            rid, prog = m.group(1), m.group(2)
            if rid in seen_ole:
                continue
            seen_ole[rid] = rm.get(rid, '(未解析)')
        for rid, tgt in seen_ole.items():
            stats['OLE'] += 1
            md.append('> 📎 **嵌入 OLE 对象**：progId=%s ｜ 内嵌文件 `%s`'
                      % ('Excel.Sheet.12' if 'Excel' in tgt or 'Workbook' in tgt or tgt.endswith('.xlsx') else '见文件', os.path.basename(tgt)))
        nt = notes_of(z, rm)
        if nt:
            stats['备注页'] += 1
            md.append('\n> 📝 **演讲者备注**：%s\n' % nt)
        if imgs:
            md.append('\n> 🖼 本页图片 %d 张：%s\n' % (len(imgs), '、'.join(sorted(set(imgs)))))
    z.close()
    return '\n'.join(md), stats


def main():
    only = None
    if '--only' in sys.argv:
        only = sys.argv[sys.argv.index('--only') + 1]
    jobs = []
    for root, dirs, fs in os.walk(ROOT):
        dept = os.path.relpath(root, ROOT).split(os.sep)[0]
        for f in sorted(fs):
            if f.endswith('.pptx') and not f.startswith('.') and '转正答辩' not in f:
                jobs.append((dept, f.replace('.pptx', ''), os.path.join(root, f), os.path.basename(root)))
    done = {re.sub(r'[\s()（）]+', '', w) for _, w, _, _ in jobs}
    for _dept, _p in EXTRA:
        w = os.path.basename(_p).replace('.pptx', '')
        w = re.sub(r'^doc_[0-9a-f]+_', '', w)
        if re.sub(r'[\s()（）]+', '', w) in done:
            continue
        jobs.append(('销售拓展部', w, _p, '6-徐'))
    all_stats, parts = [], []
    for dept, who, p, sub in jobs:
        if only and only not in who:
            continue
        try:
            md, st = transcribe(p, dept, who)
        except Exception as e:
            print('✗ %s：%s' % (who, e))
            continue
        d = os.path.join(OUT, dept)
        os.makedirs(d, exist_ok=True)
        fn = os.path.join(d, re.sub(r'[／/]', '_', who) + '.md')
        open(fn, 'w', encoding='utf-8').write(md)
        all_stats.append(st)
        parts.append(md)
        print('✓ %-42s %2d 页 ｜ 表格 %2d ｜ 图表 %d ｜ OLE %d ｜ 图片 %2d ｜ run %4d ｜ %.1f KB'
              % (who[:42], st['页数'], st['表格'], st['图表'], st['OLE'], st['图片'], st['run数'], len(md.encode()) / 1024.0))
    if parts:
        open(os.path.join(OUT, '_合并版.md'), 'w', encoding='utf-8').write('\n\n\n'.join(parts))
        json.dump(all_stats, open(os.path.join(OUT, '_转写统计.json'), 'w', encoding='utf-8'),
                  ensure_ascii=False, indent=1)
    print('\n合计 %d 份 ｜ 输出 %s' % (len(all_stats), OUT))


if __name__ == '__main__':
    main()
