# -*- coding: utf-8 -*-
"""组卷台 · pandoc 真公式 Word 导出核心。
思路（用户要求"复制母版进去改"）：每次导出都复制用户母版 导出模版-专题练习.docx，
把页眉标题换成本卷专题、清空正文，作为 pandoc 的 --reference-doc；
这样页眉（《标题》专题练习 + 日期/姓名）、页脚（页码域）、字体/页边距 全部原样保留，
而正文由 pandoc 从 markdown 生成、$...$ 转成 Word 原生可编辑公式(OMML)。
用户以后改母版的版式，导出会自动跟着变（因为是运行时复制母版）。
供 组卷台AI助手.py 的 /api/export_docx 调用;也可独立测试。"""
import os, re, base64, subprocess, tempfile, shutil
import docx
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

HERE = os.path.dirname(os.path.abspath(__file__))
MASTER = os.path.join(HERE, '导出模版-专题练习.docx')    # 用户母版：复制它当参考底，只换正文
TEMPLATE = os.path.join(HERE, '导出模板_专题练习.docx')   # 兜底参考底：母版缺失时退回它
PANDOC = shutil.which('pandoc') or '/opt/homebrew/bin/pandoc'
# 答题空选项 -> (并排算式行高cm, 整行题后书写空白cm)。一页约: 小4题/中3题/大2题
LAYOUT = {0: (0.0, 0.0), 1: (3.2, 4.2), 2: (4.5, 6.5), 3: (6.0, 10.5)}


def _stars(d):
    try:
        return '★' * int(d)
    except Exception:
        return ''


def _strip_lead_no(stem):
    """成卷清理(用户要求，题库本身不动)：
    ① 首行开头的"书本题号"(如 '6. '、'10) ')
    ② 首行开头的「计算：」前缀(纯冗余；注意「用简便方法计算：」是解题要求，不删)
    ③ 行尾公式后紧跟的句号($…$。/．/. —— 算式后挂句号难看；应用题文字句号不动)"""
    s = str(stem)
    lines = s.split('\n')
    if lines:
        lines[0] = re.sub(r'^\s*\d+[.)]\s+', '', lines[0], count=1)
        # 只删**纯冗余**的指令前缀。⚠ 绝大多数算类前缀是**解题要求，不能删** ——
        #   「用竖式计算」212 处、「用递等式计算」219 处、「列式计算」240 处、
        #   「直接写出得数」513 处、「用简便方法计算」82 处、「看图列式计算」110 处，
        #   删掉学生就不知道该用什么方法做，这道题的教学目的也没了
        #   （「125×8」横式算对也算对，但题目本意是练竖式）。
        #   真冗余的只有下面这两个（约 500 处），别往里加。
        lines[0] = re.sub(r'^\s*(?:计算|算一算)[：:。．.，,]\s*', '', lines[0], count=1)
    lines = [re.sub(r'(\$)\s*[。．.;；]+\s*$', r'\1', ln) for ln in lines]      # 行尾公式后的句号/分号
    while lines and not lines[0].strip():   # 前缀剥掉后残留的空首行(胡小群"计算。\n$算式$"格式)——不清掉会被误判成多行题
        lines.pop(0)
    return '\n'.join(lines)


_NM_CJK = re.compile(r'[一-鿿㐀-䶿]')
_NM_OP = [(r'\\times', '×'), (r'\\div', '÷'), (r'\\cdot', '·'), (r'\\pm', '±'),
          (r'\\leq', '≤'), (r'\\geq', '≥'), (r'\\neq', '≠'), (r'\\ ', ' ')]

def _normalize_math(s):
    """修 pandoc 公式分隔符坏点(护栏,数据已清洗后幂等):独立运算符段→unicode、
    含CJK的$..$段→纯文本、去 $ 内侧空格、cfrac/dfrac→frac。含字面 \\$ 的题不动。"""
    s = str(s)
    if '$' not in s or '\\$' in s:
        return s
    s = s.replace('\\cfrac', '\\frac').replace('\\dfrac', '\\frac').replace('\\tfrac', '\\frac')
    for pat, rep in [(r'\\times', '×'), (r'\\div', '÷'), (r'\\cdot', '·'), (r'\+', '+'),
                     (r'-', '−'), (r'>', '>'), (r'<', '<'), (r'=', '='),
                     (r'\\pm', '±'), (r'\\leq', '≤'), (r'\\geq', '≥'), (r'\\neq', '≠')]:
        s = re.sub(r'\$\s*' + pat + r'\s*\$', rep, s)
    def _seg(m):
        inner = m.group(1)
        if _NM_CJK.search(inner):
            t = inner
            for pat, rep in _NM_OP:
                t = re.sub(pat, rep, t)
            return t.replace('\\', '')
        return m.group(0)
    s = re.sub(r'\$([^$]+)\$', _seg, s)
    out = []; indollar = False; skip = False
    for c in s:
        if c == '$':
            if not indollar:
                indollar = True; out.append(c); skip = True
            else:
                while out and out[-1] == ' ':
                    out.pop()
                indollar = False; out.append(c); skip = False
        elif skip and c == ' ':
            continue
        else:
            skip = False; out.append(c)
    return ''.join(out)


