Prechádzať zdrojové kódy

Add 姓名牌生成: batch name-plate Word generator with reusable template

Co-Authored-By: Claude <noreply@anthropic.com>
nonvioce 2 mesiacov pred
rodič
commit
9ecab3a53a

+ 19 - 0
README.md

@@ -88,6 +88,25 @@
 | `python版/main.py` | PyQt5 GUI 版小分表工具,支持 Excel 导入、分组汇总、图表展示 |
 | `小分表.html` | 网页版小分表可视化展示 |
 
+### 姓名牌生成 (`姓名牌生成/`)
+
+| 文件 | 说明 |
+|------|------|
+| `生成姓名牌.py` | 读取 Excel 名单(自动识别"班级""姓名"列),按班级生成折叠三角姓名牌 Word 文档:每人一页、正反两面(浮动文本框),姓名 100pt 黑体,含班级标语行与座右铭。支持多班级分组、自定义座右铭、复用自定义模板 |
+| `姓名牌_模板.docx` | 姓名牌模板(一页、姓名占位"张三")。可打开修改字体/颜色/文本框位置后,用 `--模板` 参数复用 |
+
+用法:
+
+```bash
+cd 姓名牌生成
+python 生成姓名牌.py 名单.xlsx                # 自动识别班级/姓名列,按班分组输出
+python 生成姓名牌.py 名单.xlsx --班级 31       # 单列名单(无班级列)时指定班级
+python 生成姓名牌.py 名单.xlsx --座右铭 "天道酬勤 厚积薄发"
+python 生成姓名牌.py 名单.xlsx --模板 我的模板.docx
+```
+
+依赖:`openpyxl`、`python-docx`、`lxml`。脚本自包含,可拷到任意目录运行;模板查找顺序:`--模板` 指定 → 脚本/当前目录的 `姓名牌_模板.docx` → 内置版式构建。
+
 ### 其他
 
 | 文件 | 说明 |

BIN
姓名牌生成/姓名牌_模板.docx


+ 575 - 0
姓名牌生成/生成姓名牌.py

