更新
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user