def _prep_stem(stem):
    """让题干对 pandoc 友好:先归一化公式分隔符(护栏),再表格前补空行(否则不识别为表格)。"""
    s = _normalize_math(str(stem))
    lines = s.split('\n')
    out = []
    for i, ln in enumerate(lines):
        if ln.lstrip().startswith('|') and out and out[-1].strip() and not out[-1].lstrip().startswith('|'):
            out.append('')
        out.append(ln)
    return '\n'.join(out)


# ---- 纯算式题判定(两道并一行) ----
_CJK = re.compile(r'[一-鿿　-〿！-･]')
_EXPR_TEX = re.compile(r'^(?:[^$\n]{0,14}[：:])?\s*\$[^$]+\$\s*(?:的值|的得数)?\s*[。．.]?\s*$')
_EXPR_PLAIN = re.compile(r'^(?:[^\d$\n]{0,14}[：:])?[\d\s.。,，、+\-×÷*/=()（）　？?]+$')


def _plain_tex(s):
    """LaTeX 近似转纯文本，用于估算渲染宽度。"""
    t = re.sub(r'\$\$?', '', str(s))
    t = t.replace('\\times', '×').replace('\\div', '÷').replace('\\cdot', '·')
    t = re.sub(r'\\(?:c?dots|ldots)', '…', t)
    t = re.sub(r'\\[dtc]?frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}', r'\1/\2', t)
    t = re.sub(r'\\[a-zA-Z]+', '', t)
    t = re.sub(r'[{}\\]', '', t)
    return t.strip()


def _is_short_calc(it):
    """纯算式题(可两道并排)：无图、单行、题干就是一个算式(可带「用简便方法计算：」这类短指令)、
    含运算符、估宽塞得进半行(汉字记1、其他记0.55，阈值16)。应用题/多小题/带图/含表格题一律不并排。"""
    if it.get('figdata') or it.get('inlinefig'):    # 带内联公式图的也不并排
        return False
    s = _strip_lead_no(it.get('stem', '')).strip()
    if not s or '\n' in s or '|' in s:
        return False
    if not (_EXPR_TEX.match(s) or _EXPR_PLAIN.match(s)):
        return False
    p = _plain_tex(s)
    if not re.search(r'[+\-×÷*/=]', p):
        return False
    w = sum(1.0 if _CJK.match(c) else 0.55 for c in p if not c.isspace())   # 不数空格:公式渲染是紧凑的
    return w <= 16


# ================= 版式（2026-08-15 定版，逐项经用户拍板 + 实物验证）=================
#
# 旧版只有两档：短算式 → 小空白，其余一律 6.5cm。结果是选择题只写个字母也占掉半页、
# 多小问三问挤成一行共用一块空白。现在按【原书声明的题型】× 【带图/多小问】套模板。
#
# ⚠ 题型**优先读原书声明的字段**（qtype / question_type_raw，从原卷大题头抽的），
#   自写正则只在字段缺失时兜底 —— 实测正则与原书声明只有 94.5% 一致，
#   且不一致样例里多数是正则错（解答题里有下划线就被误判成填空题）。
#
# ⚠ 不加题号：交付的是可编辑 Word，老师增删题目后还要重编号，是负担（用户 2026-08-15 定）。
#   没有题号就没有视觉锚点，所以改用题干前的灰色竖条 ▍标出每道题的起点。

