|
@@ -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()
|