This commit is contained in:
JA
2026-08-21 10:39:51 +08:00
parent 46e4f8471a
commit c18f9d5536
12 changed files with 285 additions and 783 deletions
+151 -64
View File
@@ -4,6 +4,7 @@
功能:自动扫描项目目录,识别标准结构,生成游戏文字脚本Word文档。
无需手动配置路径,只需提供项目根目录即可自动完成全部流程。
配置表直接读取 xlsx(优先)或 csv,无需预先转换。
标准目录结构约定:
<project_root>/
@@ -11,7 +12,9 @@
├── *_game/ ← 游戏客户端目录
│ └── unity/Assets/Resources/.../atlas_ui/ ← 图标资源
│ └── unity/Assets/Scripts/.../*Const*.cs ← 系统提示常量
└── work/ ← 工作目录 (CSV缓存、输出等)
└── work/ ← 工作目录 (输出等)
正文编号使用 Word 自动编号列表,在 Word 中增删条目后编号会自动重排。
用法:
python universal_tool.py [项目路径] [--output 输出路径]
@@ -23,18 +26,18 @@ import csv
import os
import re
import sys
import glob
import argparse
from docx import Document
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# ============ 常量 ============
CN_NUMBERS = ["", "", "", "", "", "", "", "", "", "", "十一"]
# 11章节数据源定义(字段名取自CSV英文头行第3行)
# 11章节数据源定义(字段名取自表格英文头行第3行;优先读 xlsx,其次 csv
SECTION_DEFS = {
"quest_texts": {"csv": "quest", "field": "desc", "filter": {"category": ["1", "2"]}},
"achievements": {"csv": "quest", "field": "desc", "filter": {"category": ["9"]}},
@@ -86,7 +89,8 @@ class ProjectScanner:
base = os.path.basename(self.config_dir)
self.project_name = base.replace("_config", "")
# 3. CSV目录: work/cvs 优先,其次 config_dir 本身
# 3. 数据表目录(csv_dir): work/cvs 优先(若存在),否则用 config_dir 本身(含xlsx)
# find_file 优先查找 .xlsx,其次 .csv,两种格式都直接读取
if self.work_dir:
cvs = os.path.join(self.work_dir, "cvs")
if os.path.isdir(cvs) and os.listdir(cvs):
@@ -139,11 +143,11 @@ class ProjectScanner:
return
def find_file(self, name):
"""在 csv_dir 或 config_dir 中查找文件(支持 .csv/.xlsx"""
"""在 csv_dir 或 config_dir 中查找文件(优先 .xlsx,其次 .csv)"""
for d in [self.csv_dir, self.config_dir]:
if not d:
continue
for ext in [".csv", ".xlsx"]:
for ext in [".xlsx", ".csv"]:
p = os.path.join(d, name + ext)
if os.path.isfile(p):
return p
@@ -155,7 +159,7 @@ class ProjectScanner:
f" 项目名称: {self.project_name}",
f" 配置目录: {self.config_dir or '未找到'}",
f" 游戏目录: {self.game_dir or '未找到'}",
f" CSV目录: {self.csv_dir or '未找到'}",
f" 数据表目录: {self.csv_dir or '未找到'}",
f" 图标目录: {len(self.icon_dirs)}{list(self.icon_dirs.keys())}",
f" 脚本目录: {self.scripts_dir or '未找到'}",
f" 常量文件: {os.path.basename(self.game_text_const) if self.game_text_const else '未找到'}",
@@ -164,16 +168,24 @@ class ProjectScanner:
# ================================================================
# 二、CSV 数据读取(自动编码 + 动态字段名解析)
# 二、表格数据读取(xlsx/csv 自动识别 + 动态字段名解析)
# ================================================================
def read_csv(filepath):
def read_table(filepath):
"""
读取CSV文件,自动检测编码 (utf-8-sig/gbk/gb2312/utf-8)。
返回 (headers, rows)
统一表格读取(支持 .xlsx / .csv),返回 (headers, rows)
headers: 第3行英文字段名列表
rows: 第4行起的数据行列表 (list of list)
xlsx 直接读取,无需先转 csv。
"""
ext = os.path.splitext(filepath)[1].lower()
if ext == ".xlsx":
return _read_xlsx(filepath)
return _read_csv(filepath)
def _read_csv(filepath):
"""读取CSV文件,自动检测编码 (utf-8-sig/gbk/gb2312/utf-8)。"""
for enc in ["utf-8-sig", "gbk", "gb2312", "utf-8"]:
try:
with open(filepath, "r", encoding=enc) as f:
@@ -186,6 +198,20 @@ def read_csv(filepath):
return [], []
def _read_xlsx(filepath):
"""直接读取 xlsx(无需先转csv),表头结构与csv一致:第3行字段名,第4行起数据。"""
from openpyxl import load_workbook
wb = load_workbook(filepath, read_only=True, data_only=True)
ws = wb.active
all_rows = []
for row in ws.iter_rows(values_only=True):
all_rows.append([str(c) if c is not None else "" for c in row])
wb.close()
if len(all_rows) < 4:
return all_rows[-1] if all_rows else [], []
return all_rows[2], all_rows[3:]
def field_map(headers):
"""将英文头行转为 {字段名: 列索引} 映射"""
return {h.strip(): i for i, h in enumerate(headers) if h.strip()}
@@ -199,39 +225,6 @@ def field_val(row, fm, name, default=""):
return default
def xlsx_to_csv_batch(config_dir, output_dir):
"""将 config_dir 中所有 xlsx 批量转为 csv"""
from openpyxl import load_workbook
os.makedirs(output_dir, exist_ok=True)
count = 0
for xlsx in glob.glob(os.path.join(config_dir, "*.xlsx")):
fname = os.path.basename(xlsx)
if fname.startswith("~$"):
continue
csv_path = os.path.join(output_dir, os.path.splitext(fname)[0] + ".csv")
try:
wb = load_workbook(xlsx, read_only=True, data_only=True)
ws = wb.active
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
for row in ws.iter_rows(values_only=True):
writer.writerow([c if c is not None else "" for c in row])
wb.close()
count += 1
except Exception:
pass
# 复制非xlsx文件
import shutil
for entry in os.listdir(config_dir):
src = os.path.join(config_dir, entry)
if not os.path.isfile(src) or entry.endswith(".xlsx"):
continue
dst = os.path.join(output_dir, entry)
if not os.path.exists(dst):
shutil.copy2(src, dst)
return count
# ================================================================
# 三、图片资源查找
# ================================================================
@@ -353,11 +346,106 @@ def add_section_heading(doc, cn_num, title):
_run(p, f"{cn_num}{title}", font="仿宋", size=14, bold=True)
# ---- Word 自动编号辅助 ----
# 用 Word 的 numbering 机制生成 1. 2. 3. ... 列表,
# 这样在 Word 中增删条目后编号会自动重排,无需手改数字。
def _get_numbering_element(doc):
"""获取文档的 <w:numbering> 根元素(默认模板已含 numbering part)。"""
try:
return doc.part.numbering_part.element
except KeyError:
raise RuntimeError("当前文档模板缺少 numbering part,无法使用自动编号。")
def _create_decimal_numbering(doc):
"""
新建一个十进制自动编号定义 (1. 2. 3. ...),返回新的 numId。
每次调用返回独立的 numId,编号从 1 开始,
因此每个章节调用一次即可获得独立计数。
"""
numbering = _get_numbering_element(doc)
abs_ids = [int(an.get(qn('w:abstractNumId'))) for an in numbering.findall(qn('w:abstractNum'))]
num_ids = [int(n.get(qn('w:numId'))) for n in numbering.findall(qn('w:num'))]
next_abs = (max(abs_ids) + 1) if abs_ids else 0
next_num = (max(num_ids) + 1) if num_ids else 1
# <w:abstractNum>
abstract_num = OxmlElement('w:abstractNum')
abstract_num.set(qn('w:abstractNumId'), str(next_abs))
multi_level = OxmlElement('w:multiLevelType')
multi_level.set(qn('w:val'), 'singleLevel')
abstract_num.append(multi_level)
lvl = OxmlElement('w:lvl')
lvl.set(qn('w:ilvl'), '0')
start = OxmlElement('w:start')
start.set(qn('w:val'), '1')
lvl.append(start)
num_fmt = OxmlElement('w:numFmt')
num_fmt.set(qn('w:val'), 'decimal')
lvl.append(num_fmt)
lvl_text = OxmlElement('w:lvlText')
lvl_text.set(qn('w:val'), '%1.')
lvl.append(lvl_text)
lvl_jc = OxmlElement('w:lvlJc')
lvl_jc.set(qn('w:val'), 'left')
lvl.append(lvl_jc)
p_pr = OxmlElement('w:pPr')
ind = OxmlElement('w:ind')
ind.set(qn('w:left'), '420')
ind.set(qn('w:hanging'), '420')
p_pr.append(ind)
lvl.append(p_pr)
abstract_num.append(lvl)
# <w:num>
num = OxmlElement('w:num')
num.set(qn('w:numId'), str(next_num))
abs_ref = OxmlElement('w:abstractNumId')
abs_ref.set(qn('w:val'), str(next_abs))
num.append(abs_ref)
# abstractNum 必须排在所有 num 之前
last_abs = numbering.findall(qn('w:abstractNum'))
if last_abs:
last_abs[-1].addnext(abstract_num)
else:
numbering.insert(0, abstract_num)
numbering.append(num)
return next_num
def _apply_numbering(paragraph, num_id, ilvl=0):
"""给段落应用自动编号 (设置 numPr)。"""
p_pr = paragraph._p.get_or_add_pPr()
for existing in p_pr.findall(qn('w:numPr')):
p_pr.remove(existing)
num_pr = OxmlElement('w:numPr')
ilvl_el = OxmlElement('w:ilvl')
ilvl_el.set(qn('w:val'), str(ilvl))
num_pr.append(ilvl_el)
num_id_el = OxmlElement('w:numId')
num_id_el.set(qn('w:val'), str(num_id))
num_pr.append(num_id_el)
p_pr.append(num_pr)
def add_numbered_items(doc, items):
"""编号列表:仿宋 四号 不加粗"""
for i, text in enumerate(items, 1):
"""
编号列表:仿宋 四号 不加粗,使用 Word 自动编号。
每次调用创建独立编号序列(从 1 开始),
在 Word 中增删条目后编号会自动重排。
"""
if not items:
return
num_id = _create_decimal_numbering(doc)
for text in items:
p = doc.add_paragraph()
_run(p, f"{i}.{text}", font="仿宋", size=14, bold=False)
_apply_numbering(p, num_id)
_run(p, str(text), font="仿宋", size=14, bold=False)
def _set_cell(cell, text, bold=False, size=14):
@@ -412,10 +500,10 @@ def add_name_table(doc, names):
# ================================================================
def get_player_names(scanner):
"""获取玩家随机名称(优先 name.csv,否则使用默认列表)"""
"""获取玩家随机名称(优先 name.xlsx/csv,否则使用默认列表)"""
fp = scanner.find_file("name")
if fp and fp.endswith(".csv"):
headers, rows = read_csv(fp)
if fp:
headers, rows = read_table(fp)
fm = field_map(headers)
if "first" in fm and "last" in fm:
seen = set()
@@ -461,11 +549,11 @@ def get_player_names(scanner):
def get_csv_list_data(scanner, csv_name, field_name, filter_dict=None):
"""通用:从CSV中按字段名提取去重文本列表"""
"""通用:从表格(xlsx/csv)中按字段名提取去重文本列表"""
fp = scanner.find_file(csv_name)
if not fp or not fp.endswith(".csv"):
if not fp:
return []
headers, rows = read_csv(fp)
headers, rows = read_table(fp)
fm = field_map(headers)
seen = set()
result = []
@@ -487,11 +575,11 @@ def get_csv_list_data(scanner, csv_name, field_name, filter_dict=None):
def get_csv_table_data(scanner, csv_name, field_names, dedup_field=None):
"""通用:从CSV中提取多字段表格数据 [(f1, f2, f3, ...), ...]"""
"""通用:从表格(xlsx/csv)中提取多字段表格数据 [(f1, f2, f3, ...), ...]"""
fp = scanner.find_file(csv_name)
if not fp or not fp.endswith(".csv"):
if not fp:
return []
headers, rows = read_csv(fp)
headers, rows = read_table(fp)
fm = field_map(headers)
seen = set()
result = []
@@ -516,14 +604,13 @@ def build_document(scanner, output_path):
"""根据扫描结果构建完整文档"""
print("\n[1/4] 准备数据源...")
# 确保CSV可用
if not scanner.csv_dir or not any(f.endswith(".csv") for f in os.listdir(scanner.csv_dir)):
if scanner.config_dir:
print(f" CSV目录为空,正在从 {os.path.basename(scanner.config_dir)} 转换xlsx...")
cvs_out = os.path.join(scanner.work_dir or scanner.root, "cvs")
n = xlsx_to_csv_batch(scanner.config_dir, cvs_out)
scanner.csv_dir = cvs_out
print(f" 已转换 {n} 个xlsx文件")
# 直接从 config_dir / csv_dir 读取 xlsx(优先) 或 csv,不再转换。
data_dir = scanner.csv_dir or scanner.config_dir
if data_dir:
files = [f for f in os.listdir(data_dir) if not f.startswith("~$")]
xlsx_n = sum(1 for f in files if f.endswith(".xlsx"))
csv_n = sum(1 for f in files if f.endswith(".csv"))
print(f" 数据目录: {data_dir} (xlsx {xlsx_n} 个 / csv {csv_n} 个)")
# 预加载数据
system_prompts = parse_game_text_const(scanner.game_text_const, scanner.scripts_dir)