# ⚠ 下面是**标准档（每页约 3 题）**的基准值，实际用时要按用户选的档位缩放，
#   见 scaled()。2026-08-19 修回归：改版式时把 BLANK 写死成常量、
#   完全没接 payload['space']，导致前端「小/中/大·每页约N题」三个档位失效，
#   选哪个都出同一个尺寸（用户报「选一页3题，实际只有2题」）。
#
# 每页题数怎么倒推：A4 正文高 24.7cm，一道题占 = 题干(约1.2) + 空白 + 题间距(0.6)
#   4 题/页 → 24.7/4 - 3.3 = 2.9      3 题/页 → 24.7/3 - 3.3 = 4.9
#   2 题/页 → 24.7/2 - 3.3 = 9.0
#
# ⚠ 减的 3.3 = 题干 2.7 + 题间距 0.6，**是实测倒推出来的，不是算出来的**。
#   我先后按 1.2、1.7 估题干高度，两次都让三个档位各少放一题
#   （中档标称 3 题实际 2 题 —— 用户报的就是这个）。真实原因：
#   一行带分数的文字，OMML 会把行高撑到普通行的 2 倍多，这道典型应用题占 2 行。
#   而且整题不拆页，放不下就整体推走、页尾空间全废，误差只会往少了走不会摊平。
#
# ⚠ 这是「约 N 题」不是保证：题干高度本身是变量。题干短的（一行、无公式）会
#   多放一题，长的（三行以上）会少放。校准取的是常见应用题。
BLANK = {
    'choice': 0.9,    # 选择题：只写一个字母
    'fill':   0.0,    # 填空题：空在题干里，不额外留
    'calc':   4.4,    # 纯算式：**两道并排一行**，一页正好 4 行 = 8 道
                      #   A4 正文高 24.7 ÷ 4 = 6.2cm/行；6.2 - 题干 1.2 - 题间距 0.6 = 4.4
    'solve':  4.9,    # 解答/应用题（标准档=每页约3题，实测倒推校准）
    'sub_base': 2.0,  # 多小问：小问**紧挨着排**，末尾统一给一块 = base + per × 问数
    'sub_per':  2.5,  #   （用户定：小问都挨着放，不要每问都空很多行）
    'vform':  5.6,    # 竖式题（全库 657 道）：竖式要竖着写好几行（被除数/商/余数），
                      #   比横式算式高。同样两道并排，但一页只放 3 行。
    'draw':   7.5,    # 作图题**无图**的（如「在数轴上标出…」）：学生要自己画，留大块
    'draw_on': 0.6,   # 作图题**有图**的（454 道里 387 道有图）：画在图上，图后不用再留
}
# 图宽按题型分 —— 作图题的图是**要画上去的**，跟别的题「看一眼」不同，必须大
FIG_CM_BY_TYPE = {'draw': 11.0, 'draw_on': 11.0}
GAP_CM = 0.6          # ⚠ 题间距，恒定。和"书写空白"是两回事 ——
                      #   填空题书写空白为 0 时若不给间距，下一题会紧贴上一题。
FIG_CM = 6.0          # 图统一限宽（不限的话 420px 的图印出来 14.6cm 顶满页宽）
OPT_TABS_CM = (0.0, 4.0, 8.0, 12.0)   # 四个选项均分正文宽
CALC_TAB_CM = 8.0                     # 计算题两道并排时右边那道的起点
GAP_TWIPS = int(GAP_CM * 567)

# 档位 → 相对标准档的倍率。0=不留空（只印题面）
SPACE_SCALE = {0: 0.0, 1: 0.59, 2: 1.0, 3: 1.84}

# 这些题型**不跟档位缩放** —— 它们的空间需求由题型本身决定，不是"想留多少"：
#   选择题写个字母、填空写在题干里、作图题画在图上。
#   跟着放大只会白白占纸，还把每页题数拖下去。
FIXED_TYPES = ('choice', 'fill', 'draw_on')


def sub_lines(stem):
    """多小问题的小问行数 —— 排版和预览**必须用同一个口径**。

    ⚠ 不能用「正则数 （1）（2） 出现几次」代替：题干里「在（2）的条件下」这种
    也会被数进去，而真正决定留多少空白的是**排出来占几行**。
    /api/layout 第一版就是各写各的，结果预览把小问数算少、
    空白比实际导出小一截（7.0 vs 9.5/12.0/14.5）。
    """
    parts = [x.strip() for x in _prep_stem(_strip_lead_no(stem)).split('\n') if x.strip()]
    return max(1, len(parts) - 1)          # 去掉主干那一行


def blank_for(it, space):
    """→ 这道题后面该留多高的书写空白(cm)。排版与预览的唯一出处。"""
    t, _ = classify(it)
    if t == 'sub':
        return (scaled('sub_base', space)
                + scaled('sub_per', space) * sub_lines(it.get('stem', '')))
    return scaled(t, space)


def scaled(t, space):
    """→ 该题型在这个档位下的书写空白(cm)。"""
    base = BLANK[t]
    if t in FIXED_TYPES:
        return base if space else 0.0
    return base * SPACE_SCALE.get(int(space), 1.0)

_OPTS = re.compile(r'\s*([ABCD])\s*[.．、]\s*')
_SUBQ = re.compile(r'[（(]\s*([1-9])\s*[）)]')