@@ -0,0 +1,575 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""======================================================================
+姓名牌批量生成工具(折叠三角姓名牌 · 模板复制方案)
+======================================================================
+
+一、用途
+----------------------------------------------------------------------
+读取 Excel 名单,按班级分组,每个班生成一个 Word 文档:
+每人一页,每页一张折叠三角姓名牌,含正反两面(两个浮动文本框)。
+版式复刻自学校原有的姓名牌 Word 文件(黑体、白底黑框、姓名 100pt)。
+
+二、用法
+----------------------------------------------------------------------
+    venv/bin/python 生成姓名牌.py 示例输入.xlsx                 # 自动识别"班级""姓名"列
+    venv/bin/python 生成姓名牌.py 名单.xlsx --班级 31            # 单列名单(无班级列)时指定班级
+    venv/bin/python 生成姓名牌.py 名单.xlsx --输出目录 ./输出    # 指定输出目录(默认与输入同目录)
+    venv/bin/python 生成姓名牌.py 名单.xlsx --座右铭 "天道酬勤 厚积薄发"
+    venv/bin/python 生成姓名牌.py 名单.xlsx --模板 我的模板.docx  # 使用自定义模板
+    venv/bin/python 生成姓名牌.py 名单.xlsx --sheet 表2          # 指定工作表(默认第一个)
+    venv/bin/python 生成姓名牌.py 名单.xlsx --保留模板           # 用 --模板 时也把模板复制到输出目录
+
+    Excel 列识别规则:表头含"姓名/名字/name/学生"→姓名列;
+    含"班级/班别/班号/class/班"→班级列;其他列(如成绩)忽略。
+    班级值 31 / "31" / "31班" / "高三31班" 均显示为"高三31班";无数字(如"实验班")则原样。
+    输出文件:姓名牌_{班级}_输出.docx,如 姓名牌_高三31班_输出.docx。
+
+三、模板机制(重要)
+----------------------------------------------------------------------
+本脚本不依赖任何外部文件,可拷到任意目录单独运行。模板来源按优先级:
+    1. --模板 指定的文件
+    2. 脚本所在目录 或 当前目录 下的"姓名牌_模板.docx"
+    3. 都没有 → 按内置版式常量构建一个模板(输出到输出目录)
+
+模板 = 一份"一页姓名牌"的 docx(正文只有两段:空段 + 姓名牌段)。
+生成每个班时:复制模板文件 → 把这两段复制 N 份(N=人数)→
+逐份替换姓名、班级数字、座右铭 → 重新打包。
+
+推荐做法:用学校原有输出文件派生模板(保证与老文件 100% 一致):
+复制原文件 → 删除后面所有页面只留第一页 → 把姓名改成"张三" →
+另存为 姓名牌_模板.docx。之后 --模板 指定它即可(或用上面第 2 条自动找到)。
+在 WPS/Word 里直接修改模板的字体、颜色、文本框位置,新样式即应用到所有输出。
+
+四、自定义模板的规则(--模板 时)
+----------------------------------------------------------------------
+程序取第一个含浮动文本框的段落为"姓名牌段"(连同前面相邻的空段),
+复制 N 份后做如下替换(都在段落内部定位,不依赖文本内容):
+    - 姓名:100pt 大字(w:sz=200)的文本节点
+    - 班级:班级行中独立的纯数字文本节点(模板默认"31")
+    - 座右铭:含"脚踏实地"的居中段(整段重建 runs)
+模板里的其他内容(装饰、页眉页脚、其他文本框)会随每页一起复制。
+
+五、版式参数(想改样式时改这里)
+----------------------------------------------------------------------
+    FACE            : 正/背面文本框位置与尺寸(EMU 单位,1cm≈360000)
+                       pos_v 是距锚点段落的垂直偏移,决定卡片在页面上下的位置
+    SECTPR_BODY     : 页面设置(A4 11906×16838 twips,边距上下2cm 左右1cm)
+    BODYPR          : 文本框属性。vertOverflow="overflow" 保证内容超出框高
+                       时溢出显示——删掉会导致底部文字(座右铭)被裁剪不见
+    p_class_line    : 班级标语行 26pt,"高三31班  坚持:____"(尾随粗下划线)
+    p_spacer        : 空行(20pt 固定行高,起占位作用)
+    p_name          : 姓名 100pt 黑体居中
+    p_motto         : 座右铭 26pt 居中(--座右铭 可换内容)
+    p_tail          : 背面尾部空段 28pt
+    DEFAULT_CLASS/STUDENT/MOTTO : 内置模板的占位值
+    字号换算:w:sz 值为字号×2(52=26pt,200=100pt)
+
+六、常见问题
+----------------------------------------------------------------------
+    - 生成的文档打开后底部文字(座右铭)消失:模板的文本框缺少
+      wps:bodyPr vertOverflow="overflow"(见上文),用新模板即可
+    - 姓名是数字或含特殊字符:脚本已做 XML 转义,正常处理
+    - 一个 Excel 里有多个班:自动按班级分组,每班输出一个文件
+
+依赖:openpyxl、python-docx、lxml(本目录 venv 已安装)
+======================================================================"""
+import argparse
+import os
+import re
+import sys
+import zipfile
+from collections import OrderedDict
+from copy import deepcopy
+from xml.sax.saxutils import escape
+
+import openpyxl
+from docx import Document
+from docx.oxml import parse_xml
+from lxml import etree
+
+# ---------------------------------------------------------------------------
+# 命名空间
+# ---------------------------------------------------------------------------
+W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
+MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
+A = 'http://schemas.openxmlformats.org/drawingml/2006/main'
+WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
+WPS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape'
+R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
+NS = {'w': W, 'mc': MC, 'a': A, 'wp': WP, 'wps': WPS, 'r': R}
+
+NSDECL = ('<w:document %s>'
+          % ' '.join('xmlns:%s="%s"' % (p, u) for p, u in NS.items()))
+
+
+def _xml_elems(x):
+    """解析带命名空间声明的片段,返回子元素列表"""
+    root = parse_xml(NSDECL + x + '</w:document>')
+    return list(root)
+
+# ---------------------------------------------------------------------------
+# 版式常量(复刻自原始姓名牌文件)
+# ---------------------------------------------------------------------------
+# A4 页面 + 边距(twips: 上下 2cm / 左右 1cm)
+SECTPR_BODY = (
+    '<w:pgSz w:w="11906" w:h="16838"/>'
+    '<w:pgMar w:top="1134" w:right="567" w:bottom="1134" w:left="567" '
+    'w:header="851" w:footer="851" w:gutter="0"/>'
+    '<w:pgNumType w:start="1"/>'
+    '<w:cols w:space="425" w:num="1"/>'
+    '<w:docGrid w:linePitch="435" w:charSpace="0"/>'
+)
+
+# 文本框尺寸/位置(EMU): 宽 7154545 ≈ 19.87cm
+FACE = {
+    'back':  dict(pos_h=-170815, pos_v=4434840, cy=2590800, relh=251660288, eff_b=19050, trail=23),
+    'front': dict(pos_h=-172720, pos_v=1096010, cy=2642870, relh=251659264, eff_b=24130, trail=22),
+}
+
+FONT_HEI = '<w:rFonts w:ascii="黑体" w:hAnsi="黑体" w:eastAsia="黑体" w:cs="黑体"/>'
+FONT_HEI_HINT = '<w:rFonts w:hint="eastAsia" w:ascii="黑体" w:hAnsi="黑体" w:eastAsia="黑体" w:cs="黑体"/>'
+
+# 文本框属性: vertOverflow=overflow 使内容超出框高时溢出显示(不裁剪),
+# 缺失会导致底部内容(如座右铭行)被裁剪不见
+BODYPR = ('<wps:bodyPr rot="0" spcFirstLastPara="0" vertOverflow="overflow" '
+          'horzOverflow="overflow" vert="horz" wrap="square" lIns="0" tIns="0" '
+          'rIns="0" bIns="0" numCol="1" spcCol="0" rtlCol="0" fromWordArt="0" '
+          'anchor="t" anchorCtr="0" forceAA="0" compatLnSpc="1">'
+          '<a:noAutofit/>'
+          '<a:scene3d><a:camera prst="orthographicFront">'
+          '<a:rot lat="0" lon="0" rev="0"/></a:camera>'
+          '<a:lightRig rig="threePt" dir="t"/></a:scene3d>'
+          '</wps:bodyPr>')
+
+DEFAULT_CLASS = '31'        # 模板中的班级数字占位
+DEFAULT_STUDENT = '张三'    # 模板中的姓名占位
+DEFAULT_MOTTO = '脚踏实地  知行合一  行稳致远'
+
+
+# ---------------------------------------------------------------------------
+# 小工具
+# ---------------------------------------------------------------------------
+def rpr(fonts=FONT_HEI, sz=None, sz_cs=None, u=None, lang=None, lang_ea=None):
+    """构造 w:rPr 字符串"""
+    parts = [fonts]
+    if sz is not None:
+        parts.append('<w:sz w:val="%d"/>' % sz)
+    if sz_cs is not None:
+        parts.append('<w:szCs w:val="%d"/>' % sz_cs)
+    if u is not None:
+        parts.append('<w:u w:val="%s"/>' % u)
+    if lang is not None:
+        parts.append('<w:lang w:val="%s"/>' % lang)
+    if lang_ea is not None:
+        parts.append('<w:lang w:eastAsia="%s"/>' % lang_ea)
+    return '<w:rPr>%s</w:rPr>' % ''.join(parts)
+
+
+def run(text, rpr_xml, space_preserve=False):
+    sp = ' xml:space="preserve"' if (space_preserve or (text != text.strip())) else ''
+    return '<w:r>%s<w:t%s>%s</w:t></w:r>' % (rpr_xml, sp, escape(text))
+
+
+def sectpr(continuous=False):
+    t = '<w:type w:val="continuous"/>' if continuous else ''
+    return '<w:sectPr>%s%s</w:sectPr>' % (t, SECTPR_BODY)
+
+
+# ---------------------------------------------------------------------------
+# 姓名牌 XML 构建
+# ---------------------------------------------------------------------------
+def p_class_line(cls_display, trail, u5=False):
+    """班级标语行: '高三31班     坚持:____'  26pt 左对齐,尾随空格带粗下划线
+    u5=True(正面)时"坚持:"后的空格 run 也带粗下划线,与原始正面一致"""
+    m = re.search(r'(\d+)', cls_display)
+    if m:
+        prefix, num, suffix = cls_display[:m.start()], m.group(1), cls_display[m.end():]
+    else:
+        prefix, num, suffix = cls_display, '', ''
+    r5 = rpr(FONT_HEI_HINT, 52, 96, u='thick') if u5 else rpr(FONT_HEI, 52, 96)
+    runs = (
+        run(prefix, rpr(FONT_HEI_HINT, 52, 96)) +
+        run(num, rpr(FONT_HEI, 52, 96, lang='en-US')) +
+        run(suffix + ' ', rpr(FONT_HEI_HINT, 52, 96), True) +
+        run('    ', rpr(FONT_HEI, 52, 96), True) +
+        run('坚持:', rpr(FONT_HEI_HINT, 52, 96)) +
+        run(' ', r5, True) +
+        run(' ' * trail, rpr(FONT_HEI, 52, 96, u='thick'), True)
+    )
+    ppr = ('<w:pPr><w:spacing w:line="240" w:lineRule="auto"/>'
+           '<w:ind w:firstLine="0" w:firstLineChars="0"/>'
+           '%s</w:pPr>' % rpr(FONT_HEI, 52, 96, u='thick'))
+    return '<w:p>%s%s</w:p>' % (ppr, runs)
+
+
+def p_spacer():
+    """空行(占位): 20pt 固定行高 居中"""
+    ppr = ('<w:pPr><w:spacing w:line="20" w:lineRule="exact"/>'
+           '<w:ind w:firstLine="2240"/><w:jc w:val="center"/>'
+           '%s</w:pPr>' % rpr(FONT_HEI, 112, 192))
+    return '<w:p>%s</w:p>' % ppr
+
+
+def p_name(student, front=False):
+    """姓名: 100pt 黑体 居中(front=True 时段落标记 rPr 无 hint/lang,与原始正面一致)"""
+    ppr_rpr = rpr(FONT_HEI, 200, 200) if front else rpr(FONT_HEI_HINT, 200, 200, lang_ea='zh-CN')
+    ppr = ('<w:pPr><w:spacing w:line="240" w:lineRule="auto"/>'
+           '<w:ind w:firstLine="0" w:firstLineChars="0"/><w:jc w:val="center"/>'
+           '%s</w:pPr>' % ppr_rpr)
+    r = run(student, rpr(FONT_HEI_HINT, 200, 200, lang_ea='zh-CN'))
+    return '<w:p>%s%s</w:p>' % (ppr, r)
+
+
+def motto_runs(motto):
+    """座右铭 run 拆分(复刻原始: 词 run 带尾部 1 个空格,多余空格独立 run)"""
+    out = []
+    for tk in re.split(r'( +)', motto):
+        if not tk:
+            continue
+        if tk[0] != ' ':
+            out.append((tk, True))
+        else:
+            n = len(tk)
+            if out and out[-1][1] and not out[-1][0].endswith(' '):
+                out[-1] = (out[-1][0] + ' ', True)
+                n -= 1
+            for _ in range(n):
+                out.append((' ', False))
+    return out
+
+
+def p_motto(motto):
+    """座右铭: 26pt 黑体 居中,词间空格交替 run"""
+    runs = []
+    for text, is_word in motto_runs(motto):
+        if is_word:
+            runs.append(run(text, rpr(FONT_HEI_HINT, 52, 96)))
+        else:
+            runs.append(run(text, rpr(FONT_HEI, 52, 96), True))
+    ppr = ('<w:pPr><w:spacing w:line="240" w:lineRule="auto"/>'
+           '<w:ind w:firstLine="0" w:firstLineChars="0"/><w:jc w:val="center"/>'
+           '%s</w:pPr>' % rpr(FONT_HEI, 52, 96))
+    return '<w:p>%s%s</w:p>' % (ppr, ''.join(runs))
+
+
+def p_tail():
+    """背面尾部空段: 28pt 居中"""
+    ppr = ('<w:pPr><w:spacing w:line="240" w:lineRule="auto"/>'
+           '<w:ind w:firstLine="0" w:firstLineChars="0"/><w:jc w:val="center"/>'
+           '%s</w:pPr>' % rpr(FONT_HEI, 56, 56))
+    return '<w:p>%s</w:p>' % ppr
+
+
+def textbox_anchor(docpr_id, name, face, content_xml):
+    """浮动文本框(wps txbx),内容为 Word/WPS 均识别的 wps 绘图"""
+    f = FACE[face]
+    return (
+        '<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing>'
+        '<wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" '
+        'relativeHeight="%d" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">'
+        '<wp:simplePos x="0" y="0"/>'
+        '<wp:positionH relativeFrom="margin"><wp:posOffset>%d</wp:posOffset></wp:positionH>'
+        '<wp:positionV relativeFrom="paragraph"><wp:posOffset>%d</wp:posOffset></wp:positionV>'
+        '<wp:extent cx="7154545" cy="%d"/>'
+        '<wp:effectExtent l="0" t="0" r="27305" b="%d"/>'
+        '<wp:wrapNone/>'
+        '<wp:docPr id="%d" name="%s"/><wp:cNvGraphicFramePr/>'
+        '<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">'
+        '<wps:wsp><wps:cNvSpPr txBox="1"/>'
+        '<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="7154545" cy="%d"/></a:xfrm>'
+        '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
+        '<a:solidFill><a:schemeClr val="lt1"/></a:solidFill>'
+        '<a:ln w="6350"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:ln></wps:spPr>'
+        '<wps:style><a:lnRef idx="0"><a:schemeClr val="accent1"/></a:lnRef>'
+        '<a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef>'
+        '<a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef>'
+        '<a:fontRef idx="minor"><a:schemeClr val="dk1"/></a:fontRef></wps:style>'
+        '<wps:txbx><w:txbxContent>%s</w:txbxContent></wps:txbx>'
+        '%s'
+        '</wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice>'
+        '</mc:AlternateContent></w:r>'
+        % (f['relh'], f['pos_h'], f['pos_v'], f['cy'], f['eff_b'],
+           docpr_id, escape(name), f['cy'], content_xml, BODYPR)
+    )
+
+
+def nameplate_content(cls_display, student, motto):
+    """一页姓名牌的两面文本框内容(背面5段 / 正面4段)"""
+    back = textbox_anchor(2, '文本框 2', 'back',
+                          p_class_line(cls_display, FACE['back']['trail'])
+                          + p_spacer() + p_name(student) + p_motto(motto) + p_tail())
+    front = textbox_anchor(1, '文本框 1', 'front',
+                           p_class_line(cls_display, FACE['front']['trail'], u5=True)
+                           + p_spacer() + p_name(student, front=True) + p_motto(motto))
+    return back + front
+
+
+def page_unit_xml(cls_display, student, motto):
+    """一个姓名牌页面单元 = 空段(nextPage) + 姓名牌段(continuous)"""
+    empty = ('<w:p><w:pPr><w:ind w:firstLine="0" w:firstLineChars="0"/>%s</w:pPr></w:p>'
+             % sectpr(False))
+    person = ('<w:p><w:pPr><w:ind w:firstLine="0" w:firstLineChars="0"/>%s</w:pPr>%s</w:p>'
+              % (sectpr(True), nameplate_content(cls_display, student, motto)))
+    return empty + person
+
+
+# ---------------------------------------------------------------------------
+# 模板构建
+# ---------------------------------------------------------------------------
+def build_template(out_path, cls_display, student, motto):
+    """用 python-docx 生成一页姓名牌模板 docx"""
+    doc = Document()
+    body = doc.element.body
+    for child in list(body):
+        body.remove(child)
+    body.extend(_xml_elems(page_unit_xml(cls_display, student, motto)))
+    body.append(_xml_elems('<w:sectPr>%s</w:sectPr>' % SECTPR_BODY)[0])
+    doc.save(out_path)
+
+
+# ---------------------------------------------------------------------------
+# 复制模板生成输出
+# ---------------------------------------------------------------------------
+def replace_student(p, student):
+    """把姓名(100pt 大字 run)替换为新学生名(正背面各一处)"""
+    for t in p.iter('{%s}t' % W):
+        r = t.getparent()
+        rPr = r.find('{%s}rPr' % W)
+        if rPr is not None:
+            sz = rPr.find('{%s}sz' % W)
+            if sz is not None and sz.get('{%s}val' % W) == '200':
+                t.text = student
+
+
+def replace_class_line(p, cls_display):
+    """把班级行里的班级数字替换为新班级('高三31班' -> '高三36班' 等)"""
+    m = re.search(r'(\d+)', cls_display)
+    if m:
+        prefix, num = cls_display[:m.start()], m.group(1)
+    else:
+        prefix, num = cls_display, ''
+    for t in p.iter('{%s}t' % W):
+        if t.text == DEFAULT_CLASS:
+            t.text = num
+
+
+def replace_motto(p, motto):
+    """重建座右铭段的 runs(按 词+空格 交替拆分),正背面各一处"""
+    for tp in p.iter('{%s}p' % W):
+        texts = [t.text or '' for t in tp.iter('{%s}t' % W)]
+        joined = ''.join(texts)
+        if '脚踏实地' in joined and '坚持' not in joined:
+            # 删除段内所有 run
+            for r in list(tp.findall('{%s}r' % W)):
+                tp.remove(r)
+            for text, is_word in motto_runs(motto):
+                if is_word:
+                    xml = run(text, rpr(FONT_HEI_HINT, 52, 96))
+                else:
+                    xml = run(text, rpr(FONT_HEI, 52, 96), True)
+                tp.append(_xml_elems(xml)[0])
+
+
+def build_document_xml(template_xml, pages):
+    """根据模板 document.xml 生成含 N 页姓名牌的 document.xml"""
+    root = etree.fromstring(template_xml)
+    body = root.find('{%s}body' % W)
+    paras = body.findall('{%s}p' % W)
+
+    # 页面单元 = 第一个含浮动文本框的段 + 它前面相邻的段
+    unit = None
+    for i, p in enumerate(paras):
+        if p.find('.//{%s}anchor' % WP) is not None:
+            start = i - 1 if i > 0 else 0
+            unit = [deepcopy(paras[j]) for j in range(start, i + 1)]
+            break
+    if unit is None:
+        raise ValueError('模板中找不到姓名牌文本框(wp:anchor),请检查模板文件')
+
+    body_sectpr = body.find('{%s}sectPr' % W)
+    for ch in list(body):
+        body.remove(ch)
+
+    docpr_id = 0
+    for cls_display, student, motto in pages:
+        for para in unit:
+            p = deepcopy(para)
+            replace_student(p, student)
+            replace_class_line(p, cls_display)
+            replace_motto(p, motto)
+            # docPr id 重新编号(保证唯一)
+            for docpr in p.iter('{%s}docPr' % WP):
+                docpr_id += 1
+                docpr.set('id', str(docpr_id))
+                docpr.set('name', '文本框 %d' % docpr_id)
+            body.append(p)
+
+    if body_sectpr is not None:
+        body.append(body_sectpr)
+    return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
+
+
+def copy_and_generate(template_path, out_path, pages):
+    """复制模板 zip,替换 document.xml 生成输出"""
+    with zipfile.ZipFile(template_path) as zin, \
+            zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zout:
+        for item in zin.infolist():
+            data = zin.read(item.filename)
+            if item.filename == 'word/document.xml':
+                data = build_document_xml(data, pages)
+            zout.writestr(item, data)
+
+
+# ---------------------------------------------------------------------------
+# Excel 读取与列识别
+# ---------------------------------------------------------------------------
+COL_KEYS = {
+    '姓名': ('姓名', '名字', 'name', '学生'),
+    '班级': ('班级', '班别', '班号', 'class', '班'),
+}
+
+
+def read_roster(xlsx_path, sheet=None):
+    """读 Excel,返回 [(班级, 姓名)] 列表;自动识别列"""
+    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
+    ws = None
+    if sheet is not None:
+        if isinstance(sheet, int):
+            ws = wb.worksheets[sheet]
+        else:
+            ws = wb[sheet] if sheet in wb.sheetnames else None
+        if ws is None:
+            raise ValueError('找不到工作表: %s' % sheet)
+    else:
+        ws = wb.worksheets[0]
+
+    rows = list(ws.iter_rows(values_only=True))
+    if not rows:
+        raise ValueError('工作表为空: %s' % ws.title)
+
+    header = [('' if c is None else str(c).strip()) for c in rows[0]]
+
+    def find_col(key):
+        for i, h in enumerate(header):
+            if any(k in h.lower() for k in COL_KEYS[key]):
+                return i
+        return None
+
+    cls_col = find_col('班级')
+    name_col = find_col('姓名')
+    # 单列名单(无表头或只有姓名列)时的兜底
+    if name_col is None:
+        name_col = 0
+    data_start = 1 if (name_col is not None or cls_col is not None) else 0
+
+    roster = []
+    for r in rows[data_start:]:
+        name = r[name_col]
+        cls = r[cls_col] if cls_col is not None else None
+        if name is None or str(name).strip() == '':
+            continue
+        roster.append((cls, str(name).strip()))
+    return roster, bool(cls_col is not None)
+
+
+def normalize_class_display(cls):
+    """班级显示名规范化: 31 -> '高三31班'; '31班' -> '高三31班'; '实验班' -> '实验班'"""
+    if cls is None:
+        return None
+    s = str(cls).strip()
+    if isinstance(cls, float) and cls.is_integer():
+        s = str(int(cls))
+    m = re.search(r'(\d+)', s)
+    if m:
+        # 已含"高 X 班"完整形式则原样使用,否则补成 高三N班
+        if '高三' in s and '班' in s:
+            return s
+        return '高三%s班' % m.group(1)
+    return s or None
+
+
+def class_file_key(cls_display):
+    """输出文件名用班级标识: '高三31班' -> '31班'"""
+    m = re.search(r'(\d+)', cls_display)
+    if m:
+        return m.group(1) + '班'
+    return cls_display
+
+
+# ---------------------------------------------------------------------------
+# 主流程
+# ---------------------------------------------------------------------------
+def main():
+    ap = argparse.ArgumentParser(
+        description='批量生成折叠三角姓名牌 Word 文档(每人一页,含正反两面)')
+    ap.add_argument('输入', help='Excel 名单文件(需含"班级"与"姓名"列,自动识别)')
+    ap.add_argument('--班级', default=None,
+                    help='单列名单(无班级列)时指定班级,如 31 或 "高三31班"')
+    ap.add_argument('--输出目录', '-o', default=None, help='输出目录(默认与输入文件同目录)')
+    ap.add_argument('--模板', default=None, help='自定义模板 docx(默认自动生成)')
+    ap.add_argument('--座右铭', default=DEFAULT_MOTTO, help='座右铭(默认: %s)' % DEFAULT_MOTTO)
+    ap.add_argument('--sheet', default=None, help='Excel 工作表名或索引(默认第一个)')
+    ap.add_argument('--保留模板', action='store_true',
+                    help='保存模板 docx(姓名牌_模板.docx)以便在 WPS 中修改样式后复用')
+    args = ap.parse_args()
+
+    src = args.输入
+    if not os.path.isfile(src):
+        sys.exit('找不到输入文件: %s' % src)
+    out_dir = args.输出目录 or os.path.dirname(os.path.abspath(src)) or '.'
+    os.makedirs(out_dir, exist_ok=True)
+
+    # 1. 读名单
+    print('读取名单: %s' % src)
+    roster, has_cls_col = read_roster(src, args.sheet)
+    if not roster:
+        sys.exit('名单为空(未识别到数据行)')
+
+    # 2. 按班级分组
+    groups = OrderedDict()
+    for cls, name in roster:
+        if not has_cls_col and args.班级 is None:
+            sys.exit('名单中没有"班级"列,请用 --班级 参数指定班级')
+        display = normalize_class_display(cls if has_cls_col else args.班级)
+        if display is None:
+            print('警告: 跳过无班级的学生 %s' % name)
+            continue
+        groups.setdefault(display, []).append(name)
+    if not groups:
+        sys.exit('没有可生成的学生')
+
+    # 3. 模板: --模板 指定 > 脚本/当前目录的姓名牌_模板.docx > 内置版式构建
+    template_path = None
+    if args.模板:
+        if not os.path.isfile(args.模板):
+            sys.exit('找不到模板: %s' % args.模板)
+        template_path = args.模板
+    else:
+        for c in (os.path.join(os.path.dirname(os.path.abspath(__file__)), '姓名牌_模板.docx'),
+                  os.path.join(os.getcwd(), '姓名牌_模板.docx')):
+            if os.path.isfile(c):
+                template_path = c
+                break
+    if template_path:
+        print('使用模板: %s' % template_path)
+        if args.保留模板 and args.模板:
+            import shutil
+            shutil.copy(args.模板, os.path.join(out_dir, '姓名牌_模板.docx'))
+    else:
+        template_path = os.path.join(out_dir, '姓名牌_模板.docx')
+        print('未找到模板文件,按内置版式构建模板: %s' % template_path)
+        build_template(template_path, '高三' + DEFAULT_CLASS + '班', DEFAULT_STUDENT, DEFAULT_MOTTO)
+
+    # 4. 逐班生成
+    for cls_display, names in groups.items():
+        pages = [(cls_display, name, args.座右铭) for name in names]
+        out_path = os.path.join(out_dir, '姓名牌_%s_输出.docx' % class_file_key(cls_display))
+        print('生成 %s: %d 人 -> %s' % (cls_display, len(names), out_path))
+        copy_and_generate(template_path, out_path, pages)
+
+    print('完成。共 %d 个班,%d 人。' % (len(groups), len(roster)))
+
+
+if __name__ == '__main__':
+    main()