excel-codegen 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- excel_codegen/__init__.py +160 -0
- excel_codegen/__main__.py +8 -0
- excel_codegen/cli.py +1250 -0
- excel_codegen/derived.py +465 -0
- excel_codegen/example_pack.py +107 -0
- excel_codegen/examples/README.md +69 -0
- excel_codegen/examples/abs_fpi/ABS_FPI_load_cases.xlsx +0 -0
- excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.bat +22 -0
- excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.sh +23 -0
- excel_codegen/examples/abs_fpi/README.md +121 -0
- excel_codegen/examples/abs_fpi/abs_fpi.yaml +310 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external.xlsx +0 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external.yaml +162 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external_render.bat +22 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external_render.sh +23 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal.xlsx +0 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal.yaml +266 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal_render.bat +22 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal_render.sh +23 -0
- excel_codegen/examples/abs_fpi/compose.yaml +20 -0
- excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60.js +124 -0
- excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60_summary.md +30 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1.js +91 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1_summary.md +23 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1.js +91 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1_summary.md +23 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1.js +91 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1_summary.md +23 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0.js +124 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0_summary.md +30 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90.js +124 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90_summary.md +30 -0
- excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90.js +124 -0
- excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90_summary.md +30 -0
- excel_codegen/examples/abs_fpi/templates/ext_body.js.j2 +91 -0
- excel_codegen/examples/abs_fpi/templates/ext_summary.md.j2 +23 -0
- excel_codegen/examples/abs_fpi/templates/int_body.js.j2 +124 -0
- excel_codegen/examples/abs_fpi/templates/int_summary.md.j2 +30 -0
- excel_codegen/examples/basic/example.yaml +97 -0
- excel_codegen/examples/basic/example_formula.yaml +86 -0
- excel_codegen/examples/basic/generated/uart_init_Case1.c +6 -0
- excel_codegen/examples/basic/generated/uart_init_Case2.c +6 -0
- excel_codegen/examples/basic/generated/uart_summary_Case1.md +4 -0
- excel_codegen/examples/basic/generated/uart_summary_Case2.md +4 -0
- excel_codegen/examples/basic/generated_formula/uart_init_Case1.c +5 -0
- excel_codegen/examples/basic/generated_formula/uart_init_Case2.c +5 -0
- excel_codegen/examples/basic/generated_formula/uart_pins_Case1.csv +1 -0
- excel_codegen/examples/basic/generated_formula/uart_pins_Case2.csv +1 -0
- excel_codegen/examples/basic/template.xlsx +0 -0
- excel_codegen/examples/basic/template_formula.xlsx +0 -0
- excel_codegen/examples/basic/template_formula_render.bat +22 -0
- excel_codegen/examples/basic/template_formula_render.sh +23 -0
- excel_codegen/examples/basic/template_render.bat +22 -0
- excel_codegen/examples/basic/template_render.sh +23 -0
- excel_codegen/examples/nastran/generated/case_control.deck +18 -0
- excel_codegen/examples/nastran/generated/cc_01_LC1.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_02_LC2.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_03_LC3.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_04_COMB4.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_05_LC5.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_summary_01_LC1.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_02_LC2.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_03_LC3.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_04_COMB4.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_05_LC5.md +1 -0
- excel_codegen/examples/nastran/nastran_case_control.xlsx +0 -0
- excel_codegen/examples/nastran/nastran_case_control.yaml +141 -0
- excel_codegen/examples/nastran/nastran_case_control_render.bat +22 -0
- excel_codegen/examples/nastran/nastran_case_control_render.sh +23 -0
- excel_codegen/excel_io.py +1885 -0
- excel_codegen/formula.py +674 -0
- excel_codegen/formula_eval.py +685 -0
- excel_codegen/jinja_env.py +92 -0
- excel_codegen/models.py +1008 -0
- excel_codegen/renderer.py +558 -0
- excel_codegen/utils.py +225 -0
- excel_codegen-0.9.0.dist-info/METADATA +179 -0
- excel_codegen-0.9.0.dist-info/RECORD +82 -0
- excel_codegen-0.9.0.dist-info/WHEEL +5 -0
- excel_codegen-0.9.0.dist-info/entry_points.txt +2 -0
- excel_codegen-0.9.0.dist-info/licenses/LICENSE +21 -0
- excel_codegen-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1885 @@
|
|
|
1
|
+
"""Excel 侧实现:生成模板、读取用户填写内容、把渲染结果写回工作表。
|
|
2
|
+
|
|
3
|
+
工作表约定
|
|
4
|
+
----------
|
|
5
|
+
Global Parameter
|
|
6
|
+
``A=Variable``、``B=Value``(用户填写)、``C=Description``、``D=Prefix``、``E=Suffix``
|
|
7
|
+
|
|
8
|
+
Local Parameter
|
|
9
|
+
``A=Variable``、``B=Description``、``C=Prefix``、``D=Suffix``、从 ``E`` 列开始每个 Case 一列
|
|
10
|
+
(``E1=Case1``、``F1=Case2`` …),用户可右拉增加 Case。
|
|
11
|
+
|
|
12
|
+
Output / …
|
|
13
|
+
配置中 ``excel.sheets.outputs`` 声明的输出表,渲染结果写在这里。
|
|
14
|
+
|
|
15
|
+
Template(可选,隐藏)
|
|
16
|
+
模板原文 + 机器可读的渲染元信息(时间 / 参数指纹 / 输出指纹),方便对照与 `check`。
|
|
17
|
+
|
|
18
|
+
HOWTO(可选,第一张)
|
|
19
|
+
写进工作簿本身的"下一步跑什么"说明;每次写回结果时刷新。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from contextlib import suppress
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from openpyxl import Workbook, load_workbook
|
|
32
|
+
from openpyxl.comments import Comment
|
|
33
|
+
from openpyxl.styles import Alignment, Font, PatternFill
|
|
34
|
+
from openpyxl.worksheet.datavalidation import DataValidation
|
|
35
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
36
|
+
|
|
37
|
+
from .derived import (
|
|
38
|
+
DerivedError,
|
|
39
|
+
DerivedNotTranslatable,
|
|
40
|
+
evaluate_derived,
|
|
41
|
+
is_translatable,
|
|
42
|
+
to_excel,
|
|
43
|
+
)
|
|
44
|
+
from .derived import validate_config as derived_validate_config
|
|
45
|
+
from .formula import (
|
|
46
|
+
LONG_FORMULA_WARN,
|
|
47
|
+
compile_formulas,
|
|
48
|
+
guard_default,
|
|
49
|
+
guarded_lookup,
|
|
50
|
+
local_cell,
|
|
51
|
+
)
|
|
52
|
+
from .models import (
|
|
53
|
+
FIRST_CASE_COLUMN,
|
|
54
|
+
CaseData,
|
|
55
|
+
ProjectConfig,
|
|
56
|
+
RenderResult,
|
|
57
|
+
VariableDef,
|
|
58
|
+
)
|
|
59
|
+
from .utils import (
|
|
60
|
+
CodeGenError,
|
|
61
|
+
ExcelError,
|
|
62
|
+
VarValue,
|
|
63
|
+
column_index_to_letter,
|
|
64
|
+
fingerprint,
|
|
65
|
+
parse_cell,
|
|
66
|
+
to_text,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"GLOBAL_HEADERS",
|
|
71
|
+
"GROUP_HEADER",
|
|
72
|
+
"LOCAL_CASE_HEADER",
|
|
73
|
+
"LOCAL_HEADERS",
|
|
74
|
+
"META_MARKER",
|
|
75
|
+
"case_anchor_map",
|
|
76
|
+
"check_required_sheets",
|
|
77
|
+
"create_template",
|
|
78
|
+
"get_sheet",
|
|
79
|
+
"input_fingerprint",
|
|
80
|
+
"load_workbook_file",
|
|
81
|
+
"output_fingerprint",
|
|
82
|
+
"read_cases",
|
|
83
|
+
"read_global_values",
|
|
84
|
+
"read_group_members",
|
|
85
|
+
"read_metadata",
|
|
86
|
+
"template_source",
|
|
87
|
+
"write_results",
|
|
88
|
+
"write_run_scripts",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
GLOBAL_HEADERS: tuple[str, ...] = ("Variable", "Value", "Description", "Prefix", "Suffix")
|
|
92
|
+
LOCAL_HEADERS: tuple[str, ...] = ("Variable", "Description", "Prefix", "Suffix")
|
|
93
|
+
#: 纵向 Local 表 A1 的表头(那一列写 Case 名)。
|
|
94
|
+
LOCAL_CASE_HEADER = "Case"
|
|
95
|
+
|
|
96
|
+
#: 隐藏 Template 表里"机器可读元信息块"的起始标记。
|
|
97
|
+
META_MARKER = "## excel-codegen-meta"
|
|
98
|
+
|
|
99
|
+
#: 元信息键名(Template 表与 HOWTO 表共用同一套键,读取方不必区分来源)。
|
|
100
|
+
META_TIME = "时间"
|
|
101
|
+
META_INPUT = "参数指纹"
|
|
102
|
+
META_OUTPUT = "输出指纹"
|
|
103
|
+
|
|
104
|
+
#: 清理旧结果时,向右/向下最多扫多少列/行(防止误伤同表里的无关内容)。
|
|
105
|
+
_SCAN_LIMIT = 256
|
|
106
|
+
|
|
107
|
+
#: Global 表列号
|
|
108
|
+
_GLOBAL_COL = {"name": 1, "value": 2, "description": 3, "prefix": 4, "suffix": 5}
|
|
109
|
+
#: 成员表(第三层作用域)列号:A=成员名,B 起一个变量一列
|
|
110
|
+
_GROUP_FIRST_VAR_COLUMN = 2
|
|
111
|
+
#: 成员表 A 列表头
|
|
112
|
+
GROUP_HEADER = "成员"
|
|
113
|
+
#: Local 表列号
|
|
114
|
+
_LOCAL_COL = {"name": 1, "description": 2, "prefix": 3, "suffix": 4}
|
|
115
|
+
|
|
116
|
+
_HEADER_FONT = Font(bold=True)
|
|
117
|
+
_HEADER_FILL = PatternFill("solid", fgColor="D9E1F2")
|
|
118
|
+
_CASE_HEADER_FILL = PatternFill("solid", fgColor="EDEDED")
|
|
119
|
+
_INPUT_FILL = PatternFill("solid", fgColor="FFF2CC")
|
|
120
|
+
#: 派生参数(自动计算)的格:淡绿,和"要你填"的黄色区分开
|
|
121
|
+
_DERIVED_FILL = PatternFill("solid", fgColor="E2EFDA")
|
|
122
|
+
_TOP_ALIGN = Alignment(vertical="top", wrap_text=False)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cell_or(cell_value: Any, default: Any) -> Any:
|
|
126
|
+
"""只有"单元格真的为空"才回落 ``default``;非空值原样返回。
|
|
127
|
+
|
|
128
|
+
与 ``.strip()`` 的关键区别:``" m"``(带前导空格的单位)是**有内容**的值,
|
|
129
|
+
必须原样保留 —— 否则 ``suffix: " m"`` 会被读成 ``"m"``,生成 ``340m``。
|
|
130
|
+
"""
|
|
131
|
+
if cell_value is None:
|
|
132
|
+
return default
|
|
133
|
+
if isinstance(cell_value, str) and cell_value.strip() == "":
|
|
134
|
+
return default
|
|
135
|
+
return cell_value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _text_or(cell_value: Any, default: Any) -> str:
|
|
139
|
+
""":func:`_cell_or` 的文本版本(前缀/后缀用)。"""
|
|
140
|
+
return to_text(_cell_or(cell_value, default))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
# 生成 Excel 模板
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
def create_template(
|
|
147
|
+
config: ProjectConfig,
|
|
148
|
+
path: str | Path,
|
|
149
|
+
*,
|
|
150
|
+
cases: int | Sequence[str] = 2,
|
|
151
|
+
overwrite: bool = False,
|
|
152
|
+
include_template_sheet: bool | None = None,
|
|
153
|
+
include_howto_sheet: bool | None = None,
|
|
154
|
+
include_comments: bool = True,
|
|
155
|
+
include_scripts: bool = True,
|
|
156
|
+
base_dir: str | Path | None = None,
|
|
157
|
+
) -> Path:
|
|
158
|
+
"""按配置生成 Excel 模板文件。
|
|
159
|
+
|
|
160
|
+
:param cases: 初始 Case 列数量(``int``)或直接给出 Case 名称列表。
|
|
161
|
+
:param overwrite: 目标文件已存在时是否覆盖。
|
|
162
|
+
:param include_template_sheet: ``None`` 时遵循配置;``True/False`` 强制生成/不生成隐藏 Template 表。
|
|
163
|
+
:param include_howto_sheet: ``None`` 时遵循配置;``True/False`` 强制生成/不生成 HOWTO 说明表。
|
|
164
|
+
:param include_comments: 是否给"变量名"那一格加批注(描述 / 单位 / 约束 / 前缀后缀 / 派生表达式)。
|
|
165
|
+
:param include_scripts: 是否在工作簿旁边生成 ``*_render.bat`` / ``*_render.sh`` 一键刷新脚本。
|
|
166
|
+
:param base_dir: 解析 ``template_file`` 相对路径的基准目录,默认使用 ``config.source_dir``。
|
|
167
|
+
"""
|
|
168
|
+
target = Path(path)
|
|
169
|
+
if target.exists() and not overwrite:
|
|
170
|
+
raise ExcelError(f"Excel 模板已存在: {target}(需要覆盖请加 --force)")
|
|
171
|
+
if target.exists() and target.is_dir():
|
|
172
|
+
raise ExcelError(f"目标路径是目录: {target}")
|
|
173
|
+
|
|
174
|
+
# 派生参数先过一遍配置期检查(语法 / 引用范围 / 循环),别等到渲染才炸
|
|
175
|
+
derived_validate_config(config)
|
|
176
|
+
# 模板也先过一遍:语法 / 片段 / **公式模式能不能表达**
|
|
177
|
+
_preflight_templates(config, base_dir=base_dir or config.source_dir)
|
|
178
|
+
|
|
179
|
+
case_names = _normalise_case_names(cases)
|
|
180
|
+
config.scripts_enabled = include_scripts
|
|
181
|
+
|
|
182
|
+
workbook = Workbook()
|
|
183
|
+
default_sheet = workbook.active
|
|
184
|
+
if default_sheet is not None:
|
|
185
|
+
workbook.remove(default_sheet)
|
|
186
|
+
|
|
187
|
+
global_name = config.excel.sheets.global_
|
|
188
|
+
local_name = config.excel.sheets.local
|
|
189
|
+
_build_global_sheet(workbook.create_sheet(global_name), config, comments=include_comments)
|
|
190
|
+
_build_local_sheet(workbook.create_sheet(local_name), config, case_names, comments=include_comments)
|
|
191
|
+
if config.group is not None:
|
|
192
|
+
_build_group_sheet(
|
|
193
|
+
workbook.create_sheet(config.group.sheet), config, config.group.members, comments=include_comments
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
for name in config.excel.sheets.outputs:
|
|
197
|
+
workbook.create_sheet(name)
|
|
198
|
+
|
|
199
|
+
sheet_name = config.excel.template_sheet
|
|
200
|
+
if include_template_sheet is False:
|
|
201
|
+
sheet_name = None
|
|
202
|
+
elif include_template_sheet is True and not sheet_name:
|
|
203
|
+
sheet_name = "Template"
|
|
204
|
+
if sheet_name:
|
|
205
|
+
if sheet_name in workbook.sheetnames:
|
|
206
|
+
raise ExcelError(f"Template 工作表名 {sheet_name!r} 与已有工作表冲突,请修改 excel.template_sheet")
|
|
207
|
+
template_sheet = workbook.create_sheet(sheet_name)
|
|
208
|
+
_build_template_sheet(template_sheet, config, base_dir=base_dir or config.source_dir)
|
|
209
|
+
template_sheet.sheet_state = "hidden"
|
|
210
|
+
|
|
211
|
+
howto_name = config.excel.howto_sheet
|
|
212
|
+
if include_howto_sheet is False:
|
|
213
|
+
howto_name = None
|
|
214
|
+
elif include_howto_sheet is True and not howto_name:
|
|
215
|
+
howto_name = "HOWTO"
|
|
216
|
+
if howto_name:
|
|
217
|
+
if howto_name in workbook.sheetnames:
|
|
218
|
+
raise ExcelError(f"HOWTO 工作表名 {howto_name!r} 与已有工作表冲突,请修改 excel.howto_sheet")
|
|
219
|
+
howto_sheet = workbook.create_sheet(howto_name)
|
|
220
|
+
# 放在第一张:打开工作簿先看到"下一步跑什么"
|
|
221
|
+
workbook.move_sheet(howto_sheet, offset=-len(workbook.sheetnames) + 1)
|
|
222
|
+
write_howto_sheet(howto_sheet, config, metadata=None)
|
|
223
|
+
|
|
224
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
try:
|
|
226
|
+
workbook.save(target)
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise ExcelError(f"无法写入 Excel 模板 {target}: {exc}(文件被 Excel 占用?)") from exc
|
|
229
|
+
finally:
|
|
230
|
+
workbook.close()
|
|
231
|
+
|
|
232
|
+
if include_scripts:
|
|
233
|
+
# 放在工作簿旁边:改完参数双击就能刷新,不用记命令
|
|
234
|
+
write_run_scripts(config, target)
|
|
235
|
+
return target
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _preflight_templates(config: ProjectConfig, *, base_dir: str | Path | None) -> None:
|
|
239
|
+
"""建表之前先把每个模板过一遍:语法、``{% include %}`` 片段、公式模式能否表达。
|
|
240
|
+
|
|
241
|
+
为什么要在这里做:**公式模式是默认引擎**,而它只支持"占位符 + 单行 ``{% if %}``"。
|
|
242
|
+
如果等到 ``render`` 才发现模板用了过滤器,那时工作簿已经生成、参数也填了一半 ——
|
|
243
|
+
而这个工具的正常用法是"建一次工作簿,之后就在 Excel 里干活",越早报错越好。
|
|
244
|
+
报错里会明确说"请把该模板改回 engine: snapshot"。
|
|
245
|
+
"""
|
|
246
|
+
from .formula import compile_formulas # 本模块已导入,这里只是让依赖显式
|
|
247
|
+
from .renderer import validate_template # 延迟导入:renderer 依赖本模块
|
|
248
|
+
|
|
249
|
+
for template in config.templates:
|
|
250
|
+
validate_template(template, base_dir=base_dir)
|
|
251
|
+
if template.engine != "excel":
|
|
252
|
+
continue
|
|
253
|
+
# 轴取哪个都行 —— 这里只关心"这一行能不能编译成公式"(与 validate 命令同一套检查)
|
|
254
|
+
compile_formulas(
|
|
255
|
+
template,
|
|
256
|
+
config,
|
|
257
|
+
case_axes=[FIRST_CASE_COLUMN],
|
|
258
|
+
source=_template_source(template, base_dir),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _normalise_case_names(cases: int | Sequence[str]) -> list[str]:
|
|
263
|
+
if isinstance(cases, int):
|
|
264
|
+
if cases < 1:
|
|
265
|
+
raise ExcelError("Case 列数量至少为 1")
|
|
266
|
+
return [f"Case{index}" for index in range(1, cases + 1)]
|
|
267
|
+
names = [to_text(item).strip() for item in cases]
|
|
268
|
+
if not names or any(not name for name in names):
|
|
269
|
+
raise ExcelError("Case 名称不能为空")
|
|
270
|
+
if len(set(names)) != len(names):
|
|
271
|
+
raise ExcelError(f"Case 名称重复: {', '.join(names)}")
|
|
272
|
+
return names
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_global_sheet(worksheet: Worksheet, config: ProjectConfig, *, comments: bool = True) -> None:
|
|
276
|
+
_write_headers(worksheet, GLOBAL_HEADERS)
|
|
277
|
+
for row, variable in enumerate(config.global_variables, start=2):
|
|
278
|
+
name_cell = worksheet.cell(row=row, column=_GLOBAL_COL["name"], value=variable.name)
|
|
279
|
+
if comments:
|
|
280
|
+
_attach_comment(name_cell, variable, where="Global 表 B 列(所有 Case 共用)")
|
|
281
|
+
if variable.is_derived:
|
|
282
|
+
_write_derived_cell(
|
|
283
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["value"]),
|
|
284
|
+
variable,
|
|
285
|
+
resolve=_global_resolver(config),
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
value_cell = worksheet.cell(row=row, column=_GLOBAL_COL["value"], value=variable.default)
|
|
289
|
+
value_cell.fill = _INPUT_FILL
|
|
290
|
+
value_cell.alignment = _TOP_ALIGN
|
|
291
|
+
_add_value_validation(worksheet, variable, [value_cell.coordinate])
|
|
292
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["description"], value=_described(variable))
|
|
293
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["prefix"], value=variable.prefix)
|
|
294
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["suffix"], value=variable.suffix)
|
|
295
|
+
for index, width in enumerate((26, 30, 46, 16, 16), start=1):
|
|
296
|
+
worksheet.column_dimensions[column_index_to_letter(index)].width = width
|
|
297
|
+
worksheet.freeze_panes = "A2"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _build_local_sheet(
|
|
301
|
+
worksheet: Worksheet,
|
|
302
|
+
config: ProjectConfig,
|
|
303
|
+
case_names: Sequence[str],
|
|
304
|
+
*,
|
|
305
|
+
comments: bool = True,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""搭 Local Parameter 表:布局由 ``excel.local_direction`` 决定。
|
|
308
|
+
|
|
309
|
+
* ``horizontal``(默认):变量做行,一个工况一列(E1 起写 Case 名);
|
|
310
|
+
* ``vertical``:变量做列,一个工况一行(A2 起写 Case 名)。
|
|
311
|
+
|
|
312
|
+
两种布局都**没有**给 Prefix / Suffix 留列吗?不是 —— 横向布局保留 C/D 两列
|
|
313
|
+
(老行为,可在表里覆盖 YAML 的前后缀);纵向布局第 1 行整行都是变量名,
|
|
314
|
+
所以前后缀只来自 YAML,表头格的批注里写着它们是什么。
|
|
315
|
+
"""
|
|
316
|
+
if config.excel.local_direction == "vertical":
|
|
317
|
+
_build_local_sheet_vertical(worksheet, config, case_names, comments=comments)
|
|
318
|
+
else:
|
|
319
|
+
_build_local_sheet_horizontal(worksheet, config, case_names, comments=comments)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _build_local_sheet_horizontal(
|
|
323
|
+
worksheet: Worksheet,
|
|
324
|
+
config: ProjectConfig,
|
|
325
|
+
case_names: Sequence[str],
|
|
326
|
+
*,
|
|
327
|
+
comments: bool,
|
|
328
|
+
) -> None:
|
|
329
|
+
_write_headers(worksheet, list(LOCAL_HEADERS) + list(case_names))
|
|
330
|
+
for row, variable in enumerate(config.local_variables, start=2):
|
|
331
|
+
name_cell = worksheet.cell(row=row, column=_LOCAL_COL["name"], value=variable.name)
|
|
332
|
+
if comments:
|
|
333
|
+
_attach_comment(name_cell, variable, where="Local 表 E 列起(一个 Case 一列)")
|
|
334
|
+
worksheet.cell(row=row, column=_LOCAL_COL["description"], value=_described(variable))
|
|
335
|
+
worksheet.cell(row=row, column=_LOCAL_COL["prefix"], value=variable.prefix)
|
|
336
|
+
worksheet.cell(row=row, column=_LOCAL_COL["suffix"], value=variable.suffix)
|
|
337
|
+
input_cells: list[str] = []
|
|
338
|
+
for offset in range(len(case_names)):
|
|
339
|
+
column = FIRST_CASE_COLUMN + offset
|
|
340
|
+
cell = worksheet.cell(row=row, column=column)
|
|
341
|
+
if variable.is_derived:
|
|
342
|
+
_write_derived_cell(cell, variable, resolve=_local_resolver(config, CaseData(name="", column=column)))
|
|
343
|
+
else:
|
|
344
|
+
cell.value = variable.default
|
|
345
|
+
cell.fill = _INPUT_FILL
|
|
346
|
+
cell.alignment = _TOP_ALIGN
|
|
347
|
+
input_cells.append(cell.coordinate)
|
|
348
|
+
_add_value_validation(worksheet, variable, input_cells)
|
|
349
|
+
for index, width in enumerate((24, 40, 16, 16), start=1):
|
|
350
|
+
worksheet.column_dimensions[column_index_to_letter(index)].width = width
|
|
351
|
+
for offset, name in enumerate(case_names):
|
|
352
|
+
column = FIRST_CASE_COLUMN + offset
|
|
353
|
+
worksheet.column_dimensions[column_index_to_letter(column)].width = max(16, min(36, len(name) + 14))
|
|
354
|
+
worksheet.freeze_panes = "E2"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _build_local_sheet_vertical(
|
|
358
|
+
worksheet: Worksheet,
|
|
359
|
+
config: ProjectConfig,
|
|
360
|
+
case_names: Sequence[str],
|
|
361
|
+
*,
|
|
362
|
+
comments: bool,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""纵向 Local 表:一行一个工况(第 1 行是变量名),方便整块粘贴参数。"""
|
|
365
|
+
worksheet.cell(row=1, column=_LOCAL_COL["name"], value=LOCAL_CASE_HEADER).font = _HEADER_FONT
|
|
366
|
+
worksheet.cell(row=1, column=_LOCAL_COL["name"]).fill = _CASE_HEADER_FILL
|
|
367
|
+
for offset, variable in enumerate(config.local_variables):
|
|
368
|
+
column = _GROUP_FIRST_VAR_COLUMN + offset
|
|
369
|
+
header = worksheet.cell(row=1, column=column, value=variable.name)
|
|
370
|
+
if comments:
|
|
371
|
+
_attach_comment(header, variable, where=f"Local 表 {column_index_to_letter(column)} 列(一个 Case 一行)")
|
|
372
|
+
worksheet.column_dimensions[column_index_to_letter(column)].width = max(16, min(36, len(variable.name) + 14))
|
|
373
|
+
for offset in range(len(case_names)):
|
|
374
|
+
row = 2 + offset
|
|
375
|
+
case_cell = worksheet.cell(row=row, column=_LOCAL_COL["name"], value=case_names[offset])
|
|
376
|
+
case_cell.fill = _CASE_HEADER_FILL
|
|
377
|
+
case_cell.font = _HEADER_FONT
|
|
378
|
+
last_row = 1 + len(case_names)
|
|
379
|
+
|
|
380
|
+
for offset, variable in enumerate(config.local_variables):
|
|
381
|
+
column = _GROUP_FIRST_VAR_COLUMN + offset
|
|
382
|
+
input_cells: list[str] = []
|
|
383
|
+
for index in range(len(case_names)):
|
|
384
|
+
row = 2 + index
|
|
385
|
+
cell = worksheet.cell(row=row, column=column)
|
|
386
|
+
if variable.is_derived:
|
|
387
|
+
_write_derived_cell(cell, variable, resolve=_local_resolver(config, CaseData(name="", row=row)))
|
|
388
|
+
else:
|
|
389
|
+
cell.value = variable.default
|
|
390
|
+
cell.fill = _INPUT_FILL
|
|
391
|
+
cell.alignment = _TOP_ALIGN
|
|
392
|
+
input_cells.append(cell.coordinate)
|
|
393
|
+
if input_cells:
|
|
394
|
+
letter = column_index_to_letter(column)
|
|
395
|
+
_add_value_validation(worksheet, variable, [f"{letter}2:{letter}{last_row}"])
|
|
396
|
+
worksheet.column_dimensions[column_index_to_letter(_LOCAL_COL["name"])].width = 22
|
|
397
|
+
worksheet.freeze_panes = "B2"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _variable_comment(variable: VariableDef, *, where: str) -> str:
|
|
401
|
+
"""变量名那格的批注正文:把"这一格填什么"一次说清。"""
|
|
402
|
+
lines: list[str] = []
|
|
403
|
+
if variable.description:
|
|
404
|
+
lines.append(variable.description)
|
|
405
|
+
lines.append("")
|
|
406
|
+
if variable.is_derived:
|
|
407
|
+
lines.append(f"自动计算:{variable.derived}")
|
|
408
|
+
lines.append("不用手填;改了它引用的输入后会自动重算。")
|
|
409
|
+
else:
|
|
410
|
+
lines.append(f"填写位置:{where}")
|
|
411
|
+
if variable.unit:
|
|
412
|
+
lines.append(f"单位:{variable.unit}")
|
|
413
|
+
lines.append(f"类型:{variable.type}")
|
|
414
|
+
if variable.has_constraints:
|
|
415
|
+
lines.append(f"约束:{variable.constraint_text}")
|
|
416
|
+
if variable.prefix or variable.suffix:
|
|
417
|
+
lines.append(f"前缀 / 后缀:{variable.prefix!r} / {variable.suffix!r}")
|
|
418
|
+
if to_text(variable.default) != "":
|
|
419
|
+
lines.append(f"默认值:{to_text(variable.default)}")
|
|
420
|
+
lines.append(f"模板里引用:{{{{ {variable.name} }}}}")
|
|
421
|
+
return "\n".join(lines)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _attach_comment(cell, variable: VariableDef, *, where: str) -> None:
|
|
425
|
+
"""给"变量名"那一格加批注。
|
|
426
|
+
|
|
427
|
+
只加在名字格(A 列)而不是每个取值格:取值格已经有数据有效性的输入提示,
|
|
428
|
+
而 A 列是冻结的、永远可见 —— 鼠标一放就知道这是什么、该填什么、有没有约束。
|
|
429
|
+
"""
|
|
430
|
+
comment = Comment(_variable_comment(variable, where=where), "excel_codegen")
|
|
431
|
+
comment.width = 340
|
|
432
|
+
comment.height = 190
|
|
433
|
+
cell.comment = comment
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _relative_to(path: Path, base: Path, *, windows: bool) -> str:
|
|
437
|
+
"""把 ``path`` 表示成相对 ``base`` 的路径,并按目标平台换算分隔符。"""
|
|
438
|
+
import os
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
text = os.path.relpath(path, base)
|
|
442
|
+
except ValueError: # 跨盘符(Windows)时 relpath 会失败,退回绝对路径
|
|
443
|
+
text = str(path)
|
|
444
|
+
if windows:
|
|
445
|
+
return text.replace("/", "\\")
|
|
446
|
+
return text.replace("\\", "/")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _render_command(config: ProjectConfig, target: Path, *, windows: bool) -> str:
|
|
450
|
+
"""生成那条 render 命令(用 uv 优先,没装 uv 就退回 PATH 里的 excel-codegen)。"""
|
|
451
|
+
x_flag = target.name if target.parent else str(target)
|
|
452
|
+
if config.config_path is not None:
|
|
453
|
+
c_flag = _relative_to(config.config_path, target.parent or Path("."), windows=windows)
|
|
454
|
+
else: # 直接调库、没经过 load_config 时拿不到 YAML 路径:交给用户自己改
|
|
455
|
+
c_flag = "<你的配置>.yaml"
|
|
456
|
+
return f'render -c "{c_flag}" -x "{x_flag}" --write-excel'
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def write_run_scripts(config: ProjectConfig, target: Path) -> list[Path]:
|
|
460
|
+
"""在**工作簿旁边**生成 ``<工作簿名>_render.bat`` 与 ``<工作簿名>_render.sh``。
|
|
461
|
+
|
|
462
|
+
为什么要它:目标用户是工程师,不是终端爱好者。HOWTO 表里写了命令,但还得自己开终端敲;
|
|
463
|
+
双击脚本就能"改完参数 → 刷新 Output 表",而把 uv / venv 的差异封在脚本里。
|
|
464
|
+
|
|
465
|
+
两个平台都生成(不是只生成当前的):一本工作簿常常在 Windows 和 Linux 之间传来传去。
|
|
466
|
+
"""
|
|
467
|
+
directory = target.parent or Path(".")
|
|
468
|
+
stem = target.stem
|
|
469
|
+
command = _render_command(config, target, windows=False)
|
|
470
|
+
|
|
471
|
+
bat = f"""@echo off
|
|
472
|
+
REM ===========================================================================
|
|
473
|
+
REM 由 excel_codegen 生成 —— 改完参数双击本文件即可把结果写回 Output 表。
|
|
474
|
+
REM 重新生成工作簿(init)时会一并覆盖本文件。
|
|
475
|
+
REM ===========================================================================
|
|
476
|
+
cd /d "%~dp0"
|
|
477
|
+
|
|
478
|
+
where uv >nul 2>nul
|
|
479
|
+
if %errorlevel%==0 (
|
|
480
|
+
uv run excel-codegen {_render_command(config, target, windows=True)}
|
|
481
|
+
) else (
|
|
482
|
+
excel-codegen {_render_command(config, target, windows=True)}
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
echo.
|
|
486
|
+
if errorlevel 1 (
|
|
487
|
+
echo [失败] 上面有报错信息。常见原因:依赖没装(跑一次 setup.sh / uv sync)、
|
|
488
|
+
echo 或者 Excel 正开着这个文件(先关掉再试)。
|
|
489
|
+
) else (
|
|
490
|
+
echo [完成] 回到 Excel 打开「Output」表看结果。
|
|
491
|
+
)
|
|
492
|
+
pause
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
sh = f"""#!/usr/bin/env bash
|
|
496
|
+
# ===========================================================================
|
|
497
|
+
# 由 excel_codegen 生成 —— 改完参数跑一次本文件即可把结果写回 Output 表。
|
|
498
|
+
# 重新生成工作簿(init)时会一并覆盖本文件。
|
|
499
|
+
# ===========================================================================
|
|
500
|
+
set -uo pipefail
|
|
501
|
+
cd "$(dirname "$0")"
|
|
502
|
+
|
|
503
|
+
if command -v uv >/dev/null 2>&1; then
|
|
504
|
+
uv run excel-codegen {command}
|
|
505
|
+
else
|
|
506
|
+
excel-codegen {command}
|
|
507
|
+
fi
|
|
508
|
+
status=$?
|
|
509
|
+
|
|
510
|
+
echo
|
|
511
|
+
if [ "$status" -ne 0 ]; then
|
|
512
|
+
echo "[失败] 上面有报错信息。常见原因:依赖没装(跑一次 ./setup.sh 或 uv sync)、"
|
|
513
|
+
echo " 或者 Excel / WPS 正开着这个文件(先关掉再试)。"
|
|
514
|
+
else
|
|
515
|
+
echo "[完成] 回到 Excel 打开「Output」表看结果。"
|
|
516
|
+
fi
|
|
517
|
+
exit "$status"
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
written: list[Path] = []
|
|
521
|
+
for name, text in ((f"{stem}_render.bat", bat), (f"{stem}_render.sh", sh)):
|
|
522
|
+
path = directory / name
|
|
523
|
+
path.write_text(text, encoding="utf-8", newline="\r\n" if name.endswith(".bat") else "\n")
|
|
524
|
+
if name.endswith(".sh"):
|
|
525
|
+
path.chmod(path.stat().st_mode | 0o111) # 让 Linux/macOS 上可以直接 ./ 跑
|
|
526
|
+
written.append(path)
|
|
527
|
+
return written
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _build_group_sheet(
|
|
531
|
+
worksheet: Worksheet,
|
|
532
|
+
config: ProjectConfig,
|
|
533
|
+
members: Sequence[str],
|
|
534
|
+
*,
|
|
535
|
+
comments: bool = True,
|
|
536
|
+
) -> None:
|
|
537
|
+
"""建"成员表":**一行一个成员,B 列起一个变量一列**(表头是变量名)。
|
|
538
|
+
|
|
539
|
+
为什么不像 Local 那样"变量做行":工程师写舱容表就是一行一个舱;而且这样公式模式
|
|
540
|
+
能用与 global / local 同一形态的 ``INDEX/MATCH`` 定位(一次一维查找)。
|
|
541
|
+
"""
|
|
542
|
+
group = config.group
|
|
543
|
+
if group is None:
|
|
544
|
+
return
|
|
545
|
+
_write_headers(worksheet, [GROUP_HEADER, *group.names])
|
|
546
|
+
for offset, variable in enumerate(group.variables):
|
|
547
|
+
cell = worksheet.cell(row=1, column=_GROUP_FIRST_VAR_COLUMN + offset)
|
|
548
|
+
if comments:
|
|
549
|
+
_attach_comment(cell, variable, where=f"成员表({group.sheet}):一行一个成员")
|
|
550
|
+
|
|
551
|
+
for row, member in enumerate(members, start=2):
|
|
552
|
+
worksheet.cell(row=row, column=1, value=member)
|
|
553
|
+
for offset, variable in enumerate(group.variables):
|
|
554
|
+
cell = worksheet.cell(row=row, column=_GROUP_FIRST_VAR_COLUMN + offset, value=variable.default)
|
|
555
|
+
cell.fill = _INPUT_FILL
|
|
556
|
+
cell.alignment = _TOP_ALIGN
|
|
557
|
+
|
|
558
|
+
for offset, variable in enumerate(group.variables):
|
|
559
|
+
column = _GROUP_FIRST_VAR_COLUMN + offset
|
|
560
|
+
cells = [worksheet.cell(row=row, column=column).coordinate for row in range(2, len(members) + 2)]
|
|
561
|
+
_add_value_validation(worksheet, variable, cells)
|
|
562
|
+
|
|
563
|
+
worksheet.column_dimensions["A"].width = 24
|
|
564
|
+
for offset in range(len(group.variables)):
|
|
565
|
+
letter = column_index_to_letter(_GROUP_FIRST_VAR_COLUMN + offset)
|
|
566
|
+
worksheet.column_dimensions[letter].width = 20
|
|
567
|
+
worksheet.freeze_panes = "B2"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _add_value_validation(worksheet: Worksheet, variable: VariableDef, cells: Sequence[str]) -> None:
|
|
571
|
+
"""把变量声明的取值约束写成 Excel 的**数据有效性**(下拉列表 / 数值范围)。
|
|
572
|
+
|
|
573
|
+
这是"挡在输入口"的第一道闸,方便人填;**判据仍然是** :func:`check_value_constraints` ——
|
|
574
|
+
读回来的取值一律再查一遍。原因:数据有效性挡不住粘贴、脚本写入和别人发来的老文件,
|
|
575
|
+
而且我们允许空单元格(``allow_blank``),而工具侧认为"声明了约束就不许为空"。
|
|
576
|
+
|
|
577
|
+
约束本身表达不了时(下拉列表的选项里带逗号、或拼起来超过 Excel 的 255 字符上限)
|
|
578
|
+
直接报错 —— 与其写一个悄悄失效的校验,不如让人知道。
|
|
579
|
+
"""
|
|
580
|
+
if not cells or not variable.has_constraints:
|
|
581
|
+
return
|
|
582
|
+
if not variable.choices and variable.min is None and variable.max is None:
|
|
583
|
+
# 只有 pattern:Excel 的数据有效性没有正则,写不出有意义的校验。
|
|
584
|
+
# 不写总比写一个乱报错的强 —— pattern 由 check_value_constraints 在 render/validate 时检查。
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
if variable.choices:
|
|
588
|
+
allowed = [to_text(item) for item in variable.choices]
|
|
589
|
+
if any("," in item for item in allowed):
|
|
590
|
+
raise ExcelError(
|
|
591
|
+
f"变量 {variable.name!r} 的 choices 里有取值含逗号({', '.join(allowed)})—— "
|
|
592
|
+
"Excel 下拉列表用逗号分隔,表达不了;请改写取值或去掉 choices"
|
|
593
|
+
)
|
|
594
|
+
joined = ",".join(allowed)
|
|
595
|
+
if len(joined) > 255:
|
|
596
|
+
raise ExcelError(
|
|
597
|
+
f"变量 {variable.name!r} 的 choices 合计超过 Excel 下拉列表的 255 字符上限"
|
|
598
|
+
f"(当前 {len(joined)});请减少选项,或去掉 choices(取值仍会在 render / validate 时检查)"
|
|
599
|
+
)
|
|
600
|
+
validation = DataValidation(type="list", formula1=f'"{joined}"', allow_blank=True)
|
|
601
|
+
else:
|
|
602
|
+
operator: str
|
|
603
|
+
low: float | None
|
|
604
|
+
high: float | None
|
|
605
|
+
if variable.min is not None and variable.max is not None:
|
|
606
|
+
operator, low, high = "between", variable.min, variable.max
|
|
607
|
+
elif variable.min is not None:
|
|
608
|
+
operator, low, high = "greaterThanOrEqual", variable.min, None
|
|
609
|
+
else:
|
|
610
|
+
operator, low, high = "lessThanOrEqual", variable.max, None
|
|
611
|
+
validation = DataValidation(
|
|
612
|
+
type="whole" if variable.type == "int" else "decimal",
|
|
613
|
+
operator=operator,
|
|
614
|
+
formula1=to_text(low),
|
|
615
|
+
formula2=None if high is None else to_text(high),
|
|
616
|
+
allow_blank=True,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
prompt = variable.constraint_text
|
|
620
|
+
validation.promptTitle = variable.name
|
|
621
|
+
validation.prompt = prompt
|
|
622
|
+
validation.showInputMessage = True
|
|
623
|
+
validation.errorTitle = "取值不合规"
|
|
624
|
+
validation.error = f"{variable.name}:{prompt}"
|
|
625
|
+
validation.showErrorMessage = True
|
|
626
|
+
|
|
627
|
+
worksheet.add_data_validation(validation)
|
|
628
|
+
for coordinate in cells:
|
|
629
|
+
validation.add(coordinate)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# --------------------------------------------------------------------------- #
|
|
633
|
+
# 派生参数(derived:):参数表里那一格写成公式(能翻译时)或算好的值
|
|
634
|
+
# --------------------------------------------------------------------------- #
|
|
635
|
+
def _described(variable: VariableDef) -> str:
|
|
636
|
+
if not variable.is_derived:
|
|
637
|
+
return variable.description
|
|
638
|
+
expression = variable.derived or ""
|
|
639
|
+
note = f"(自动计算:{expression})"
|
|
640
|
+
return f"{variable.description} {note}".strip() if variable.description else note
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _global_resolver(config: ProjectConfig):
|
|
644
|
+
"""派生表达式里的变量名 -> Excel 引用(Global 表的取值列)。"""
|
|
645
|
+
sheet = config.excel.sheets.global_
|
|
646
|
+
globals_by_name = {item.name: item for item in config.global_variables}
|
|
647
|
+
|
|
648
|
+
def resolve(name: str) -> str:
|
|
649
|
+
definition = globals_by_name.get(name)
|
|
650
|
+
if definition is None:
|
|
651
|
+
raise DerivedError(
|
|
652
|
+
f"派生表达式引用了 {name!r}:global 的派生参数只能引用 global 变量(不能引用 local,也不存在别的表)"
|
|
653
|
+
)
|
|
654
|
+
return guarded_lookup(sheet, name, _GLOBAL_COL["value"], definition.default, absolute=True)
|
|
655
|
+
|
|
656
|
+
return resolve
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _local_resolver(config: ProjectConfig, case: CaseData):
|
|
660
|
+
"""派生表达式里的变量名 -> Excel 引用(本 Case 的那一格 / Global 取值列)。"""
|
|
661
|
+
local_sheet = config.excel.sheets.local
|
|
662
|
+
global_sheet = config.excel.sheets.global_
|
|
663
|
+
locals_by_name = {item.name: item for item in config.local_variables}
|
|
664
|
+
globals_by_name = {item.name: item for item in config.global_variables}
|
|
665
|
+
horizontal = config.excel.local_direction == "horizontal"
|
|
666
|
+
|
|
667
|
+
def resolve(name: str) -> str:
|
|
668
|
+
if name in locals_by_name:
|
|
669
|
+
definition = locals_by_name[name]
|
|
670
|
+
# 派生格里没有"拖动"语义:坐标锁死(参数表由工具维护)
|
|
671
|
+
if horizontal:
|
|
672
|
+
assert case.column is not None
|
|
673
|
+
expr = local_cell(local_sheet, name, column=case.column, absolute=True)
|
|
674
|
+
else:
|
|
675
|
+
assert case.row is not None
|
|
676
|
+
expr = local_cell(local_sheet, name, row=case.row, absolute=True)
|
|
677
|
+
return guard_default(expr, definition.default)
|
|
678
|
+
if name in globals_by_name:
|
|
679
|
+
return guarded_lookup(
|
|
680
|
+
global_sheet, name, _GLOBAL_COL["value"], globals_by_name[name].default, absolute=True
|
|
681
|
+
)
|
|
682
|
+
raise DerivedError(f"派生表达式引用了未定义的变量 {name!r}")
|
|
683
|
+
|
|
684
|
+
return resolve
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _write_derived_cell(cell, variable: VariableDef, *, resolve, value: Any = None) -> None:
|
|
688
|
+
"""写派生参数格:能翻译就写公式,否则写算好的值(没有值就先留空)。"""
|
|
689
|
+
cell.fill = _DERIVED_FILL
|
|
690
|
+
cell.alignment = _TOP_ALIGN
|
|
691
|
+
try:
|
|
692
|
+
formula = to_excel(variable.derived or "", name=variable.name, resolve=resolve)
|
|
693
|
+
except DerivedNotTranslatable:
|
|
694
|
+
formula = None
|
|
695
|
+
if formula is not None:
|
|
696
|
+
cell.value = "=" + formula
|
|
697
|
+
return
|
|
698
|
+
# 翻译不了:只能写入 Python 算好的值(没有就留空,等 --write-excel)
|
|
699
|
+
cell.value = value if value is not None else None
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _build_template_sheet(
|
|
703
|
+
worksheet: Worksheet,
|
|
704
|
+
config: ProjectConfig,
|
|
705
|
+
*,
|
|
706
|
+
base_dir: str | Path | None = None,
|
|
707
|
+
) -> None:
|
|
708
|
+
hint = worksheet.cell(
|
|
709
|
+
row=1,
|
|
710
|
+
column=1,
|
|
711
|
+
value="!! 只读参考:模板真源是 YAML / template_file;在这里改模板不会影响渲染结果",
|
|
712
|
+
)
|
|
713
|
+
hint.font = Font(bold=True, color="C00000")
|
|
714
|
+
row = 2
|
|
715
|
+
for template in config.templates:
|
|
716
|
+
title = worksheet.cell(row=row, column=1, value=f"### {template.name}")
|
|
717
|
+
title.font = _HEADER_FONT
|
|
718
|
+
row += 1
|
|
719
|
+
metadata = (
|
|
720
|
+
("output_sheet", template.output_sheet),
|
|
721
|
+
("start_cell", template.start_cell),
|
|
722
|
+
("direction", template.direction),
|
|
723
|
+
("filename", template.filename or ""),
|
|
724
|
+
("extension", template.extension),
|
|
725
|
+
("description", template.description),
|
|
726
|
+
)
|
|
727
|
+
for key, value in metadata:
|
|
728
|
+
worksheet.cell(row=row, column=1, value=key)
|
|
729
|
+
worksheet.cell(row=row, column=2, value=to_text(value))
|
|
730
|
+
row += 1
|
|
731
|
+
|
|
732
|
+
source = template.source_code
|
|
733
|
+
if template.template_file:
|
|
734
|
+
file_path = Path(template.template_file)
|
|
735
|
+
if base_dir and not file_path.is_absolute():
|
|
736
|
+
file_path = Path(base_dir) / file_path
|
|
737
|
+
try:
|
|
738
|
+
source = file_path.read_text(encoding="utf-8")
|
|
739
|
+
except OSError as exc:
|
|
740
|
+
source = f"<<无法读取模板文件 {file_path}: {exc}>>"
|
|
741
|
+
worksheet.cell(row=row, column=1, value="code ↓").font = _HEADER_FONT
|
|
742
|
+
row += 1
|
|
743
|
+
for line_number, line in enumerate(source.splitlines(), start=1):
|
|
744
|
+
worksheet.cell(row=row, column=1, value=line_number)
|
|
745
|
+
worksheet.cell(row=row, column=2, value=line)
|
|
746
|
+
row += 1
|
|
747
|
+
row += 1
|
|
748
|
+
|
|
749
|
+
worksheet.column_dimensions["A"].width = 12
|
|
750
|
+
worksheet.column_dimensions["B"].width = 110
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _write_headers(worksheet: Worksheet, headers: Sequence[str]) -> None:
|
|
754
|
+
for index, text in enumerate(headers, start=1):
|
|
755
|
+
cell = worksheet.cell(row=1, column=index, value=text)
|
|
756
|
+
cell.font = _HEADER_FONT
|
|
757
|
+
cell.alignment = Alignment(vertical="center", horizontal="center")
|
|
758
|
+
cell.fill = _CASE_HEADER_FILL if index >= FIRST_CASE_COLUMN else _HEADER_FILL
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# --------------------------------------------------------------------------- #
|
|
762
|
+
# HOWTO 表:把"下一步跑什么"写进工作簿本身
|
|
763
|
+
# --------------------------------------------------------------------------- #
|
|
764
|
+
_HOWTO_TITLE = Font(bold=True, size=14, color="1F3864")
|
|
765
|
+
_HOWTO_HEAD = Font(bold=True, size=11, color="1F3864")
|
|
766
|
+
_HOWTO_MONO = Font(name="Consolas", size=10)
|
|
767
|
+
_HOWTO_NOTE = Font(size=9, color="606060")
|
|
768
|
+
_HOWTO_WARN = Font(bold=True, size=10, color="C00000")
|
|
769
|
+
_HOWTO_FILL = PatternFill("solid", fgColor="FFF2CC")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def write_howto_sheet(
|
|
773
|
+
worksheet: Worksheet,
|
|
774
|
+
config: ProjectConfig,
|
|
775
|
+
*,
|
|
776
|
+
metadata: Mapping[str, str] | None = None,
|
|
777
|
+
command: str | None = None,
|
|
778
|
+
) -> None:
|
|
779
|
+
"""重写 HOWTO 表:三步说明 + 快照提醒 + 本次生成记录。"""
|
|
780
|
+
for row in worksheet.iter_rows(min_row=1, max_row=max(worksheet.max_row, 1), max_col=2):
|
|
781
|
+
for cell in row:
|
|
782
|
+
cell.value = None
|
|
783
|
+
|
|
784
|
+
lines: list[tuple[str, Font, PatternFill | None]] = []
|
|
785
|
+
add = lambda text, font, fill=None: lines.append((text, font, fill)) # noqa: E731
|
|
786
|
+
|
|
787
|
+
add(f"{config.excel.output} —— 参数填写与代码生成说明", _HOWTO_TITLE, None)
|
|
788
|
+
add("", _HOWTO_NOTE, None)
|
|
789
|
+
add("这个工作簿怎么用", _HOWTO_HEAD, None)
|
|
790
|
+
add(f" 1. {config.excel.sheets.global_} 的 B 列:全局变量取值(所有 Case 共用)。", _HOWTO_NOTE, None)
|
|
791
|
+
if config.excel.local_direction == "horizontal":
|
|
792
|
+
add(f" 2. {config.excel.sheets.local} 从 E 列起:每个 Case 一列,右拉复制即可增加。", _HOWTO_NOTE, None)
|
|
793
|
+
else:
|
|
794
|
+
add(f" 2. {config.excel.sheets.local} 从第 2 行起:每个 Case 一行,下拉复制即可增加。", _HOWTO_NOTE, None)
|
|
795
|
+
all_formula = bool(config.templates) and all(item.engine == "excel" for item in config.templates)
|
|
796
|
+
if all_formula:
|
|
797
|
+
# 全公式模式(默认):改完参数 Output 表自己就重算了,不需要跑命令 —— 别再让人白跑一趟
|
|
798
|
+
add(" 3. 改完参数直接看输出表:里面是公式,Excel 会自己重算,**不用跑任何命令**。", _HOWTO_NOTE, None)
|
|
799
|
+
add(" 只有『要把代码导成文件』时才回到命令行:", _HOWTO_NOTE, None)
|
|
800
|
+
add(
|
|
801
|
+
f" excel-codegen render -c <配置>.yaml -x {config.excel.output} --outdir <目录>",
|
|
802
|
+
_HOWTO_MONO,
|
|
803
|
+
_HOWTO_FILL,
|
|
804
|
+
)
|
|
805
|
+
else:
|
|
806
|
+
add(" 3. 改完参数后回到命令行执行:", _HOWTO_NOTE, None)
|
|
807
|
+
add(
|
|
808
|
+
f" excel-codegen render -c <配置>.yaml -x {config.excel.output} --write-excel",
|
|
809
|
+
_HOWTO_MONO,
|
|
810
|
+
_HOWTO_FILL,
|
|
811
|
+
)
|
|
812
|
+
add(" 只想导出代码文件就换成 --outdir <目录>;只预览不写回则什么参数都不加。", _HOWTO_NOTE, None)
|
|
813
|
+
if config.scripts_enabled:
|
|
814
|
+
stem = Path(config.excel.output).stem
|
|
815
|
+
add(
|
|
816
|
+
f" ★ 懒得开终端就双击本文件旁边的 {stem}_render.bat(Windows)"
|
|
817
|
+
f"或跑 {stem}_render.sh(Linux / macOS)。",
|
|
818
|
+
_HOWTO_NOTE,
|
|
819
|
+
None,
|
|
820
|
+
)
|
|
821
|
+
add(" 想确认表里的代码是不是已经过期:excel-codegen check -c <配置>.yaml", _HOWTO_NOTE, None)
|
|
822
|
+
add("", _HOWTO_NOTE, None)
|
|
823
|
+
formula_templates = [t for t in config.templates if t.engine == "excel"]
|
|
824
|
+
snapshot_templates = [t for t in config.templates if t.engine != "excel"]
|
|
825
|
+
if formula_templates:
|
|
826
|
+
add("★ 部分输出表是公式(engine: excel):改参数后 Excel 打开即重算,不用跑脚本", _HOWTO_HEAD, None)
|
|
827
|
+
add(
|
|
828
|
+
" 公式支持占位符替换与行内分支({% if %});要生成代码文件(--outdir)或做 CI 检查(check)仍需命令行。",
|
|
829
|
+
_HOWTO_NOTE,
|
|
830
|
+
None,
|
|
831
|
+
)
|
|
832
|
+
if snapshot_templates:
|
|
833
|
+
add("⚠ 部分输出表是「快照」,不是活公式", _HOWTO_WARN, None)
|
|
834
|
+
add(" 在 Excel 里改了参数、但没跑上面那条命令,输出表里的代码还是上一次的。", _HOWTO_NOTE, None)
|
|
835
|
+
add(" 不带 --write-excel 的 render 只预览,不会改变本文件。", _HOWTO_NOTE, None)
|
|
836
|
+
add("", _HOWTO_NOTE, None)
|
|
837
|
+
add("输出位置", _HOWTO_HEAD, None)
|
|
838
|
+
for template in config.templates:
|
|
839
|
+
where = f"{template.output_sheet} @ {template.start_cell} ({template.direction})"
|
|
840
|
+
engine = "公式·自动重算" if template.engine == "excel" else "快照·需重跑"
|
|
841
|
+
extra = f",case_filter: {template.case_filter}" if template.case_filter else ""
|
|
842
|
+
add(f" {template.name}: {where}[{engine}]{extra}", _HOWTO_MONO, None)
|
|
843
|
+
if config.excel.template_sheet:
|
|
844
|
+
add(f" {config.excel.template_sheet}(隐藏):模板原文,只读参考,改它不影响渲染结果。", _HOWTO_NOTE, None)
|
|
845
|
+
add("", _HOWTO_NOTE, None)
|
|
846
|
+
|
|
847
|
+
add("本次生成", _HOWTO_HEAD, None)
|
|
848
|
+
if metadata:
|
|
849
|
+
for key, value in metadata.items():
|
|
850
|
+
add(f" {key:<10}{value}", _HOWTO_NOTE, None)
|
|
851
|
+
if command:
|
|
852
|
+
add(f" {'命令':<8}{command}", _HOWTO_MONO, None)
|
|
853
|
+
else:
|
|
854
|
+
add(" (尚未渲染:本文件由 excel-codegen init 生成,还没有写回结果)", _HOWTO_WARN, None)
|
|
855
|
+
add("", _HOWTO_NOTE, None)
|
|
856
|
+
add("工况一览(来自上一次渲染)", _HOWTO_HEAD, None)
|
|
857
|
+
cases = (metadata or {}).get("cases") or "(未知)"
|
|
858
|
+
add(f" {cases}", _HOWTO_MONO, None)
|
|
859
|
+
|
|
860
|
+
for offset, (text, font, fill) in enumerate(lines, start=1):
|
|
861
|
+
cell = worksheet.cell(row=offset, column=1, value=text)
|
|
862
|
+
cell.font = font
|
|
863
|
+
cell.alignment = Alignment(vertical="top", wrap_text=False)
|
|
864
|
+
if fill is not None:
|
|
865
|
+
cell.fill = fill
|
|
866
|
+
worksheet.column_dimensions["A"].width = 110
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _meta_rows(metadata: Mapping[str, str]) -> list[tuple[str, str]]:
|
|
870
|
+
return [(key, to_text(value)) for key, value in metadata.items()]
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _write_meta_block(worksheet: Worksheet, metadata: Mapping[str, str]) -> None:
|
|
874
|
+
"""在隐藏 Template 表末尾写入/刷新机器可读元信息块。"""
|
|
875
|
+
marker_row = None
|
|
876
|
+
for row in range(1, worksheet.max_row + 1):
|
|
877
|
+
if to_text(worksheet.cell(row=row, column=1).value).strip() == META_MARKER:
|
|
878
|
+
marker_row = row
|
|
879
|
+
break
|
|
880
|
+
start = marker_row if marker_row else worksheet.max_row + 2
|
|
881
|
+
if marker_row:
|
|
882
|
+
for row in range(marker_row, worksheet.max_row + 1):
|
|
883
|
+
for column in (1, 2):
|
|
884
|
+
worksheet.cell(row=row, column=column).value = None
|
|
885
|
+
|
|
886
|
+
cell = worksheet.cell(row=start, column=1, value=META_MARKER)
|
|
887
|
+
cell.font = _HEADER_FONT
|
|
888
|
+
for offset, (key, value) in enumerate(_meta_rows(metadata), start=1):
|
|
889
|
+
worksheet.cell(row=start + offset, column=1, value=key)
|
|
890
|
+
worksheet.cell(row=start + offset, column=2, value=value)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def read_metadata(workbook: Workbook, config: ProjectConfig) -> dict[str, str]:
|
|
894
|
+
"""读回渲染元信息(时间 / 指纹 / 命令)。没写过则返回空字典。"""
|
|
895
|
+
sheet_name = config.excel.template_sheet
|
|
896
|
+
if sheet_name and sheet_name in workbook.sheetnames:
|
|
897
|
+
worksheet = workbook[sheet_name]
|
|
898
|
+
collecting = False
|
|
899
|
+
found: dict[str, str] = {}
|
|
900
|
+
for row in range(1, worksheet.max_row + 1):
|
|
901
|
+
key = to_text(worksheet.cell(row=row, column=1).value).strip()
|
|
902
|
+
value = to_text(worksheet.cell(row=row, column=2).value)
|
|
903
|
+
if key == META_MARKER:
|
|
904
|
+
collecting = True
|
|
905
|
+
continue
|
|
906
|
+
if collecting:
|
|
907
|
+
if key == "" and value == "":
|
|
908
|
+
break
|
|
909
|
+
if key:
|
|
910
|
+
found[key] = value
|
|
911
|
+
if found:
|
|
912
|
+
return found
|
|
913
|
+
|
|
914
|
+
# 退化路径:只有 HOWTO 表(template_sheet 被关掉)时,从人读的那几行里抠出来。
|
|
915
|
+
# 键名必须和 Template 表里的元信息块**完全一致**,否则调用方按 "参数指纹" 取值会取空。
|
|
916
|
+
howto_name = config.excel.howto_sheet
|
|
917
|
+
if howto_name and howto_name in workbook.sheetnames:
|
|
918
|
+
worksheet = workbook[howto_name]
|
|
919
|
+
found = {}
|
|
920
|
+
for row in range(1, worksheet.max_row + 1):
|
|
921
|
+
text = to_text(worksheet.cell(row=row, column=1).value).strip()
|
|
922
|
+
for label in (META_TIME, META_INPUT, META_OUTPUT):
|
|
923
|
+
if text.startswith(label):
|
|
924
|
+
found[label] = text[len(label) :].strip()
|
|
925
|
+
if found:
|
|
926
|
+
return found
|
|
927
|
+
return {}
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
# --------------------------------------------------------------------------- #
|
|
931
|
+
# 读取工作簿
|
|
932
|
+
# --------------------------------------------------------------------------- #
|
|
933
|
+
def load_workbook_file(path: str | Path) -> Workbook:
|
|
934
|
+
"""打开 Excel 文件,失败时给出可读的提示。"""
|
|
935
|
+
target = Path(path)
|
|
936
|
+
if not target.exists():
|
|
937
|
+
raise ExcelError(f"Excel 文件不存在: {target}(请先运行 `excel-codegen init` 生成模板)")
|
|
938
|
+
if target.is_dir():
|
|
939
|
+
raise ExcelError(f"路径是目录而不是 Excel 文件: {target}")
|
|
940
|
+
try:
|
|
941
|
+
return load_workbook(target)
|
|
942
|
+
except Exception as exc: # openpyxl 会抛各种异常类型
|
|
943
|
+
raise ExcelError(f"无法读取 Excel 文件 {target}: {exc}") from exc
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def get_sheet(workbook: Workbook, name: str) -> Worksheet:
|
|
947
|
+
if name not in workbook.sheetnames:
|
|
948
|
+
raise ExcelError(f"缺少工作表 {name!r}。当前工作表: {', '.join(workbook.sheetnames)}")
|
|
949
|
+
return workbook[name]
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def check_required_sheets(workbook: Workbook, config: ProjectConfig) -> None:
|
|
953
|
+
"""校验 Global / Local / Output 工作表是否存在,一次报出所有缺失项。"""
|
|
954
|
+
required = [
|
|
955
|
+
config.excel.sheets.global_,
|
|
956
|
+
config.excel.sheets.local,
|
|
957
|
+
*config.excel.sheets.outputs,
|
|
958
|
+
]
|
|
959
|
+
missing = [name for name in required if name not in workbook.sheetnames]
|
|
960
|
+
if missing:
|
|
961
|
+
raise ExcelError(
|
|
962
|
+
"Excel 缺少工作表: "
|
|
963
|
+
+ ", ".join(repr(name) for name in missing)
|
|
964
|
+
+ f"。当前工作表: {', '.join(workbook.sheetnames)}"
|
|
965
|
+
+ "(可重新运行 `excel-codegen init --force` 生成模板)"
|
|
966
|
+
)
|
|
967
|
+
_check_header(workbook[config.excel.sheets.global_], GLOBAL_HEADERS, config.excel.sheets.global_)
|
|
968
|
+
local_sheet = workbook[config.excel.sheets.local]
|
|
969
|
+
if config.excel.local_direction == "horizontal":
|
|
970
|
+
_check_header(local_sheet, LOCAL_HEADERS, config.excel.sheets.local, strict=False)
|
|
971
|
+
else:
|
|
972
|
+
# 纵向布局第 1 行整行都是变量名,只有 A1 是固定的
|
|
973
|
+
_check_header(local_sheet, (LOCAL_CASE_HEADER,), config.excel.sheets.local, strict=False)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _check_header(
|
|
977
|
+
worksheet: Worksheet,
|
|
978
|
+
expected: Sequence[str],
|
|
979
|
+
sheet_name: str,
|
|
980
|
+
*,
|
|
981
|
+
strict: bool = True,
|
|
982
|
+
) -> None:
|
|
983
|
+
for index, title in enumerate(expected, start=1):
|
|
984
|
+
actual = to_text(worksheet.cell(row=1, column=index).value).strip()
|
|
985
|
+
if actual and actual.lower() != title.lower():
|
|
986
|
+
raise ExcelError(f"工作表 {sheet_name!r} 第 1 行第 {index} 列表头应为 {title!r},实际是 {actual!r}")
|
|
987
|
+
if not actual and strict:
|
|
988
|
+
raise ExcelError(f"工作表 {sheet_name!r} 第 1 行第 {index} 列表头为空,应为 {title!r}")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def read_global_values(
|
|
992
|
+
workbook: Workbook,
|
|
993
|
+
config: ProjectConfig,
|
|
994
|
+
*,
|
|
995
|
+
warnings: list[str] | None = None,
|
|
996
|
+
) -> dict[str, VarValue]:
|
|
997
|
+
"""读取 Global Parameter 表:B 列值 + D/E 列前缀后缀;**派生参数在这里算出来**。
|
|
998
|
+
|
|
999
|
+
回落规则:**只有单元格真的为空才回落 YAML**;非空值原样使用(首尾空格有意义),
|
|
1000
|
+
所以 ``suffix: " m"`` 会得到 ``" m"`` 而不是 ``"m"``。
|
|
1001
|
+
|
|
1002
|
+
派生参数(``derived:``)的格子由工具写成公式 / 算好的值,这里**不读它**,
|
|
1003
|
+
而是用表达式现算(同 Case 的其他参数 + 全局参数)。
|
|
1004
|
+
"""
|
|
1005
|
+
worksheet = get_sheet(workbook, config.excel.sheets.global_)
|
|
1006
|
+
definitions = {item.name: item for item in config.global_variables}
|
|
1007
|
+
values: dict[str, VarValue] = {}
|
|
1008
|
+
raw_values: dict[str, Any] = {}
|
|
1009
|
+
seen: set[str] = set()
|
|
1010
|
+
|
|
1011
|
+
for row in range(2, worksheet.max_row + 1):
|
|
1012
|
+
name = to_text(worksheet.cell(row=row, column=_GLOBAL_COL["name"]).value).strip()
|
|
1013
|
+
if not name:
|
|
1014
|
+
continue
|
|
1015
|
+
if name in seen:
|
|
1016
|
+
raise ExcelError(f"工作表 {config.excel.sheets.global_!r} 第 {row} 行变量名 {name!r} 重复")
|
|
1017
|
+
seen.add(name)
|
|
1018
|
+
|
|
1019
|
+
definition = definitions.get(name)
|
|
1020
|
+
prefix = _text_or(
|
|
1021
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["prefix"]).value,
|
|
1022
|
+
definition.prefix if definition else "",
|
|
1023
|
+
)
|
|
1024
|
+
suffix = _text_or(
|
|
1025
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["suffix"]).value,
|
|
1026
|
+
definition.suffix if definition else "",
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
if definition is not None and definition.is_derived:
|
|
1030
|
+
# 派生格:留个占位,等输入读完之后统一算
|
|
1031
|
+
values[name] = VarValue("", prefix, suffix)
|
|
1032
|
+
continue
|
|
1033
|
+
|
|
1034
|
+
raw_value = _cell_or(
|
|
1035
|
+
worksheet.cell(row=row, column=_GLOBAL_COL["value"]).value,
|
|
1036
|
+
definition.default if definition else "",
|
|
1037
|
+
)
|
|
1038
|
+
kind = definition.type if definition else "auto"
|
|
1039
|
+
coerced = _coerce(raw_value, kind, name)
|
|
1040
|
+
raw_values[name] = coerced
|
|
1041
|
+
values[name] = VarValue(coerced, prefix, suffix)
|
|
1042
|
+
|
|
1043
|
+
# YAML 中定义但表里没写的变量,用默认值补齐,保证模板引用不报错。
|
|
1044
|
+
for definition in config.global_variables:
|
|
1045
|
+
if definition.name in values:
|
|
1046
|
+
continue
|
|
1047
|
+
values[definition.name] = VarValue(definition.default, definition.prefix, definition.suffix)
|
|
1048
|
+
if not definition.is_derived:
|
|
1049
|
+
raw_values[definition.name] = _coerce(definition.default, definition.type, definition.name)
|
|
1050
|
+
|
|
1051
|
+
_resolve_derived_globals(worksheet, config, values, raw_values, warnings)
|
|
1052
|
+
return values
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _resolve_derived_globals(
|
|
1056
|
+
worksheet: Worksheet,
|
|
1057
|
+
config: ProjectConfig,
|
|
1058
|
+
values: dict[str, VarValue],
|
|
1059
|
+
raw_values: dict[str, Any],
|
|
1060
|
+
warnings: list[str] | None,
|
|
1061
|
+
) -> None:
|
|
1062
|
+
"""把 global 的派生参数算出来(按依赖顺序),并检查格子里有没有被手工改过。"""
|
|
1063
|
+
derived = [item for item in config.global_variables if item.is_derived]
|
|
1064
|
+
if not derived:
|
|
1065
|
+
return
|
|
1066
|
+
evaluate_derived(
|
|
1067
|
+
config.global_variables,
|
|
1068
|
+
raw_values,
|
|
1069
|
+
scope="global 的派生参数",
|
|
1070
|
+
forbidden={
|
|
1071
|
+
item.name: "global 的派生参数不能引用 local 变量(那时还没有当前 Case)" for item in config.local_variables
|
|
1072
|
+
},
|
|
1073
|
+
)
|
|
1074
|
+
_warn_hand_edited_derived_global(worksheet, derived, _GLOBAL_COL["value"], raw_values, warnings, label="Global 表")
|
|
1075
|
+
for definition in derived:
|
|
1076
|
+
current = values.get(definition.name, VarValue(""))
|
|
1077
|
+
computed = _coerce(raw_values[definition.name], definition.type, definition.name)
|
|
1078
|
+
values[definition.name] = VarValue(computed, current.prefix, current.suffix)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _warn_hand_edited_derived_global(
|
|
1082
|
+
worksheet: Worksheet,
|
|
1083
|
+
derived: Sequence[VariableDef],
|
|
1084
|
+
value_column: int,
|
|
1085
|
+
raw_values: Mapping[str, Any],
|
|
1086
|
+
warnings: list[str] | None,
|
|
1087
|
+
*,
|
|
1088
|
+
label: str,
|
|
1089
|
+
) -> None:
|
|
1090
|
+
"""Global 表专用:变量永远在行上,派生格固定在 ``value_column`` 列。"""
|
|
1091
|
+
if warnings is None:
|
|
1092
|
+
return
|
|
1093
|
+
rows = {
|
|
1094
|
+
to_text(worksheet.cell(row=row, column=_GLOBAL_COL["name"]).value).strip(): row
|
|
1095
|
+
for row in range(2, worksheet.max_row + 1)
|
|
1096
|
+
}
|
|
1097
|
+
for definition in derived:
|
|
1098
|
+
row = rows.get(definition.name)
|
|
1099
|
+
if row is None:
|
|
1100
|
+
continue
|
|
1101
|
+
raw = worksheet.cell(row=row, column=value_column).value
|
|
1102
|
+
if isinstance(raw, str) and raw.startswith("="):
|
|
1103
|
+
continue # 正常的公式格
|
|
1104
|
+
if is_translatable(definition):
|
|
1105
|
+
warnings.append(
|
|
1106
|
+
f"{label} 的派生参数 {definition.name!r} 的格子被手工改成了 {raw!r},"
|
|
1107
|
+
"工具会忽略它(该格由表达式算出来;重跑 --write-excel 会把它改回公式)"
|
|
1108
|
+
)
|
|
1109
|
+
elif definition.name in raw_values and to_text(raw_values[definition.name]) != to_text(raw):
|
|
1110
|
+
warnings.append(
|
|
1111
|
+
f"{label} 的派生参数 {definition.name!r} 表里是 {raw!r},当前算式应为 "
|
|
1112
|
+
f"{to_text(raw_values[definition.name])!r} —— 请重跑 --write-excel 刷新"
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _warn_hand_edited_derived(
|
|
1117
|
+
worksheet: Worksheet,
|
|
1118
|
+
config: ProjectConfig,
|
|
1119
|
+
derived: Sequence[VariableDef],
|
|
1120
|
+
case: CaseData,
|
|
1121
|
+
raw_values: Mapping[str, Any],
|
|
1122
|
+
warnings: list[str] | None,
|
|
1123
|
+
*,
|
|
1124
|
+
label: str,
|
|
1125
|
+
) -> None:
|
|
1126
|
+
"""派生格是公式(或工具写的值)—— 如果用户手工改成了别的值,提醒他会被忽略。"""
|
|
1127
|
+
if warnings is None:
|
|
1128
|
+
return
|
|
1129
|
+
slots = {slot.name: slot for slot in _local_slots(worksheet, config)}
|
|
1130
|
+
for definition in derived:
|
|
1131
|
+
slot = slots.get(definition.name)
|
|
1132
|
+
if slot is None:
|
|
1133
|
+
continue
|
|
1134
|
+
raw = _case_cell(worksheet, config, slot, case).value
|
|
1135
|
+
if isinstance(raw, str) and raw.startswith("="):
|
|
1136
|
+
continue # 正常的公式格
|
|
1137
|
+
if is_translatable(definition):
|
|
1138
|
+
warnings.append(
|
|
1139
|
+
f"{label} 的派生参数 {definition.name!r} 的格子被手工改成了 {raw!r},"
|
|
1140
|
+
"工具会忽略它(该格由表达式算出来;重跑 --write-excel 会把它改回公式)"
|
|
1141
|
+
)
|
|
1142
|
+
elif definition.name in raw_values and to_text(raw_values[definition.name]) != to_text(raw):
|
|
1143
|
+
warnings.append(
|
|
1144
|
+
f"{label} 的派生参数 {definition.name!r} 表里是 {raw!r},当前算式应为 "
|
|
1145
|
+
f"{to_text(raw_values[definition.name])!r} —— 请重跑 --write-excel 刷新"
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def read_cases(
|
|
1150
|
+
workbook: Workbook,
|
|
1151
|
+
config: ProjectConfig,
|
|
1152
|
+
*,
|
|
1153
|
+
global_values: Mapping[str, VarValue] | None = None,
|
|
1154
|
+
warnings: list[str] | None = None,
|
|
1155
|
+
) -> list[CaseData]:
|
|
1156
|
+
"""读取 Local Parameter 表里的工况;**派生参数按 Case 算出来**。
|
|
1157
|
+
|
|
1158
|
+
两种布局由 ``excel.local_direction`` 决定:
|
|
1159
|
+
|
|
1160
|
+
* ``horizontal``(默认):一个工况**一列**(E1 起写 Case 名),变量在行上;
|
|
1161
|
+
* ``vertical``:一个工况**一行**(A2 起写 Case 名),变量在列上。
|
|
1162
|
+
|
|
1163
|
+
两种布局都是"遇到空表头就停",所以右拉 / 下拉就能加工况。
|
|
1164
|
+
"""
|
|
1165
|
+
worksheet = get_sheet(workbook, config.excel.sheets.local)
|
|
1166
|
+
sheet_name = config.excel.sheets.local
|
|
1167
|
+
if global_values is None:
|
|
1168
|
+
global_values = read_global_values(workbook, config, warnings=warnings)
|
|
1169
|
+
global_raw = {name: value.value for name, value in global_values.items()}
|
|
1170
|
+
|
|
1171
|
+
horizontal = config.excel.local_direction == "horizontal"
|
|
1172
|
+
anchors = _case_anchors(worksheet, config)
|
|
1173
|
+
if not anchors:
|
|
1174
|
+
hint = (
|
|
1175
|
+
"请在 E1 填写 Case1、F1 填写 Case2 …(可右拉复制列)"
|
|
1176
|
+
if horizontal
|
|
1177
|
+
else "请在 A2 填写 Case1、A3 填写 Case2 …(可下拉复制行)"
|
|
1178
|
+
)
|
|
1179
|
+
raise ExcelError(f"工作表 {sheet_name!r} 里没有工况。{hint}")
|
|
1180
|
+
|
|
1181
|
+
slots = _local_slots(worksheet, config)
|
|
1182
|
+
if not slots:
|
|
1183
|
+
raise ExcelError(
|
|
1184
|
+
f"工作表 {sheet_name!r} 没有定义任何局部变量 —— "
|
|
1185
|
+
"本工具用「局部变量 × 工况」定位计算,所以 variables.local 至少要有一个变量"
|
|
1186
|
+
"(哪怕只是个标注用的 kind)"
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
cases: list[CaseData] = []
|
|
1190
|
+
for anchor in anchors:
|
|
1191
|
+
values: dict[str, VarValue] = {}
|
|
1192
|
+
raw_values: dict[str, Any] = dict(global_raw) # 局部派生可以引用全局
|
|
1193
|
+
explicit = False
|
|
1194
|
+
for slot in slots:
|
|
1195
|
+
if slot.definition is not None and slot.definition.is_derived:
|
|
1196
|
+
values[slot.name] = VarValue("", slot.prefix, slot.suffix)
|
|
1197
|
+
continue
|
|
1198
|
+
cell = _case_cell(worksheet, config, slot, anchor)
|
|
1199
|
+
if cell.value is not None and not (isinstance(cell.value, str) and cell.value.strip() == ""):
|
|
1200
|
+
explicit = True
|
|
1201
|
+
raw_value = _cell_or(cell.value, slot.definition.default if slot.definition else "")
|
|
1202
|
+
kind = slot.definition.type if slot.definition else "auto"
|
|
1203
|
+
coerced = _coerce(raw_value, kind, f"{anchor.name}.{slot.name}")
|
|
1204
|
+
raw_values[slot.name] = coerced
|
|
1205
|
+
values[slot.name] = VarValue(coerced, slot.prefix, slot.suffix)
|
|
1206
|
+
|
|
1207
|
+
# YAML 中定义但表里缺少的局部变量,用默认值补齐(派生参数稍后算)
|
|
1208
|
+
for definition in config.local_variables:
|
|
1209
|
+
if definition.name in values:
|
|
1210
|
+
continue
|
|
1211
|
+
values[definition.name] = VarValue(definition.default, definition.prefix, definition.suffix)
|
|
1212
|
+
if not definition.is_derived:
|
|
1213
|
+
raw_values[definition.name] = _coerce(definition.default, definition.type, definition.name)
|
|
1214
|
+
|
|
1215
|
+
_resolve_derived_locals(worksheet, config, anchor, values, raw_values, warnings)
|
|
1216
|
+
cases.append(
|
|
1217
|
+
CaseData(
|
|
1218
|
+
name=anchor.name,
|
|
1219
|
+
column=anchor.column,
|
|
1220
|
+
row=anchor.row,
|
|
1221
|
+
values=values,
|
|
1222
|
+
explicit_values=explicit,
|
|
1223
|
+
)
|
|
1224
|
+
)
|
|
1225
|
+
return cases
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _resolve_derived_locals(
|
|
1229
|
+
worksheet: Worksheet,
|
|
1230
|
+
config: ProjectConfig,
|
|
1231
|
+
case: CaseData,
|
|
1232
|
+
values: dict[str, VarValue],
|
|
1233
|
+
raw_values: dict[str, Any],
|
|
1234
|
+
warnings: list[str] | None,
|
|
1235
|
+
) -> None:
|
|
1236
|
+
derived = [item for item in config.local_variables if item.is_derived]
|
|
1237
|
+
if not derived:
|
|
1238
|
+
return
|
|
1239
|
+
evaluate_derived(
|
|
1240
|
+
config.local_variables,
|
|
1241
|
+
raw_values,
|
|
1242
|
+
scope=f"Case {case.name!r} 的 local 派生参数",
|
|
1243
|
+
)
|
|
1244
|
+
_warn_hand_edited_derived(
|
|
1245
|
+
worksheet,
|
|
1246
|
+
config,
|
|
1247
|
+
derived,
|
|
1248
|
+
case,
|
|
1249
|
+
raw_values,
|
|
1250
|
+
warnings,
|
|
1251
|
+
label=f"Local 表 Case {case.name!r}",
|
|
1252
|
+
)
|
|
1253
|
+
for definition in derived:
|
|
1254
|
+
current = values.get(definition.name, VarValue(""))
|
|
1255
|
+
computed = _coerce(raw_values[definition.name], definition.type, definition.name)
|
|
1256
|
+
values[definition.name] = VarValue(computed, current.prefix, current.suffix)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def read_group_members(
|
|
1260
|
+
workbook: Workbook,
|
|
1261
|
+
config: ProjectConfig,
|
|
1262
|
+
*,
|
|
1263
|
+
warnings: list[str] | None = None,
|
|
1264
|
+
) -> dict[str, dict[str, VarValue]]:
|
|
1265
|
+
"""读成员表(第三层作用域):**一行一个成员,B 列起一个变量一列**。
|
|
1266
|
+
|
|
1267
|
+
:returns: ``{成员名: {变量名: VarValue}}``;没配 ``variables.group`` 时返回空字典。
|
|
1268
|
+
"""
|
|
1269
|
+
group = config.group
|
|
1270
|
+
if group is None:
|
|
1271
|
+
return {}
|
|
1272
|
+
sheet = group.sheet
|
|
1273
|
+
worksheet = get_sheet(workbook, sheet)
|
|
1274
|
+
|
|
1275
|
+
# 表头:B 列起是变量名(遇到空表头就停)
|
|
1276
|
+
columns: dict[str, int] = {}
|
|
1277
|
+
for column in range(_GROUP_FIRST_VAR_COLUMN, worksheet.max_column + 1):
|
|
1278
|
+
name = to_text(worksheet.cell(row=1, column=column).value).strip()
|
|
1279
|
+
if not name:
|
|
1280
|
+
break
|
|
1281
|
+
if name in columns:
|
|
1282
|
+
raise ExcelError(f"成员表 {sheet!r} 第 1 行表头 {name!r} 重复")
|
|
1283
|
+
columns[name] = column
|
|
1284
|
+
defined = {variable.name: variable for variable in group.variables}
|
|
1285
|
+
missing = [name for name in defined if name not in columns]
|
|
1286
|
+
if missing:
|
|
1287
|
+
raise ExcelError(
|
|
1288
|
+
f"成员表 {sheet!r} 缺少这些变量的列: {', '.join(missing)}"
|
|
1289
|
+
"(表头要写变量名;改了 YAML 的 group.variables 之后要重跑 init / 手工补列)"
|
|
1290
|
+
)
|
|
1291
|
+
unknown = [name for name in columns if name not in defined]
|
|
1292
|
+
if unknown and warnings is not None:
|
|
1293
|
+
warnings.append(f"成员表 {sheet!r} 里有 YAML 未定义的列: {', '.join(unknown)}(会被读进上下文,按 auto 类型)")
|
|
1294
|
+
|
|
1295
|
+
members: dict[str, dict[str, VarValue]] = {}
|
|
1296
|
+
for row in range(2, worksheet.max_row + 1):
|
|
1297
|
+
name = to_text(worksheet.cell(row=row, column=1).value).strip()
|
|
1298
|
+
if not name:
|
|
1299
|
+
continue
|
|
1300
|
+
if name in members:
|
|
1301
|
+
raise ExcelError(f"成员表 {sheet!r} 第 {row} 行成员名 {name!r} 重复")
|
|
1302
|
+
values: dict[str, VarValue] = {}
|
|
1303
|
+
for column_name, column in columns.items():
|
|
1304
|
+
definition = defined.get(column_name)
|
|
1305
|
+
raw = worksheet.cell(row=row, column=column).value
|
|
1306
|
+
value = _cell_or(raw, definition.default if definition else "")
|
|
1307
|
+
kind = definition.type if definition else "auto"
|
|
1308
|
+
values[column_name] = VarValue(
|
|
1309
|
+
_coerce(value, kind, f"{name}.{column_name}"),
|
|
1310
|
+
definition.prefix if definition else "",
|
|
1311
|
+
definition.suffix if definition else "",
|
|
1312
|
+
)
|
|
1313
|
+
members[name] = values
|
|
1314
|
+
return members
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
@dataclass(frozen=True)
|
|
1318
|
+
class _VarSlot:
|
|
1319
|
+
"""Local 表里一个局部变量的位置。
|
|
1320
|
+
|
|
1321
|
+
``axis`` 的含义随布局而变:横向布局是**行号**(名字在 A 列),纵向布局是**列号**(名字在第 1 行)。
|
|
1322
|
+
"""
|
|
1323
|
+
|
|
1324
|
+
name: str
|
|
1325
|
+
definition: VariableDef | None
|
|
1326
|
+
prefix: str
|
|
1327
|
+
suffix: str
|
|
1328
|
+
axis: int
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _local_slots(worksheet: Worksheet, config: ProjectConfig) -> list[_VarSlot]:
|
|
1332
|
+
"""扫出 Local 表里的变量槽。
|
|
1333
|
+
|
|
1334
|
+
横向布局:A 列从第 2 行起写变量名,C/D 列可覆盖 Prefix/Suffix(与 YAML 一致的老行为)。
|
|
1335
|
+
纵向布局:第 1 行从 B 列起写变量名(A1 是 "Case"),**没有** Prefix/Suffix 列 ——
|
|
1336
|
+
它们只来自 YAML(表头格有批注写着)。
|
|
1337
|
+
"""
|
|
1338
|
+
sheet_name = config.excel.sheets.local
|
|
1339
|
+
definitions = {item.name: item for item in config.local_variables}
|
|
1340
|
+
slots: list[_VarSlot] = []
|
|
1341
|
+
seen: set[str] = set()
|
|
1342
|
+
|
|
1343
|
+
if config.excel.local_direction == "horizontal":
|
|
1344
|
+
candidates = [(row, _LOCAL_COL["name"]) for row in range(2, worksheet.max_row + 1)]
|
|
1345
|
+
else:
|
|
1346
|
+
candidates = [(1, column) for column in range(_GROUP_FIRST_VAR_COLUMN, worksheet.max_column + 1)]
|
|
1347
|
+
|
|
1348
|
+
for row, column in candidates:
|
|
1349
|
+
name = to_text(worksheet.cell(row=row, column=column).value).strip()
|
|
1350
|
+
if not name:
|
|
1351
|
+
continue
|
|
1352
|
+
if name in seen:
|
|
1353
|
+
where = f"第 {row} 行" if config.excel.local_direction == "horizontal" else f"第 {column} 列"
|
|
1354
|
+
raise ExcelError(f"工作表 {sheet_name!r} {where}变量名 {name!r} 重复")
|
|
1355
|
+
seen.add(name)
|
|
1356
|
+
definition = definitions.get(name)
|
|
1357
|
+
if config.excel.local_direction == "horizontal":
|
|
1358
|
+
prefix = _text_or(
|
|
1359
|
+
worksheet.cell(row=row, column=_LOCAL_COL["prefix"]).value,
|
|
1360
|
+
definition.prefix if definition else "",
|
|
1361
|
+
)
|
|
1362
|
+
suffix = _text_or(
|
|
1363
|
+
worksheet.cell(row=row, column=_LOCAL_COL["suffix"]).value,
|
|
1364
|
+
definition.suffix if definition else "",
|
|
1365
|
+
)
|
|
1366
|
+
axis = row
|
|
1367
|
+
else:
|
|
1368
|
+
prefix = definition.prefix if definition else ""
|
|
1369
|
+
suffix = definition.suffix if definition else ""
|
|
1370
|
+
axis = column
|
|
1371
|
+
slots.append(_VarSlot(name, definition, prefix, suffix, axis))
|
|
1372
|
+
return slots
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _case_cell(worksheet: Worksheet, config: ProjectConfig, slot: _VarSlot, case: CaseData):
|
|
1376
|
+
"""某个变量在某个 Case 上的那格(两种布局各取一个坐标)。"""
|
|
1377
|
+
if config.excel.local_direction == "horizontal":
|
|
1378
|
+
assert case.column is not None
|
|
1379
|
+
return worksheet.cell(row=slot.axis, column=case.column)
|
|
1380
|
+
assert case.row is not None
|
|
1381
|
+
return worksheet.cell(row=case.row, column=slot.axis)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _case_anchors(worksheet: Worksheet, config: ProjectConfig) -> list[CaseData]:
|
|
1385
|
+
"""Local 表里的工况位置(只读名字与位置,不读取值)。
|
|
1386
|
+
|
|
1387
|
+
* ``horizontal``(默认):一个工况一列,名字在**第 1 行**从 E 列起;
|
|
1388
|
+
* ``vertical``:一个工况一行,名字在 **A 列**从第 2 行起。
|
|
1389
|
+
|
|
1390
|
+
两种布局都是"遇到空表头就停",所以右拉 / 下拉加一列 / 一行即可增工况。
|
|
1391
|
+
"""
|
|
1392
|
+
sheet_name = config.excel.sheets.local
|
|
1393
|
+
anchors: list[CaseData] = []
|
|
1394
|
+
seen: set[str] = set()
|
|
1395
|
+
|
|
1396
|
+
def take(name: str, *, column: int | None, row: int | None, position: str) -> None:
|
|
1397
|
+
if name in seen:
|
|
1398
|
+
raise ExcelError(f"工作表 {sheet_name!r} 的 Case 名重复: {name!r}")
|
|
1399
|
+
seen.add(name)
|
|
1400
|
+
anchors.append(CaseData(name=name, column=column, row=row, values={}))
|
|
1401
|
+
del position
|
|
1402
|
+
|
|
1403
|
+
if config.excel.local_direction == "horizontal":
|
|
1404
|
+
upper = max(worksheet.max_column, FIRST_CASE_COLUMN)
|
|
1405
|
+
for column in range(FIRST_CASE_COLUMN, upper + 1):
|
|
1406
|
+
name = to_text(worksheet.cell(row=1, column=column).value).strip()
|
|
1407
|
+
if not name:
|
|
1408
|
+
if anchors: # 遇到空列说明 Case 列已经结束
|
|
1409
|
+
break
|
|
1410
|
+
continue
|
|
1411
|
+
take(name, column=column, row=None, position=f"第 {column} 列")
|
|
1412
|
+
else:
|
|
1413
|
+
for row in range(2, worksheet.max_row + 1):
|
|
1414
|
+
name = to_text(worksheet.cell(row=row, column=1).value).strip()
|
|
1415
|
+
if not name:
|
|
1416
|
+
if anchors: # 遇到空行说明 Case 行已经结束
|
|
1417
|
+
break
|
|
1418
|
+
continue
|
|
1419
|
+
take(name, column=None, row=row, position=f"第 {row} 行")
|
|
1420
|
+
return anchors
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _coerce(value: Any, kind: str, label: str) -> Any:
|
|
1424
|
+
if value is None or kind in ("auto", "raw"):
|
|
1425
|
+
return value
|
|
1426
|
+
if kind == "string":
|
|
1427
|
+
return to_text(value)
|
|
1428
|
+
text = value.strip() if isinstance(value, str) else value
|
|
1429
|
+
if kind == "int":
|
|
1430
|
+
try:
|
|
1431
|
+
return int(float(text))
|
|
1432
|
+
except (TypeError, ValueError):
|
|
1433
|
+
raise ExcelError(f"变量 {label} 的值 {value!r} 无法转换为 int") from None
|
|
1434
|
+
if kind == "float":
|
|
1435
|
+
try:
|
|
1436
|
+
return float(text)
|
|
1437
|
+
except (TypeError, ValueError):
|
|
1438
|
+
raise ExcelError(f"变量 {label} 的值 {value!r} 无法转换为 float") from None
|
|
1439
|
+
if kind == "bool":
|
|
1440
|
+
if isinstance(value, bool):
|
|
1441
|
+
return value
|
|
1442
|
+
text_value = to_text(value).strip().lower()
|
|
1443
|
+
if text_value in {"1", "true", "yes", "y", "on"}:
|
|
1444
|
+
return True
|
|
1445
|
+
if text_value in {"0", "false", "no", "n", "off", ""}:
|
|
1446
|
+
return False
|
|
1447
|
+
raise ExcelError(f"变量 {label} 的值 {value!r} 无法转换为 bool")
|
|
1448
|
+
return value
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def check_value_constraints(
|
|
1452
|
+
config: ProjectConfig,
|
|
1453
|
+
global_values: Mapping[str, VarValue],
|
|
1454
|
+
cases: Sequence[CaseData],
|
|
1455
|
+
members: Mapping[str, Mapping[str, VarValue]] | None = None,
|
|
1456
|
+
) -> None:
|
|
1457
|
+
"""校验表里填的取值是否满足变量声明的 ``min`` / ``max`` / ``choices`` / ``pattern``。
|
|
1458
|
+
|
|
1459
|
+
为什么要它:工具此前只查"变量有没有定义、类型对不对",**完全不看值** —— 把
|
|
1460
|
+
``20.559`` 手滑打成 ``205.59`` 会一路渲染成错误代码,而 ``check`` 只会说"与参数一致"。
|
|
1461
|
+
声明了约束就一定查,**空值也算不合格**(``choices`` 意味着"必须给一个合法取值")。
|
|
1462
|
+
|
|
1463
|
+
不满足时抛 :class:`ExcelError`,一次列全部问题并指出是哪张表、哪一列、哪个变量。
|
|
1464
|
+
"""
|
|
1465
|
+
problems: list[str] = []
|
|
1466
|
+
global_sheet = config.excel.sheets.global_
|
|
1467
|
+
|
|
1468
|
+
for row, variable in enumerate(config.global_variables, start=2):
|
|
1469
|
+
if variable.is_derived or not variable.has_constraints:
|
|
1470
|
+
continue
|
|
1471
|
+
value = global_values.get(variable.name)
|
|
1472
|
+
if value is None:
|
|
1473
|
+
continue
|
|
1474
|
+
problem = variable.value_problem(value.text)
|
|
1475
|
+
if problem:
|
|
1476
|
+
problems.append(f" {global_sheet} 第 {row} 行 '{variable.name}':{problem}")
|
|
1477
|
+
|
|
1478
|
+
local_sheet = config.excel.sheets.local
|
|
1479
|
+
for case in cases:
|
|
1480
|
+
for variable in config.local_variables:
|
|
1481
|
+
if variable.is_derived or not variable.has_constraints:
|
|
1482
|
+
continue
|
|
1483
|
+
value = case.values.get(variable.name)
|
|
1484
|
+
if value is None:
|
|
1485
|
+
continue
|
|
1486
|
+
problem = variable.value_problem(value.text)
|
|
1487
|
+
if problem:
|
|
1488
|
+
problems.append(f" {local_sheet} {case.where} '{case.name}' 的 '{variable.name}':{problem}")
|
|
1489
|
+
|
|
1490
|
+
# 成员表(第三层作用域):一个成员一行
|
|
1491
|
+
if members:
|
|
1492
|
+
for member, values in members.items():
|
|
1493
|
+
for variable in config.group_variables:
|
|
1494
|
+
if not variable.has_constraints:
|
|
1495
|
+
continue
|
|
1496
|
+
value = values.get(variable.name)
|
|
1497
|
+
if value is None:
|
|
1498
|
+
continue
|
|
1499
|
+
problem = variable.value_problem(value.text)
|
|
1500
|
+
if problem:
|
|
1501
|
+
problems.append(f" {config.group.sheet} 成员 '{member}' 的 '{variable.name}':{problem}")
|
|
1502
|
+
|
|
1503
|
+
if problems:
|
|
1504
|
+
raise ExcelError(
|
|
1505
|
+
f"参数取值不满足变量声明的约束,共 {len(problems)} 处:\n"
|
|
1506
|
+
+ "\n".join(problems)
|
|
1507
|
+
+ "\n → 改 Excel 里的取值,或放宽 YAML 里的 min / max / choices / pattern"
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
# --------------------------------------------------------------------------- #
|
|
1512
|
+
# 写回渲染结果
|
|
1513
|
+
# --------------------------------------------------------------------------- #
|
|
1514
|
+
def input_fingerprint(
|
|
1515
|
+
global_values: Mapping[str, VarValue],
|
|
1516
|
+
cases: Sequence[CaseData],
|
|
1517
|
+
) -> str:
|
|
1518
|
+
"""参数指纹:Global 取值 + 每个 Case 的取值(前缀/后缀也算参数)。"""
|
|
1519
|
+
parts: list[str] = []
|
|
1520
|
+
for name, value in global_values.items():
|
|
1521
|
+
parts.append(f"G|{name}|{value.prefix}|{value.text}|{value.suffix}")
|
|
1522
|
+
for case in cases:
|
|
1523
|
+
for name, value in case.values.items():
|
|
1524
|
+
parts.append(f"L|{case.name}|{name}|{value.prefix}|{value.text}|{value.suffix}")
|
|
1525
|
+
return fingerprint(*parts)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def output_fingerprint(rendered: Mapping[str, Sequence[RenderResult]]) -> str:
|
|
1529
|
+
"""输出指纹:所有模板 × Case 的渲染文本。"""
|
|
1530
|
+
parts: list[str] = []
|
|
1531
|
+
for template_name, per_case in rendered.items():
|
|
1532
|
+
for result in per_case:
|
|
1533
|
+
parts.append(f"{template_name}/{result.case_name}\n{result.text}")
|
|
1534
|
+
return fingerprint(*parts)
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
@dataclass(frozen=True)
|
|
1538
|
+
class _Block:
|
|
1539
|
+
"""写进输出表的一块内容:一个 Case 名 + 若干"行"。"""
|
|
1540
|
+
|
|
1541
|
+
case_name: str
|
|
1542
|
+
lines: list[str]
|
|
1543
|
+
|
|
1544
|
+
@property
|
|
1545
|
+
def line_count(self) -> int:
|
|
1546
|
+
return len(self.lines)
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _template_source(template, base_dir: str | Path | None) -> str:
|
|
1550
|
+
"""读取模板源码(公式模式需要它;``template_file`` 相对**声明它的那个文件**所在目录解析)。"""
|
|
1551
|
+
if not template.template_file:
|
|
1552
|
+
return template.source_code
|
|
1553
|
+
path = Path(template.template_file)
|
|
1554
|
+
base = template.source_dir or base_dir
|
|
1555
|
+
if base and not path.is_absolute():
|
|
1556
|
+
path = Path(base) / path
|
|
1557
|
+
if not path.exists():
|
|
1558
|
+
raise ExcelError(f"模板 {template.name!r} 引用的模板文件不存在: {path}")
|
|
1559
|
+
try:
|
|
1560
|
+
return path.read_text(encoding="utf-8")
|
|
1561
|
+
except OSError as exc:
|
|
1562
|
+
raise ExcelError(f"无法读取模板文件 {path}: {exc}") from exc
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
#: 公开别名(CLI 的 check/validate 也要用同一份解析逻辑)
|
|
1566
|
+
template_source = _template_source
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def case_anchor_map(workbook: Workbook, config: ProjectConfig) -> dict[str, CaseData]:
|
|
1570
|
+
"""Local 表的 ``Case 名 -> 位置`` 映射(公式模式要知道每个 Case 在哪一列 / 哪一行)。"""
|
|
1571
|
+
worksheet = get_sheet(workbook, config.excel.sheets.local)
|
|
1572
|
+
return {anchor.name: anchor for anchor in _case_anchors(worksheet, config)}
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _case_axis(anchor: CaseData, config: ProjectConfig) -> int:
|
|
1576
|
+
"""Case 的"工况轴":横向布局是列号,纵向布局是行号。"""
|
|
1577
|
+
if config.excel.local_direction == "horizontal":
|
|
1578
|
+
assert anchor.column is not None
|
|
1579
|
+
return anchor.column
|
|
1580
|
+
assert anchor.row is not None
|
|
1581
|
+
return anchor.row
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def case_axis_map(workbook: Workbook, config: ProjectConfig) -> dict[str, int]:
|
|
1585
|
+
return {name: _case_axis(anchor, config) for name, anchor in case_anchor_map(workbook, config).items()}
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def _formula_blocks(
|
|
1589
|
+
workbook: Workbook,
|
|
1590
|
+
config: ProjectConfig,
|
|
1591
|
+
template,
|
|
1592
|
+
results: Sequence[RenderResult],
|
|
1593
|
+
case_axes: dict[str, int],
|
|
1594
|
+
) -> list[_Block]:
|
|
1595
|
+
"""把模板编译成"每行一个公式"的块。"""
|
|
1596
|
+
missing = [result.case_name for result in results if result.case_name not in case_axes]
|
|
1597
|
+
if missing:
|
|
1598
|
+
raise ExcelError(
|
|
1599
|
+
f"模板 {template.name!r} 使用公式模式,但 Local 表里找不到这些 Case 列: "
|
|
1600
|
+
f"{', '.join(missing)}(可用: {', '.join(case_axes) or '(无)'})"
|
|
1601
|
+
)
|
|
1602
|
+
source = _template_source(template, config.source_dir)
|
|
1603
|
+
per_case = compile_formulas(
|
|
1604
|
+
template,
|
|
1605
|
+
config,
|
|
1606
|
+
case_axes=[case_axes[result.case_name] for result in results],
|
|
1607
|
+
source=source,
|
|
1608
|
+
)
|
|
1609
|
+
return [_Block(case_name=result.case_name, lines=lines) for result, lines in zip(results, per_case, strict=False)]
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _row_of(worksheet: Worksheet, variable: str) -> int | None:
|
|
1613
|
+
"""按变量名在 A 列找行号。"""
|
|
1614
|
+
for row in range(2, worksheet.max_row + 1):
|
|
1615
|
+
if to_text(worksheet.cell(row=row, column=1).value).strip() == variable:
|
|
1616
|
+
return row
|
|
1617
|
+
return None
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def refresh_derived_cells(
|
|
1621
|
+
workbook: Workbook,
|
|
1622
|
+
config: ProjectConfig,
|
|
1623
|
+
global_values: Mapping[str, VarValue],
|
|
1624
|
+
cases: Sequence[CaseData],
|
|
1625
|
+
*,
|
|
1626
|
+
warnings: list[str] | None = None,
|
|
1627
|
+
) -> bool:
|
|
1628
|
+
"""刷新参数表里的派生参数格:能写公式就写公式,否则写入 Python 算好的值。
|
|
1629
|
+
|
|
1630
|
+
:returns: 是否写过 Excel 公式(调用方据此决定要不要设 ``fullCalcOnLoad``)
|
|
1631
|
+
"""
|
|
1632
|
+
if not any(item.is_derived for item in [*config.global_variables, *config.local_variables]):
|
|
1633
|
+
return False
|
|
1634
|
+
|
|
1635
|
+
wrote_formula = False
|
|
1636
|
+
global_sheet = get_sheet(workbook, config.excel.sheets.global_)
|
|
1637
|
+
global_resolve = _global_resolver(config)
|
|
1638
|
+
for definition in config.global_variables:
|
|
1639
|
+
if not definition.is_derived:
|
|
1640
|
+
continue
|
|
1641
|
+
row = _row_of(global_sheet, definition.name)
|
|
1642
|
+
if row is None:
|
|
1643
|
+
continue
|
|
1644
|
+
value = global_values.get(definition.name)
|
|
1645
|
+
_write_derived_cell(
|
|
1646
|
+
global_sheet.cell(row=row, column=_GLOBAL_COL["value"]),
|
|
1647
|
+
definition,
|
|
1648
|
+
resolve=global_resolve,
|
|
1649
|
+
value=value.value if value is not None else None,
|
|
1650
|
+
)
|
|
1651
|
+
wrote_formula = wrote_formula or is_translatable(definition)
|
|
1652
|
+
|
|
1653
|
+
local_sheet = get_sheet(workbook, config.excel.sheets.local)
|
|
1654
|
+
slots = {slot.name: slot for slot in _local_slots(local_sheet, config)}
|
|
1655
|
+
for case in cases:
|
|
1656
|
+
resolve = _local_resolver(config, case)
|
|
1657
|
+
for definition in config.local_variables:
|
|
1658
|
+
if not definition.is_derived:
|
|
1659
|
+
continue
|
|
1660
|
+
slot = slots.get(definition.name)
|
|
1661
|
+
if slot is None:
|
|
1662
|
+
continue
|
|
1663
|
+
value = case.values.get(definition.name)
|
|
1664
|
+
_write_derived_cell(
|
|
1665
|
+
_case_cell(local_sheet, config, slot, case),
|
|
1666
|
+
definition,
|
|
1667
|
+
resolve=resolve,
|
|
1668
|
+
value=value.value if value is not None else None,
|
|
1669
|
+
)
|
|
1670
|
+
wrote_formula = wrote_formula or is_translatable(definition)
|
|
1671
|
+
return wrote_formula
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def write_results(
|
|
1675
|
+
path: str | Path,
|
|
1676
|
+
config: ProjectConfig,
|
|
1677
|
+
rendered: Mapping[str, Sequence[RenderResult]],
|
|
1678
|
+
*,
|
|
1679
|
+
update_howto: bool = True,
|
|
1680
|
+
command: str | None = None,
|
|
1681
|
+
warnings: list[str] | None = None,
|
|
1682
|
+
) -> Path:
|
|
1683
|
+
"""把渲染结果写入各模板对应的 Output 表,并在 HOWTO / Template 表里记录指纹。
|
|
1684
|
+
|
|
1685
|
+
* ``direction: horizontal`` —— 每个 Case 一列(结果行向下展开)
|
|
1686
|
+
* ``direction: vertical`` —— 每个 Case 一行(结果行向右展开)
|
|
1687
|
+
* ``engine: excel`` —— 写 **Excel 公式**(默认):改参数后由 Excel 自己重算,不用重跑脚本
|
|
1688
|
+
* ``engine: snapshot`` —— 写**文本快照**:改参数必须重跑命令(要用循环 / 过滤器时才选它)
|
|
1689
|
+
|
|
1690
|
+
写入前会清理旧的输出区域(按**表内原有的真实边界**算,而不是按本次行数),
|
|
1691
|
+
因此"改短模板 / 减少 Case 之后重渲染"不会残留上一次的内容。
|
|
1692
|
+
|
|
1693
|
+
:param warnings: 传一个列表进来,会把"公式过长"这类不致命的问题写进去。
|
|
1694
|
+
"""
|
|
1695
|
+
target = Path(path)
|
|
1696
|
+
workbook = load_workbook_file(target)
|
|
1697
|
+
try:
|
|
1698
|
+
case_axes: dict[str, int] | None = None
|
|
1699
|
+
formula_written = False
|
|
1700
|
+
for template in config.templates:
|
|
1701
|
+
results = list(rendered.get(template.name, ()))
|
|
1702
|
+
if not results:
|
|
1703
|
+
continue
|
|
1704
|
+
if template.output_sheet not in workbook.sheetnames:
|
|
1705
|
+
workbook.create_sheet(template.output_sheet)
|
|
1706
|
+
worksheet = workbook[template.output_sheet]
|
|
1707
|
+
column, row = parse_cell(template.start_cell)
|
|
1708
|
+
|
|
1709
|
+
if template.engine == "excel":
|
|
1710
|
+
if case_axes is None:
|
|
1711
|
+
case_axes = case_axis_map(workbook, config)
|
|
1712
|
+
blocks = _formula_blocks(workbook, config, template, results, case_axes)
|
|
1713
|
+
formula_written = True
|
|
1714
|
+
if warnings is not None:
|
|
1715
|
+
longest = max((len(line) for block in blocks for line in block.lines), default=0)
|
|
1716
|
+
if longest > LONG_FORMULA_WARN:
|
|
1717
|
+
warnings.append(
|
|
1718
|
+
f"模板 {template.name!r} 的最长公式 {longest} 字符"
|
|
1719
|
+
f"(警告阈值 {LONG_FORMULA_WARN}):一个 {{{{ x }}}} 约展开 300–400 字符,"
|
|
1720
|
+
"建议把这一行拆成多行"
|
|
1721
|
+
)
|
|
1722
|
+
else:
|
|
1723
|
+
blocks = [_Block(result.case_name, result.lines) for result in results]
|
|
1724
|
+
|
|
1725
|
+
if template.direction == "horizontal":
|
|
1726
|
+
_write_horizontal(worksheet, template, blocks, column, row)
|
|
1727
|
+
else:
|
|
1728
|
+
_write_vertical(worksheet, template, blocks, column, row)
|
|
1729
|
+
|
|
1730
|
+
if formula_written:
|
|
1731
|
+
# 让 Excel / WPS 打开文件时立刻重算,而不是显示上一次的缓存值。
|
|
1732
|
+
# 公式模式下必须重算(openpyxl 默认恰好也是 True,这里显式写死,不依赖上游默认)
|
|
1733
|
+
with suppress(AttributeError): # pragma: no cover - 老版本 openpyxl 兜底
|
|
1734
|
+
workbook.calculation.fullCalcOnLoad = True
|
|
1735
|
+
|
|
1736
|
+
metadata = {
|
|
1737
|
+
META_TIME: datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1738
|
+
META_OUTPUT: output_fingerprint(rendered),
|
|
1739
|
+
}
|
|
1740
|
+
values: dict[str, VarValue] | None = None
|
|
1741
|
+
cases: list[CaseData] | None = None
|
|
1742
|
+
try:
|
|
1743
|
+
values = read_global_values(workbook, config, warnings=warnings)
|
|
1744
|
+
cases = read_cases(workbook, config, global_values=values, warnings=warnings)
|
|
1745
|
+
metadata[META_INPUT] = input_fingerprint(values, cases)
|
|
1746
|
+
metadata["case 数"] = str(len(cases))
|
|
1747
|
+
metadata["cases"] = ", ".join(case.name for case in cases) or "(无)"
|
|
1748
|
+
except CodeGenError:
|
|
1749
|
+
metadata[META_INPUT] = "(读取参数失败)"
|
|
1750
|
+
metadata["cases"] = ", ".join(_case_names(rendered)) or "(无)"
|
|
1751
|
+
|
|
1752
|
+
# 派生参数的格子也刷新一遍:能写公式就写公式(改输入自动重算)
|
|
1753
|
+
if (
|
|
1754
|
+
values is not None
|
|
1755
|
+
and cases is not None
|
|
1756
|
+
and refresh_derived_cells(workbook, config, values, cases, warnings=warnings)
|
|
1757
|
+
):
|
|
1758
|
+
formula_written = True
|
|
1759
|
+
|
|
1760
|
+
# 脚本是否还在工作簿旁边要现查:config 可能是刚从 YAML 重新加载的,
|
|
1761
|
+
# 那个标记会回落成默认的 False(HOWTO 表据此决定要不要提「双击」)
|
|
1762
|
+
config.scripts_enabled = (target.parent / f"{target.stem}_render.sh").exists()
|
|
1763
|
+
_record_metadata(workbook, config, metadata, command=command, update_howto=update_howto)
|
|
1764
|
+
workbook.save(target)
|
|
1765
|
+
except OSError as exc:
|
|
1766
|
+
raise ExcelError(f"无法写回 Excel {target}: {exc}(文件被 Excel 占用?)") from exc
|
|
1767
|
+
finally:
|
|
1768
|
+
workbook.close()
|
|
1769
|
+
return target
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _record_metadata(
|
|
1773
|
+
workbook: Workbook,
|
|
1774
|
+
config: ProjectConfig,
|
|
1775
|
+
metadata: Mapping[str, str],
|
|
1776
|
+
*,
|
|
1777
|
+
command: str | None,
|
|
1778
|
+
update_howto: bool,
|
|
1779
|
+
) -> None:
|
|
1780
|
+
sheet_name = config.excel.template_sheet
|
|
1781
|
+
if sheet_name:
|
|
1782
|
+
if sheet_name not in workbook.sheetnames:
|
|
1783
|
+
worksheet = workbook.create_sheet(sheet_name)
|
|
1784
|
+
_build_template_sheet(worksheet, config)
|
|
1785
|
+
worksheet.sheet_state = "hidden"
|
|
1786
|
+
_write_meta_block(workbook[sheet_name], metadata)
|
|
1787
|
+
if update_howto and config.excel.howto_sheet:
|
|
1788
|
+
howto_name = config.excel.howto_sheet
|
|
1789
|
+
if howto_name not in workbook.sheetnames:
|
|
1790
|
+
worksheet = workbook.create_sheet(howto_name)
|
|
1791
|
+
workbook.move_sheet(worksheet, offset=-len(workbook.sheetnames) + 1)
|
|
1792
|
+
write_howto_sheet(workbook[howto_name], config, metadata=metadata, command=command)
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _case_names(rendered: Mapping[str, Sequence[RenderResult]]) -> list[str]:
|
|
1796
|
+
ordered: list[str] = []
|
|
1797
|
+
for per_case in rendered.values():
|
|
1798
|
+
for result in per_case:
|
|
1799
|
+
if result.case_name not in ordered:
|
|
1800
|
+
ordered.append(result.case_name)
|
|
1801
|
+
return ordered
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _write_horizontal(worksheet, template, blocks: Sequence[_Block], column: int, row: int) -> None:
|
|
1805
|
+
max_lines = max(block.line_count for block in blocks)
|
|
1806
|
+
header_row = row - 1 if template.write_case_headers and row > 1 else None
|
|
1807
|
+
top = header_row if header_row else row
|
|
1808
|
+
last_column = _last_used_column(worksheet, top, worksheet.max_row, column)
|
|
1809
|
+
last_column = max(last_column, column + len(blocks) - 1)
|
|
1810
|
+
# 底边取"表里原有的真实底边",而不是本次行数 —— 否则改短模板会留下旧行
|
|
1811
|
+
last_row = _last_used_row(worksheet, top, column, last_column)
|
|
1812
|
+
bottom = max(row + max_lines + 1, last_row + 1)
|
|
1813
|
+
_clear_region(worksheet, top, bottom, column, last_column)
|
|
1814
|
+
|
|
1815
|
+
for offset, block in enumerate(blocks):
|
|
1816
|
+
target_column = column + offset
|
|
1817
|
+
if header_row:
|
|
1818
|
+
cell = worksheet.cell(row=header_row, column=target_column, value=block.case_name)
|
|
1819
|
+
cell.font = _HEADER_FONT
|
|
1820
|
+
cell.fill = _CASE_HEADER_FILL
|
|
1821
|
+
for line_offset, line in enumerate(block.lines):
|
|
1822
|
+
worksheet.cell(row=row + line_offset, column=target_column, value=line)
|
|
1823
|
+
width = max((len(line) for line in block.lines), default=0)
|
|
1824
|
+
worksheet.column_dimensions[column_index_to_letter(target_column)].width = max(
|
|
1825
|
+
14, min(160, max(width, len(block.case_name)) + 2)
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
def _write_vertical(worksheet, template, blocks: Sequence[_Block], column: int, row: int) -> None:
|
|
1830
|
+
max_columns = max(block.line_count for block in blocks)
|
|
1831
|
+
header_column = column - 1 if template.write_case_headers and column > 1 else None
|
|
1832
|
+
left = header_column if header_column else column
|
|
1833
|
+
right = max(column + max_columns - 1, column)
|
|
1834
|
+
last_column = _last_used_column(worksheet, row, worksheet.max_row, left)
|
|
1835
|
+
last_column = max(last_column, right)
|
|
1836
|
+
last_row = _last_used_row(worksheet, row, left, last_column)
|
|
1837
|
+
bottom = max(row + len(blocks) + 1, last_row + 1)
|
|
1838
|
+
_clear_region(worksheet, row, bottom, left, last_column)
|
|
1839
|
+
|
|
1840
|
+
for offset, block in enumerate(blocks):
|
|
1841
|
+
target_row = row + offset
|
|
1842
|
+
if header_column:
|
|
1843
|
+
cell = worksheet.cell(row=target_row, column=header_column, value=block.case_name)
|
|
1844
|
+
cell.font = _HEADER_FONT
|
|
1845
|
+
cell.fill = _CASE_HEADER_FILL
|
|
1846
|
+
for line_offset, line in enumerate(block.lines):
|
|
1847
|
+
target_column = column + line_offset
|
|
1848
|
+
worksheet.cell(row=target_row, column=target_column, value=line)
|
|
1849
|
+
width = max(14, min(160, len(line) + 2))
|
|
1850
|
+
current = worksheet.column_dimensions[column_index_to_letter(target_column)].width or 0
|
|
1851
|
+
if width > current:
|
|
1852
|
+
worksheet.column_dimensions[column_index_to_letter(target_column)].width = width
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def _last_used_column(worksheet: Worksheet, top: int, bottom: int, left: int) -> int:
|
|
1856
|
+
"""在给定行区间内找到 left 右侧最后一个有内容的列(用于清理旧结果)。"""
|
|
1857
|
+
upper = max(worksheet.max_column, left)
|
|
1858
|
+
upper = min(upper, left + _SCAN_LIMIT)
|
|
1859
|
+
last = left - 1
|
|
1860
|
+
for row in worksheet.iter_rows(min_row=top, max_row=max(bottom, top), min_col=left, max_col=upper):
|
|
1861
|
+
for cell in row:
|
|
1862
|
+
if cell.value not in (None, ""):
|
|
1863
|
+
last = max(last, cell.column)
|
|
1864
|
+
return last
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def _last_used_row(worksheet: Worksheet, top: int, left: int, right: int) -> int:
|
|
1868
|
+
"""在给定列区间内找到 top 之下最后一个有内容的行号(用于清理上一次更长的结果)。"""
|
|
1869
|
+
lower = max(worksheet.max_row, top)
|
|
1870
|
+
lower = min(lower, top + _SCAN_LIMIT)
|
|
1871
|
+
last = top - 1
|
|
1872
|
+
for row in worksheet.iter_rows(min_row=top, max_row=lower, min_col=left, max_col=max(right, left)):
|
|
1873
|
+
for cell in row:
|
|
1874
|
+
if cell.value not in (None, ""):
|
|
1875
|
+
last = max(last, cell.row)
|
|
1876
|
+
return last
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def _clear_region(worksheet: Worksheet, top: int, bottom: int, left: int, right: int) -> None:
|
|
1880
|
+
if bottom < top or right < left:
|
|
1881
|
+
return
|
|
1882
|
+
for row in worksheet.iter_rows(min_row=top, max_row=bottom, min_col=left, max_col=right):
|
|
1883
|
+
for cell in row:
|
|
1884
|
+
if cell.value is not None:
|
|
1885
|
+
cell.value = None
|