def classify(it):
    """→ (主类型, 是否带图)。主类型优先读原书声明的题型字段。"""
    # ⚠ 字段名有三种叫法，全都要认：
    #     qtype               题库 JSON 里的原名
    #     question_type_raw   中考库的原名
    #     type                **前端 doDocxServer 实际发过来的名字**
    #   只认前两个的话，从网页导出时永远读不到题型、全部走兜底正则 ——
    #   竖式题会被判成普通解答题（5.6cm 竖式空间 → 6.5cm 写字空间），作图题同理。
    #   选择题因为兜底正则认得出 ABCD 而侥幸正确，所以这个 bug 不容易被发现。
    raw = (it.get('qtype') or it.get('question_type_raw')
           or it.get('type') or '').strip()
    stem = it.get('stem', '')
    has_fig = bool(it.get('figdata'))
    if '选择' in raw:
        t = 'choice'
    elif '填空' in raw:
        t = 'fill'
    elif raw in ('计算题', '口算', '计算'):
        t = 'calc'
    elif '竖式' in raw:
        t = 'vform'
    elif '作图' in raw:
        # ⚠ 有图/无图要分开：有图的学生画在图上（图放大、不留空白），
        #   无图的（如「在数轴上标出…」）得留地方让他自己画。
        t = 'draw_on' if has_fig else 'draw'
    elif raw:
        t = 'solve'
    else:                                     # 无字段才兜底
        t = ('choice' if re.search(r'A[.．、].*D[.．、]', stem, re.S)
             else 'calc' if _is_short_calc(it) else 'solve')
    # ⚠ 作图/竖式**不能**被多小问规则改写 —— 它们的空间需求由题型决定，
    #   一道有三小问的作图题仍然是画在图上，不是留三块写字的地方。
    if len(_SUBQ.findall(stem)) >= 2 and t not in ('choice', 'fill', 'draw', 'draw_on', 'vform'):
        t = 'sub'
    return t, has_fig


def split_options(stem):
    """题干 → (问句, [四个选项])；不是四选项题返回 (原文, [])。

    ⚠ 选项跟在问句后同一行排不下时，最后一个会被挤到下一行
    （实测「A. 8　B. 10　C. 12」占满一行，「D. 14」孤零零掉到第二行）。
    """
    parts = _OPTS.split(stem)
    if len(parts) != 9 or [parts[i] for i in (1, 3, 5, 7)] != ['A', 'B', 'C', 'D']:
        return stem, []
    opts = [parts[i].strip() for i in (2, 4, 6, 8)]
    if any('\n' in o for o in opts):
        return stem, []
    return parts[0].rstrip(), opts


def _anti_center(stem):
    """⚠ WPS 把"整段只有一个公式"的段落**强制居中**，无视 w:jc=left（Word 不会）。

    _inline_math 已拆过 oMathPara 并写死左对齐，但 WPS 认的是"这段除了公式没别的"
    这个特征。补一个零宽不换行空格：肉眼看不出，段落不再"只含公式"。
    （「计算：」前缀被 _strip_lead_no 删掉后，算式题正好落进这个坑。）
    """
    return '\ufeff' + stem.strip() if re.fullmatch(r'\$[^$]+\$', stem.strip()) else stem


def _blank_p(cm):
    if cm <= 0:
        return None
    return ('```{=openxml}\n<w:p><w:pPr><w:spacing w:before="0" w:after="0"'
            ' w:line="%d" w:lineRule="exact"/></w:pPr></w:p>\n```' % int(cm * 567))


def _fits_half_row(it):
    """题干（剥掉公式壳、按汉字1/其他0.55估宽）塞得进半行吗。"""
    plain = _plain_tex(_strip_lead_no(it.get('stem', '')))
    plain = re.sub(r'\s+', '', plain)
    return sum(1.0 if _CJK.match(c) else 0.55 for c in plain) <= 15


def _pair_calc(items):
    """把**相邻**的纯算式题两两配成一行（用户定：计算题每行两题）。

    ⚠ 用制表位不用表格 —— 2026-07-16 定过「计算题不用表格」，理由是表格难编辑；
      制表位没这问题，交付的仍是普通段落。中间夹了别的题型就断开重配，不打乱卷面顺序。
    """
    out, buf = [], []
    for it in items:
        t, has_fig = classify(it)
        # ⚠ 能不能并排看的是**题干够不够短**，不是题型。
        #   竖式题题型上像计算题，但题干带一长串要求（「列竖式计算,用"四舍五入"法
        #   将得数凑整到十分位。」），两道挤一行会糊成一句话读不出边界（实测）。
        #   半行约 8cm，汉字记 1、其他记 0.55，留出余量取 15。
        if t in ('calc', 'vform') and not has_fig and _fits_half_row(it):
            buf.append(it)
            if len(buf) == 2:
                out.append(('pair', buf)); buf = []
        else:
            if buf:
                out.append(('pair', buf)); buf = []
            out.append(('one', [it]))
    if buf:
        out.append(('pair', buf))
    return out


