#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 PPT撰写任务 目录下用户上传的 37 个文件分类并完整转写为 Markdown。

用法：/usr/bin/python3 脚本/transcribe_ppt_tasks.py [--force]
输出：PPT撰写任务/_MD转写/
"""
from __future__ import annotations

import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path

import fitz
import openpyxl
import pytesseract
import docx
from docx.table import Table
from docx.text.paragraph import Paragraph
from PIL import Image
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE

BASE = Path('/Users/liuxinyuan/Desktop/Hermes输出-工作类')
ROOT = BASE / 'PPT撰写任务'
OUT = ROOT / '_MD转写'
FORCE = '--force' in sys.argv
NOW = '2026-11-02'

# 分类：按源文件相对路径的规则匹配；先匹配先得
CATEGORY_RULES = [
    ('00_原始任务与培训计划', lambda p: p.name.startswith('诊断原料销售新人培训课程计划安排')),
    ('01_公司介绍与行业市场', lambda p: (
        '珠海宝锐生物科技有限公司-行业公司产品讲解' in p.name
        or '各来源分子诊断市场规模' in p.name
        or '头豹研究院' in p.name
        or '全式金IPO招股书' in p.name
    )),
    ('02_销售认知与客户开发', lambda p: (
        '客户关系管理规划' in p.name
        or '客户开发思路拓展' in p.name
        or '/驻地赋能培训第一期/' in str(p)
        and not any(x in p.name for x in [
            '冻干服务流程介绍', '冻干服务培训测试题', '冻干服务流程方案',
            'FM类产品冻干程序汇总', '20230224-qPCR产品知识解答',
            '产品清单培训PPT', 'qPCR和LAMP产品清单培训试题',
            'LAMP产品思维导图'
        ])
        and '/产品资料/' not in str(p)
    )),
    ('03_冻干服务流程', lambda p: (
        any(x in p.name for x in [
            '20230224-qPCR产品知识解答', '冻干服务流程介绍', '冻干服务培训测试题',
            '冻干服务流程方案', 'FM类产品冻干程序汇总'
        ])
    )),
    ('04_产品清单与产品资料', lambda p: (
        '产品清单培训' in str(p) or '/产品资料/' in str(p) or 'LAMP产品思维导图' in p.name
    )),
]
CATEGORY_ORDER = [
    '00_原始任务与培训计划',
    '01_公司介绍与行业市场',
    '02_销售认知与客户开发',
    '03_冻干服务流程',
    '04_产品清单与产品资料',
]


def category_of(rel: Path) -> str:
    for name, pred in CATEGORY_RULES:
        if pred(rel):
            return name
    return '99_未分类'


def sha256_file(p: Path) -> str:
    h = hashlib.sha256()
    with p.open('rb') as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b''):
            h.update(chunk)
    return h.hexdigest()


def md_cell(v) -> str:
    if v is None:
        return ''
    if isinstance(v, (datetime,)):
        v = v.isoformat(sep=' ')
    elif isinstance(v, float):
        v = ('%g' % v)
    s = str(v).replace('\r\n', '\n').replace('\r', '\n')
    s = s.replace('|', r'\|').replace('\n', '<br>')
    return s.strip()


def md_table(rows: list[list]) -> str:
    if not rows:
        return ''
    ncol = max(len(r) for r in rows)
    out = []
    header = [md_cell(x) for x in rows[0]] + [''] * (ncol - len(rows[0]))
    out.append('| ' + ' | '.join(header) + ' |')
    out.append('| ' + ' | '.join(['---'] * ncol) + ' |')
    for r in rows[1:]:
        vals = [md_cell(x) for x in r] + [''] * (ncol - len(r))
        out.append('| ' + ' | '.join(vals) + ' |')
    return '\n'.join(out)


def yaml_quote(s: str) -> str:
    return json.dumps(s, ensure_ascii=False)


def header(title: str, source: str, category: str, ftype: str, units: str,
           status: str = 'success', confidence: str = '高', note: str = '') -> str:
    return (
        '---\n'
        f'title: {yaml_quote(title)}\n'
        f'source: {yaml_quote(source)}\n'
        f'category: {yaml_quote(category)}\n'
        f'file_type: {yaml_quote(ftype)}\n'
        f'units: {yaml_quote(units)}\n'
        f'transcribed_at: {yaml_quote(NOW)}\n'
        f'status: {yaml_quote(status)}\n'
        f'confidence: {yaml_quote(confidence)}\n'
        f'note: {yaml_quote(note)}\n'
        '---\n\n'
    )


VISION_OCR = Path(__file__).resolve().parent / 'vision_ocr'


def ocr_image(img: Image.Image) -> str:
    """优先 macOS Vision（中文准确率明显高于 tesseract）；失败再回退。"""
    tmp = None
    try:
        if VISION_OCR.exists():
            fd, name = tempfile.mkstemp(suffix='.png')
            os.close(fd)
            tmp = Path(name)
            img.save(tmp, 'PNG')
            r = subprocess.run([str(VISION_OCR), str(tmp), 'zh-Hans'], capture_output=True, text=True)
            txt = r.stdout.strip()
            if txt:
                return txt
    except Exception:
        pass
    finally:
        if tmp:
            tmp.unlink(missing_ok=True)
    try:
        return pytesseract.image_to_string(img, lang='chi_sim+eng', config='--psm 6').strip()
    except Exception as e:
        return f'[OCR失败：{type(e).__name__}: {e}]'


def transcribe_pdf(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    d = fitz.open(str(src))
    parts = [header(src.stem, str(rel), cat, 'PDF', f'{len(d)} 页', note='文字层优先；无文字/低文字页使用 OCR 补充')]
    ocr_pages = 0
    for i, page in enumerate(d, 1):
        text = page.get_text('text').strip()
        imgs = page.get_images(full=True)
        ocr = ''
        # 无文字，或文字很少且本页有图 -> OCR
        if (not text) or (len(text) < 220 and imgs):
            pix = page.get_pixmap(dpi=220, alpha=False)
            img = Image.frombytes('RGB', [pix.width, pix.height], pix.samples)
            ocr = ocr_image(img)
            if ocr:
                ocr_pages += 1
        parts.append(f'## 第 {i} 页\n')
        if text:
            parts.append(text)
            parts.append('')
        if ocr:
            parts.append('### OCR 补充\n')
            parts.append(ocr)
            parts.append('')
        if not text and not ocr:
            parts.append('> 本页无可提取文字。\n')
    note = f'OCR 页数：{ocr_pages}/{len(d)}；PDF 共 {len(d)} 页。'
    body = '\n'.join(parts).strip() + '\n'
    return body, {'units': f'{len(d)} 页', 'ocr_pages': ocr_pages, 'status': 'success' if (len(body) > len(header('','','','',''))) else 'error', 'confidence': '中' if ocr_pages else '高', 'note': note}


def iter_block_items(doc):
    body = doc.element.body
    for child in body.iterchildren():
        if child.tag.endswith('}p'):
            yield Paragraph(child, doc)
        elif child.tag.endswith('}tbl'):
            yield Table(child, doc)


def transcribe_docx(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    d = docx.Document(str(src))
    parts = [header(src.stem, str(rel), cat, 'DOCX', f'{len(d.paragraphs)} 段 / {len(d.tables)} 表')]
    for block in iter_block_items(d):
        if isinstance(block, Paragraph):
            t = block.text.strip()
            if not t:
                continue
            style = (block.style.name or '')
            if style.lower().startswith('heading'):
                level = re.sub(r'\D', '', style) or '2'
                parts.append('#' * min(int(level) + 1, 6) + ' ' + t + '\n')
            else:
                parts.append(t + '\n')
        else:
            rows = []
            for row in block.rows:
                rows.append([c.text for c in row.cells])
            if rows:
                parts.append(md_table(rows) + '\n')
    body = '\n'.join(parts).strip() + '\n'
    return body, {'units': f'{len(d.paragraphs)} 段 / {len(d.tables)} 表', 'status': 'success', 'confidence': '高', 'note': ''}


def transcribe_doc(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    r = subprocess.run(['textutil', '-convert', 'txt', '-stdout', str(src)], capture_output=True)
    txt = r.stdout.decode('utf-8', 'replace').replace('\r\n', '\n').replace('\r', '\n').strip()
    parts = [header(src.stem, str(rel), cat, 'DOC（旧版 Word）', '1 份', note='textutil 提取正文；旧版 .doc 无表格结构信息')]
    parts.append(txt or '> 未提取到文字。')
    body = '\n'.join(parts).strip() + '\n'
    return body, {'units': '1 份', 'status': 'success' if txt else 'error', 'confidence': '中', 'note': f'textutil exit={r.returncode}'}


def transcribe_xlsx(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    tmp = None
    path = src
    if src.suffix.lower() == '.ksheet':
        tmp = Path(tempfile.mkstemp(suffix='.xlsx')[1])
        shutil.copyfile(src, tmp)
        path = tmp
    wb = openpyxl.load_workbook(path, data_only=True, read_only=False)
    parts = [header(src.stem, str(rel), cat, 'XLSX/KSHEET', f'{len(wb.sheetnames)} 个工作表', note='原样转写；空单元格保持为空')]
    details = []
    for ws in wb.worksheets:
        parts.append(f'\n## 工作表：{ws.title}\n')
        merged = [str(r) for r in ws.merged_cells.ranges]
        if merged:
            parts.append('> 合并单元格：' + '、'.join(merged) + '\n')
        rows = []
        for row in ws.iter_rows(values_only=True):
            rows.append(list(row))
        if not rows:
            parts.append('> 空工作表。\n')
            continue
        # 去掉完全空的尾部行，保留中间空行
        while rows and all(v is None or str(v).strip() == '' for v in rows[-1]):
            rows.pop()
        if rows:
            parts.append(md_table(rows) + '\n')
        details.append(f'{ws.title}:{len(rows)}行×{ws.max_column}列')
    if tmp:
        tmp.unlink(missing_ok=True)
    body = '\n'.join(parts).strip() + '\n'
    return body, {'units': '; '.join(details), 'status': 'success', 'confidence': '高', 'note': ''}


def shape_text(shape) -> str:
    try:
        if shape.has_text_frame:
            return shape.text_frame.text.strip()
    except Exception:
        pass
    return ''


def walk_shapes(shapes):
    for sh in shapes:
        try:
            if sh.shape_type == MSO_SHAPE_TYPE.GROUP:
                yield from walk_shapes(sh.shapes)
            else:
                yield sh
        except Exception:
            yield sh


def transcribe_pptx(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    prs = Presentation(str(src))
    parts = [header(src.stem, str(rel), cat, 'PPTX', f'{len(prs.slides)} 张幻灯片', note='正文/表格/图表/备注/图片OCR 四通道；图片保留为文字标注')]
    ocr_count = 0
    ocr_cache = {}
    for i, slide in enumerate(list(prs.slides), 1):
        parts.append(f'\n## 幻灯片 {i}\n')
        shapes = []
        for sh in walk_shapes(slide.shapes):
            try:
                shapes.append((sh.top or 0, sh.left or 0, sh))
            except Exception:
                shapes.append((0, 0, sh))
        shapes.sort(key=lambda x: (x[0], x[1]))
        for _, __, sh in shapes:
            # 文本
            t = shape_text(sh)
            if t:
                parts.append(t + '\n')
            # 表格
            try:
                if sh.has_table:
                    rows = [[c.text for c in row.cells] for row in sh.table.rows]
                    parts.append(md_table(rows) + '\n')
            except Exception:
                pass
            # 图表
            try:
                if sh.has_chart:
                    ch = sh.chart
                    rows = [['系列', '类别', '数值']]
                    try:
                        cats = [str(x) for x in ch.plots[0].categories]
                    except Exception:
                        cats = []
                    for series in ch.series:
                        vals = list(series.values)
                        for j, v in enumerate(vals):
                            catv = cats[j] if j < len(cats) else ''
                            rows.append([series.name or '', catv, v])
                    parts.append('**图表数据**\n\n' + md_table(rows) + '\n')
            except Exception:
                pass
            # 图片 OCR
            try:
                if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
                    img = sh.image
                    blob = img.blob
                    h = hashlib.sha256(blob).hexdigest()
                    if h not in ocr_cache:
                        try:
                            pil = Image.open(io.BytesIO(blob)).convert('RGB')
                            ocr_cache[h] = (img.filename or f'image_{h[:8]}', ocr_image(pil))
                        except Exception as e:
                            ocr_cache[h] = (img.filename or f'image_{h[:8]}', f'[图片读取失败：{e}]')
                    name, txt = ocr_cache[h]
                    letter_count = len(re.findall(r'[\u4e00-\u9fffA-Za-z]', txt))
                    if letter_count >= 4:
                        parts.append(f'🖼 **图片文字（{name}）**\n\n{txt}\n')
                        ocr_count += 1
                    else:
                        parts.append(f'🖼 [图片：{name}]\n')
            except Exception:
                pass
        # 备注
        try:
            notes = slide.notes_slide.notes_text_frame.text.strip()
            parts.append('**备注：** ' + (notes if notes else '备注为空') + '\n')
        except Exception:
            parts.append('**备注：** 备注为空\n')
    body = '\n'.join(parts).strip() + '\n'
    return body, {'units': f'{len(prs.slides)} 张幻灯片', 'ocr_images': ocr_count, 'status': 'success', 'confidence': '中' if ocr_count else '高', 'note': f'图片 OCR {ocr_count} 张'}


def transcribe_image(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    img = Image.open(src).convert('RGB')
    raw = ocr_image(img)
    # 原图是课程计划表，人工校对后按原表结构转写；OCR 原文附后
    manual = '''## 课程计划表（人工校对）

### 标题

诊断原料销售新人培训课程计划安排（总部 2 周 10 个工作日）

### 表格

| 课程类别 | 学习课程 | 讲师 | 时间安排 | 课程时长 | 教学方式 | 考核方式 | 备注 | 截止时间 |
|---|---|---|---|---|---|---|---|---|
| 分子诊断行业知识 | 第一节 公司业务布局/市场容量与分布 | 刘新元 | 第一周第二天 | 2H | 现场授课 | 笔试试卷 | 主讲人需对内容负责 | 2026.11.20 |
| 销售管理和绩效考核 | 第二节—销售管理、客户项目管理、售后服务管理（飞书…） | 刘新元 | 第二周第三天 | 2H | 现场授课 | 日常执行 |  | 2026.11.30 |
| 销售专项技能培训 | 第一节—客情关系升级与维护（含授课和视频） | 刘新元 | 第二周第四天 | 4H | 现场授课 | 日常执行 |  | 2026.11.10 |
| 销售专项技能培训 | 第二节—公司介绍PPT（含qPCR产品介绍、分子酶定制化、场景化产品解决方案、冻干服务介绍） | 刘新元 | 第二周第四天 | 2H | 现场授课 | PPT介绍转化考核 | 需时总审核 | 2026.11.10 |
| 销售专项技能培训 | 第三节—销售技巧和销售话术 | 刘新元 | 第二周第五天 | 1H | 视频课程 | 日常执行 | 《销售就是玩转情商》《大客户销售：谋攻之道》《细节营销》《先发影响力》 | 2026.11.30 |
| 销售专项技能培训 | 第四节—公司介绍PPT转化考核 | 刘新元 | 第二周第五天 | 2H | 模拟演练 | 考核打分 |  | 未标注 |

> 说明：原图右侧“回复部门所负责培训课程教材ppt更新制作、笔试试卷及分管领导完成审核截止时间”为截止时间列。
> 原图中课程 02 标题与课程 06 截止时间被裁切；上表按可见内容照录，未补猜。

### OCR 原文

```text
''' + raw + '''
```
'''
    body = header(src.stem, str(rel), cat, 'JPG', '1 张') + manual.strip() + '\n'
    return body, {'units': '1 张', 'status': 'success', 'confidence': '中', 'note': '课程计划表人工校对；其余图像文字见 OCR 原文'}


def transcribe_file(src: Path, rel: Path, cat: str) -> tuple[str, dict]:
    ext = src.suffix.lower()
    if ext == '.pdf':
        return transcribe_pdf(src, rel, cat)
    if ext == '.pptx':
        return transcribe_pptx(src, rel, cat)
    if ext == '.docx':
        return transcribe_docx(src, rel, cat)
    if ext == '.doc':
        return transcribe_doc(src, rel, cat)
    if ext in ('.xlsx', '.ksheet'):
        return transcribe_xlsx(src, rel, cat)
    if ext in ('.jpg', '.jpeg', '.png'):
        return transcribe_image(src, rel, cat)
    raise ValueError(f'未支持类型：{ext}')


def main():
    OUT.mkdir(parents=True, exist_ok=True)
    files = []
    for dp, dn, fn in os.walk(ROOT):
        if '_MD转写' in dp:
            continue
        for f in sorted(fn):
            if f == 'README.md':
                continue
            p = Path(dp) / f
            rel = p.relative_to(ROOT)
            files.append((p, rel, category_of(rel)))
    # 分类并排序
    files.sort(key=lambda x: (CATEGORY_ORDER.index(x[2]) if x[2] in CATEGORY_ORDER else 99, str(x[1])))
    # 找重复（同 SHA256）
    hashes = {}
    for p, rel, cat in files:
        try:
            hashes.setdefault(sha256_file(p), []).append((p, rel, cat))
        except Exception as e:
            print('hash error', p, e, flush=True)
    canonical = {}
    for h, group in hashes.items():
        if len(group) > 1:
            first = group[0]
            for p, rel, cat in group:
                canonical[str(rel)] = (h, first)
    records = []
    log = []
    start = time.time()
    for idx, (p, rel, cat) in enumerate(files, 1):
        # 输出路径：分类目录 + 同名 .md
        out_dir = OUT / cat
        out_dir.mkdir(parents=True, exist_ok=True)
        md = out_dir / (p.stem + '.md')
        print(f'[{idx}/{len(files)}] {rel} -> {md}', flush=True)
        if md.exists() and not FORCE:
            records.append({'source': str(rel), 'category': cat, 'output': str(md.relative_to(OUT)), 'status': 'existing'})
            print('  skip existing', flush=True)
            continue
        # 重复文件：不重复 OCR，写引用页
        if str(rel) in canonical and canonical[str(rel)][1][1] != rel:
            h, first = canonical[str(rel)]
            first_rel = first[1]
            first_md = OUT / first[2] / (first[0].stem + '.md')
            body = header(p.stem, str(rel), cat, p.suffix.lstrip('.').upper(), '—', status='duplicate', confidence='高',
                           note=f'与 {first_rel} 字节相同（SHA256 {h[:16]}…），内容转写见对应 MD。')
            body += f'\n本文件与 `{first_rel}` 完全相同（SHA256 `{h}`）。\n\n正文转写见：`{first_md.relative_to(OUT)}`\n'
            md.write_text(body, encoding='utf-8')
            records.append({'source': str(rel), 'category': cat, 'output': str(md.relative_to(OUT)), 'status': 'duplicate',
                            'units': '—', 'note': f'duplicate of {first_rel}', 'chars': len(body)})
            log.append({'source': str(rel), 'status': 'duplicate', 'output': str(md.relative_to(OUT))})
            continue
        try:
            body, meta = transcribe_file(p, rel, cat)
            md.write_text(body, encoding='utf-8')
            rec = {'source': str(rel), 'category': cat, 'output': str(md.relative_to(OUT)),
                   'status': meta.get('status', 'success'), 'units': meta.get('units', ''),
                   'note': meta.get('note', ''), 'chars': len(body),
                   'confidence': meta.get('confidence', ''), 'type': p.suffix.lstrip('.').upper()}
            records.append(rec)
            log.append(rec)
            print(f'  ok {meta.get("status")} chars={len(body)} {meta.get("note","")}', flush=True)
        except Exception as e:
            err = f'# 转写失败\n\n源文件：`{rel}`\n\n错误：`{type(e).__name__}: {e}`\n'
            md.write_text(header(p.stem, str(rel), cat, p.suffix.lstrip('.').upper(), '—', status='error', confidence='低') + err, encoding='utf-8')
            records.append({'source': str(rel), 'category': cat, 'output': str(md.relative_to(OUT)), 'status': 'error',
                            'units': '—', 'note': f'{type(e).__name__}: {e}', 'chars': len(err)})
            log.append(records[-1])
            print('  ERROR', type(e).__name__, e, flush=True)
    # 索引
    lines = ['# PPT撰写任务 · 文件分类与转写索引', '',
             f'- 生成日期：{NOW}',
             f'- 源文件：`PPT撰写任务/`（不含本索引、README）',
             f'- 转写件：`PPT撰写任务/_MD转写/`',
             f'- 文件总数：{len(files)}；成功/重复/错误：{sum(1 for r in records if r["status"]=="success")}/{sum(1 for r in records if r["status"]=="duplicate")}/{sum(1 for r in records if r["status"]=="error")}',
             '', '## 分类说明', '']
    for cat in CATEGORY_ORDER:
        cat_recs = [r for r in records if r['category'] == cat]
        if not cat_recs:
            continue
        lines += [f'### {cat}', '', f'共 {len(cat_recs)} 个文件。', '',
                  '| # | 源文件 | 类型 | 页/片/表 | 转写件 | 状态 | 说明 |',
                  '|---|---|---|---|---|---|---|']
        for j, r in enumerate(cat_recs, 1):
            lines.append('| %d | `%s` | %s | %s | `%s` | %s | %s |' % (
                j, r['source'], r.get('type', ''), r.get('units', ''), r['output'], r['status'], (r.get('note') or '').replace('|', r'\|')))
        lines.append('')
    lines += ['## 转写规则', '',
              '- PDF：优先文字层；无文字或低文字且含图的页面用 macOS Vision OCR（中文）补充，Tesseract 仅作兜底，并保留页码。',
              '- PPTX：正文、原生表格、图表数据、图片文字 OCR、备注五类内容；图片保留文件标注。',
              '- DOCX：段落 + 表格按原文顺序转写。',
              '- DOC：`textutil` 提取纯文本，旧版格式无表格结构信息。',
              '- XLSX/KSHEET：逐工作表转 Markdown 表格；空单元格保持空；合并单元格范围单列说明。',
              '- 重复文件：SHA256 相同者写引用页，不重复 OCR，避免同一内容两份长文。',
              '- 原文中的错别字、口径矛盾未擅自修正；仅在需要时保留原文。', '',
              '## 完整性与待核对', '',
              '- 所有文件均已生成对应 MD；状态见上表。',
              '- 扫描页/图片页的文字由 OCR 识别，数字、英文缩写、手写内容可能有个别误识，使用前请对照原件。',
              '- `客户关系管理规划：关键客户界面规划管理.ksheet` 实际为 Excel 2007+ 文件，按 XLSX 读取。',
              '- 课程计划表原图中课程 02 标题、课程 06 截止时间被裁切，按可见内容照录。', '']
    idx_path = OUT / '00_文件分类与转写索引.md'
    idx_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
    (OUT / '_transcription_log.json').write_text(json.dumps(log, ensure_ascii=False, indent=2), encoding='utf-8')
    print('DONE', len(records), 'elapsed', round(time.time() - start, 1), 'index', idx_path, flush=True)


if __name__ == '__main__':
    main()