def _build_md(payload, imgdir):
    showsrc = bool(payload.get('showSrc'))
    space = payload.get('space', 2)
    items = payload.get('items', [])
    ex = max(0, min(int(payload.get('ex', 0) or 0), len(items)))
    _inl = [0]
    _fig = [0]
    _cur_type = ['']          # figs() 要知道当前题型才能决定图宽

    def src_note(it):
        return '  \\[%s·%s\\]' % (it.get('src', ''), _stars(it.get('difficulty', 0))) \
            if showsrc else ''

    def inline_figs(it, stem):
        """把题干里的 `![](INLINEFIG_n)` 占位换成真实临时图片路径。

        中考真题有 8000+ 道题的公式本身就是一张图且长在句子中间；前端把它们换成
        占位符连 base64 一起送来（原文 `asset://…` pandoc 认不了，会**静默丢掉整个公式**，
        题面就变成「求 的值」）。"""
        arr = it.get('inlinefig') or []
        if not arr or 'INLINEFIG_' not in stem:
            return stem

        def sub(m):
            i = int(m.group(1))
            if i >= len(arr):
                return ''
            try:
                raw = base64.b64decode(str(arr[i]).split(',')[-1])
                _inl[0] += 1
                fp = os.path.join(imgdir, 'inl_%d.png' % _inl[0])
                open(fp, 'wb').write(raw)
                return fp.replace('\\', '/')
            except Exception:
                return ''
        out = re.sub(r'INLINEFIG_(\d+)', sub, stem)
        return re.sub(r'!\[[^\]]*\]\(\s*\)', '', out)

    def figs(it):
        outs = []
        for b64 in it.get('figdata') or []:
            try:
                _fig[0] += 1
                fp = os.path.join(imgdir, 'fig_%d.png' % _fig[0])
                open(fp, 'wb').write(base64.b64decode(b64.split(',')[-1]))
                w = FIG_CM_BY_TYPE.get(_cur_type[0], FIG_CM)
                outs.append('![](%s){width=%scm}' % (fp.replace('\\', '/'), w))
            except Exception:
                pass
        return outs

    def emit(seq, is_practice, blocks):
        for kind, grp in _pair_calc(seq):
            if kind == 'pair':
                texts = [inline_figs(x, _prep_stem(_strip_lead_no(x.get('stem', '')))).strip()
                         for x in grp]
                blocks.append('CALCROW' + '⇥'.join(texts))
                if is_practice:
                    # ⚠ 一行里可能一道横式一道竖式，取**高的那个** ——
                    #   按 calc 的 4.4cm 给，竖式就写不下（竖式要竖着写好几行）。
                    b = _blank_p(max(scaled(classify(x)[0], space) for x in grp))
                    if b:
                        blocks.append(b)
                blocks.append(_blank_p(GAP_CM))
                continue

            it = grp[0]
            t, has_fig = classify(it)
            _cur_type[0] = t
            stem = inline_figs(it, _prep_stem(_strip_lead_no(it.get('stem', ''))))

            if t == 'sub':
                head, *rest = [x.strip() for x in stem.split('\n') if x.strip()]
                blocks.append(head + src_note(it))
                blocks.extend(figs(it))
                blocks.extend(rest)          # 小问紧挨着，中间不插空白
                if is_practice:
                    b = _blank_p(blank_for(it, space))     # 与 /api/layout 同一出处
                    if b:
                        blocks.append(b)
            elif t == 'choice':
                q, opts = split_options(stem)
                blocks.append(q + src_note(it))
                blocks.extend(figs(it))
                if opts:
                    blocks.append('OPTROW' + '⇥'.join(
                        '%s. %s' % (L, o) for L, o in zip('ABCD', opts)))
                if is_practice:
                    b = _blank_p(scaled('choice', space))
                    if b:
                        blocks.append(b)
            else:
                blocks.append(_anti_center(stem) + src_note(it))
                blocks.extend(figs(it))
                if is_practice:
                    b = _blank_p(scaled(t, space))
                    if b:
                        blocks.append(b)
            blocks.append(_blank_p(GAP_CM))

    blocks = []
    if ex > 0:
        blocks.append('**一、例题**')
        emit(items[:ex], False, blocks)
        blocks.append('**二、练习**')
        emit(items[ex:], True, blocks)
    else:
        emit(items, True, blocks)
    return '\n\n'.join(x for x in blocks if x)


def _finish_pair_tables(doc):
    """把带 @@P###@@ 标记行的并排布局表还原：删标记行、去边框、固定50/50宽、
    行高 atLeast(下方即竖式空间)+禁跨页断行。题干里真实的表格题没有标记，不受影响。"""
    for tbl in doc.tables:
        try:
            mark = tbl.rows[0].cells[0].text.strip()
        except Exception:
            continue
        m = re.match(r'^@@P(\d+)@@$', mark)
        if not m:
            continue
        h100 = int(m.group(1))
        tblel = tbl._tbl
        tblel.remove(tbl.rows[0]._tr)                       # 删标记行
        tblPr = tblel.tblPr
        for tag in ('w:tblBorders', 'w:tblW', 'w:tblLayout'):
            for old in tblPr.findall(qn(tag)):
                tblPr.remove(old)
        borders = OxmlElement('w:tblBorders')               # 全部无边框
        for side in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):
            el = OxmlElement('w:' + side); el.set(qn('w:val'), 'none'); borders.append(el)
        tblPr.append(borders)
        lay = OxmlElement('w:tblLayout'); lay.set(qn('w:type'), 'fixed'); tblPr.append(lay)
        w = OxmlElement('w:tblW'); w.set(qn('w:w'), '5000'); w.set(qn('w:type'), 'pct'); tblPr.append(w)
        mar = OxmlElement('w:tblCellMar')                   # 格内边距:顶上留点气,别顶死
        for side, val in (('top', '85'), ('left', '113'), ('right', '113')):
            el = OxmlElement('w:' + side); el.set(qn('w:w'), val); el.set(qn('w:type'), 'dxa'); mar.append(el)
        tblPr.append(mar)
        grid = tblel.find(qn('w:tblGrid'))
        if grid is not None:
            for gc in grid.findall(qn('w:gridCol')):
                gc.set(qn('w:w'), '4157')                   # 可用宽14.66cm 的一半
        for row in tbl.rows:
            trPr = row._tr.get_or_add_trPr()
            trPr.append(OxmlElement('w:cantSplit'))
            if h100 > 0:
                trh = OxmlElement('w:trHeight')
                trh.set(qn('w:val'), str(int(h100 * 567 / 100)))
                trh.set(qn('w:hRule'), 'atLeast')
                trPr.append(trh)
            for c in row.cells:
                tcPr = c._tc.get_or_add_tcPr()
                for old in tcPr.findall(qn('w:tcW')):
                    tcPr.remove(old)
                tcw = OxmlElement('w:tcW'); tcw.set(qn('w:w'), '2500'); tcw.set(qn('w:type'), 'pct')
                tcPr.append(tcw)


def _make_reference(title, sub, tmpdir):
    """复制用户母版 -> 页眉标题换成《title》专题练习、副标题写进页眉空行、清空正文 -> 返回参考底路径。
    母版缺失时退回旧 TEMPLATE(仍带 __PAPER_TITLE__ 占位，交给 _set_header_placeholders)。"""
    if not os.path.exists(MASTER):
        return (TEMPLATE if os.path.exists(TEMPLATE) else None), True   # (路径, 是否占位符模式)
    ref = os.path.join(tmpdir, 'reference.docx')
    d = docx.Document(MASTER)
    # 页眉：把标题行 《…》专题练习 里的专题名替换成本卷 title（保留首个 run 的加粗/字号，清空其余同段 run）
    for p in d.sections[0].header.paragraphs:
        full = ''.join(r.text for r in p.runs)
        if '《' in full and '》' in full and '专题练习' in full:
            new = re.sub(r'《[^》]*》', '《' + title + '》', full, count=1)
            if p.runs:
                p.runs[0].text = new
                for r in p.runs[1:]:
                    r.text = ''
            break   # 只动标题行，别碰"日期/姓名"行
    # 副标题：写进页眉末尾的空段(母版自带的居中空行)，与预览一致；不填就不占地方。
    # 若副标题本身是"日期/姓名"行(老默认值)则跳过——母版页眉已自带，写进去会重复。
    if (sub or '').strip() and not ('日期' in sub and '姓名' in sub):
        try:
            from docx.enum.text import WD_ALIGN_PARAGRAPH
            hps = d.sections[0].header.paragraphs
            tgt = hps[-1] if (hps and not hps[-1].text.strip()) else d.sections[0].header.add_paragraph()
            tgt.add_run(str(sub).strip())
            tgt.alignment = WD_ALIGN_PARAGRAPH.CENTER
        except Exception:
            pass
    # 清空正文(保留 sectPr) —— 正文交给 pandoc 生成
    body = d.element.body
    for el in list(body):
        if el.tag == qn('w:p'):
            body.remove(el)
    d.save(ref)
    return ref, False


def _set_header_placeholders(doc, title, sub):
    """兜底模式用：替换旧模板里的 __PAPER_TITLE__ / __PAPER_SUB__ 占位符。"""
    for p in doc.sections[0].header.paragraphs:
        for r in p.runs:
            if '__PAPER_TITLE__' in r.text:
                r.text = r.text.replace('__PAPER_TITLE__', title)
            if '__PAPER_SUB__' in r.text:
                r.text = r.text.replace('__PAPER_SUB__', sub or '')


def _set_two_columns(doc):
    sectPr = doc.sections[0]._sectPr
    cols = sectPr.find(qn('w:cols'))
    if cols is None:
        cols = OxmlElement('w:cols'); sectPr.append(cols)
    cols.set(qn('w:num'), '2'); cols.set(qn('w:space'), '425')


_MNS = '{http://schemas.openxmlformats.org/officeDocument/2006/math}'


def _inline_math(doc):
    """pandoc 会把"整段只有一个公式"升级成展示公式(oMathPara=居中)。练习卷算式应靠左，
    全部拆回行内公式(oMath)。竖式/阵列拆完仍是同一个框，只是左对齐。
    另:WPS 对"整段只有公式"的段落会自作主张居中渲染(Word不会)——所有含公式段落显式写死左对齐。"""
    body = doc.element.body
    for omp in list(body.iter(_MNS + 'oMathPara')):
        parent = omp.getparent()
        idx = parent.index(omp)
        for child in reversed(omp.findall(_MNS + 'oMath')):
            parent.insert(idx, child)
        parent.remove(omp)
    for p in body.iter(qn('w:p')):
        if p.find(_MNS + 'oMath') is None:
            continue
        pPr = p.find(qn('w:pPr'))
        if pPr is None:
            pPr = OxmlElement('w:pPr'); p.insert(0, pPr)
        for old in pPr.findall(qn('w:jc')):
            pPr.remove(old)
        jc = OxmlElement('w:jc'); jc.set(qn('w:val'), 'left')
        rPr = pPr.find(qn('w:rPr'))
        pPr.insert(list(pPr).index(rPr), jc) if rPr is not None else pPr.append(jc)


def _keep_blank_with_question(doc):
    """书写空白段(exact行高空段)跨页时会和题目失散——页顶一块死白、题留上页。
    给空白段前面那个题目段落加 keepNext，让 Word 把"题+空白"当整体挪页。"""
    body = doc.element.body
    prev = None
    for el in body.iterchildren():
        if el.tag == qn('w:p'):
            pPr = el.find(qn('w:pPr'))
            sp = pPr.find(qn('w:spacing')) if pPr is not None else None
            is_blank = (sp is not None and sp.get(qn('w:lineRule')) == 'exact'
                        and not el.findall(qn('w:r')))
            if is_blank and prev is not None and prev.tag == qn('w:p'):
                ppr = prev.find(qn('w:pPr'))
                if ppr is None:
                    ppr = OxmlElement('w:pPr'); prev.insert(0, ppr)
                if ppr.find(qn('w:keepNext')) is None:
                    ppr.insert(0, OxmlElement('w:keepNext'))
        prev = el


def _has_content(p):
    """段落有没有内容 —— **不能只看 p.text**。

    ⚠ 纯公式段落（整行就是两道算式）的 p.text 是**空字符串**：公式在 OMML 元素里，
    不是 w:t 文本。只看 p.text 会把整行计算题当空行，竖条全漏（实测 8 道只加上 1 个）。
    """
    return bool((p.text or '').strip()) or p._p.find('.//' + _MNS + 'oMath') is not None


def _is_gap(p):
    if _has_content(p):
        return False
    pPr = p._p.find(qn('w:pPr'))
    if pPr is None:
        return False
    sp = pPr.find(qn('w:spacing'))
    return sp is not None and sp.get(qn('w:line')) == str(GAP_TWIPS)


def _set_row_tabs(doc):
    """给选项行/计算题并排行装制表位，并抹掉标记。

    ⚠ 两件事 pandoc 都做不了：制表位（w:tabs）它不认；markdown 里写的 \t
    **会被它转成普通空格**（实测 w:tab/ 个数为 0）。所以分隔符用 ⇥，在这里
    换成真正的 <w:tab/>。不能整段重建 —— 选项里可能含 OMML 公式，重建会丢公式。
    """
    for p in doc.paragraphs:
        txt = p.text or ''
        is_calc = txt.startswith('CALCROW')
        if not (is_calc or txt.startswith('OPTROW')):
            continue
        mark = 'CALCROW' if is_calc else 'OPTROW'
        for r in p.runs:
            if mark in r.text:
                r.text = r.text.replace(mark, '', 1)
                break
        for r in list(p.runs):
            if '⇥' not in r.text:
                continue
            head, *tail = r.text.split('⇥')
            r.text = head
            anchor = r._r
            for seg in tail:
                tr = OxmlElement('w:r'); tr.append(OxmlElement('w:tab'))
                anchor.addnext(tr); anchor = tr
                sr = OxmlElement('w:r'); st = OxmlElement('w:t')
                st.set(qn('xml:space'), 'preserve'); st.text = seg
                sr.append(st); anchor.addnext(sr); anchor = sr
        pPr = p._p.find(qn('w:pPr'))
        if pPr is None:
            pPr = OxmlElement('w:pPr'); p._p.insert(0, pPr)
        for old in pPr.findall(qn('w:tabs')):
            pPr.remove(old)
        tabs = OxmlElement('w:tabs')
        for cm in ((CALC_TAB_CM,) if is_calc else OPT_TABS_CM[1:]):
            t = OxmlElement('w:tab')
            t.set(qn('w:val'), 'left'); t.set(qn('w:pos'), str(int(cm * 567)))
            tabs.append(t)
        pPr.insert(0, tabs)


def _add_bars(doc):
    """每道题的首段前加一个灰色竖条 ▍。

    不加题号是用户定的（交付可编辑 Word，老师增删题后重编号是负担），
    但没题号就没有视觉锚点，满页文字块+空白块交替看不出边界。竖条是**字符**不是
    段落属性，老师不想要直接删得掉。
    """
    fresh = True
    for p in doc.paragraphs:
        if _is_gap(p):
            fresh = True
            continue
        if not _has_content(p):
            continue
        if fresh:
            r = OxmlElement('w:r')
            rPr = OxmlElement('w:rPr')
            c = OxmlElement('w:color'); c.set(qn('w:val'), '888888'); rPr.append(c)
            r.append(rPr)
            t = OxmlElement('w:t'); t.set(qn('xml:space'), 'preserve'); t.text = '▍'
            r.append(t)
            p._p.insert(1 if p._p.find(qn('w:pPr')) is not None else 0, r)
        fresh = False


def _keep_question_together(doc):
    """一道题的所有段落绑成整体，不许拆到两页。

    ⚠ 保护粒度和「小问怎么排」是配套的，改一个必须改另一个（我分两轮各踩一次）：
      · 小问各留空白时用整题绑定 → 一道题高近一页，放不下就整体推走，上页留大半白
      · 小问紧挨排却用小问级绑定 → 题面被劈两页，(3) 独自跑到下一页
    现在小问紧挨着排、题面很紧凑，所以用整题绑定，挪页代价小；
    而「题面被拆开」是学生看不全题的硬伤，不能接受。
    """
    group = []
    for p in list(doc.paragraphs):
        if _is_gap(p):
            for q in group[:-1]:            # 末段不加，否则会把下一道题也粘上来
                pPr = q._p.find(qn('w:pPr'))
                if pPr is None:
                    pPr = OxmlElement('w:pPr'); q._p.insert(0, pPr)
                if pPr.find(qn('w:keepNext')) is None:
                    pPr.insert(0, OxmlElement('w:keepNext'))
            group = []
        else:
            group.append(p)


def build_paper_docx(payload):
    """payload -> docx bytes。失败抛异常(调用方兜底回退纯文本)。"""
    title = (payload.get('title') or '专题').strip()
    sub = payload.get('sub') or ''
    tmp = tempfile.mkdtemp(prefix='zjt_export_')
    try:
        ref, placeholder_mode = _make_reference(title, sub, tmp)
        if not ref:
            raise RuntimeError('找不到导出母版/模板：' + MASTER)
        md = _build_md(payload, tmp)
        mdfile = os.path.join(tmp, 'paper.md')
        open(mdfile, 'w', encoding='utf-8').write(md)
        outdocx = os.path.join(tmp, 'out.docx')
        # ⚠ -f 必须显式给：pandoc 的 markdown 默认只认 $…$，**不认 \(…\)**。
        # 上海中考真题的公式全是 \(…\) 写法，不开 tex_math_single_backslash 的话
        # 一整批公式会被当普通文字导出（反斜杠还会被吃掉，只剩一对光秃秃的括号）。
        r = subprocess.run([PANDOC, '-f', 'markdown+tex_math_single_backslash',
                            mdfile, '--reference-doc=' + ref, '-o', outdocx],
                           capture_output=True, timeout=60)
        if r.returncode != 0 or not os.path.exists(outdocx):
            raise RuntimeError('pandoc 失败: ' + r.stderr.decode('utf-8', 'ignore')[:300])
        doc = docx.Document(outdocx)
        _finish_pair_tables(doc)        # 并排算式布局表:删标记/去边框/50-50/精确行高
        _inline_math(doc)               # 拆掉pandoc的展示公式升级(居中→靠左)
        _set_row_tabs(doc)              # 选项行/计算题并排行的制表位（必须在加竖条之前）
        _keep_blank_with_question(doc)  # 题目和它的书写空白绑定跨页
        _keep_question_together(doc)    # 整题不许拆到两页
        _add_bars(doc)                  # 每题首段加灰色竖条（代替题号做视觉锚点）
        if placeholder_mode:            # 母版模式标题已在参考底里改好；只有兜底模板才需替换占位符
            _set_header_placeholders(doc, title, sub)
        if payload.get('cols'):
            _set_two_columns(doc)
        finalp = os.path.join(tmp, 'final.docx')
        doc.save(finalp)
        return open(finalp, 'rb').read()
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
