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.
Files changed (82) hide show
  1. excel_codegen/__init__.py +160 -0
  2. excel_codegen/__main__.py +8 -0
  3. excel_codegen/cli.py +1250 -0
  4. excel_codegen/derived.py +465 -0
  5. excel_codegen/example_pack.py +107 -0
  6. excel_codegen/examples/README.md +69 -0
  7. excel_codegen/examples/abs_fpi/ABS_FPI_load_cases.xlsx +0 -0
  8. excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.bat +22 -0
  9. excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.sh +23 -0
  10. excel_codegen/examples/abs_fpi/README.md +121 -0
  11. excel_codegen/examples/abs_fpi/abs_fpi.yaml +310 -0
  12. excel_codegen/examples/abs_fpi/abs_fpi_external.xlsx +0 -0
  13. excel_codegen/examples/abs_fpi/abs_fpi_external.yaml +162 -0
  14. excel_codegen/examples/abs_fpi/abs_fpi_external_render.bat +22 -0
  15. excel_codegen/examples/abs_fpi/abs_fpi_external_render.sh +23 -0
  16. excel_codegen/examples/abs_fpi/abs_fpi_internal.xlsx +0 -0
  17. excel_codegen/examples/abs_fpi/abs_fpi_internal.yaml +266 -0
  18. excel_codegen/examples/abs_fpi/abs_fpi_internal_render.bat +22 -0
  19. excel_codegen/examples/abs_fpi/abs_fpi_internal_render.sh +23 -0
  20. excel_codegen/examples/abs_fpi/compose.yaml +20 -0
  21. excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60.js +124 -0
  22. excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60_summary.md +30 -0
  23. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1.js +91 -0
  24. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1_summary.md +23 -0
  25. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1.js +91 -0
  26. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1_summary.md +23 -0
  27. excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1.js +91 -0
  28. excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1_summary.md +23 -0
  29. excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0.js +124 -0
  30. excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0_summary.md +30 -0
  31. excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90.js +124 -0
  32. excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90_summary.md +30 -0
  33. excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90.js +124 -0
  34. excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90_summary.md +30 -0
  35. excel_codegen/examples/abs_fpi/templates/ext_body.js.j2 +91 -0
  36. excel_codegen/examples/abs_fpi/templates/ext_summary.md.j2 +23 -0
  37. excel_codegen/examples/abs_fpi/templates/int_body.js.j2 +124 -0
  38. excel_codegen/examples/abs_fpi/templates/int_summary.md.j2 +30 -0
  39. excel_codegen/examples/basic/example.yaml +97 -0
  40. excel_codegen/examples/basic/example_formula.yaml +86 -0
  41. excel_codegen/examples/basic/generated/uart_init_Case1.c +6 -0
  42. excel_codegen/examples/basic/generated/uart_init_Case2.c +6 -0
  43. excel_codegen/examples/basic/generated/uart_summary_Case1.md +4 -0
  44. excel_codegen/examples/basic/generated/uart_summary_Case2.md +4 -0
  45. excel_codegen/examples/basic/generated_formula/uart_init_Case1.c +5 -0
  46. excel_codegen/examples/basic/generated_formula/uart_init_Case2.c +5 -0
  47. excel_codegen/examples/basic/generated_formula/uart_pins_Case1.csv +1 -0
  48. excel_codegen/examples/basic/generated_formula/uart_pins_Case2.csv +1 -0
  49. excel_codegen/examples/basic/template.xlsx +0 -0
  50. excel_codegen/examples/basic/template_formula.xlsx +0 -0
  51. excel_codegen/examples/basic/template_formula_render.bat +22 -0
  52. excel_codegen/examples/basic/template_formula_render.sh +23 -0
  53. excel_codegen/examples/basic/template_render.bat +22 -0
  54. excel_codegen/examples/basic/template_render.sh +23 -0
  55. excel_codegen/examples/nastran/generated/case_control.deck +18 -0
  56. excel_codegen/examples/nastran/generated/cc_01_LC1.inc +8 -0
  57. excel_codegen/examples/nastran/generated/cc_02_LC2.inc +8 -0
  58. excel_codegen/examples/nastran/generated/cc_03_LC3.inc +8 -0
  59. excel_codegen/examples/nastran/generated/cc_04_COMB4.inc +8 -0
  60. excel_codegen/examples/nastran/generated/cc_05_LC5.inc +8 -0
  61. excel_codegen/examples/nastran/generated/cc_summary_01_LC1.md +1 -0
  62. excel_codegen/examples/nastran/generated/cc_summary_02_LC2.md +1 -0
  63. excel_codegen/examples/nastran/generated/cc_summary_03_LC3.md +1 -0
  64. excel_codegen/examples/nastran/generated/cc_summary_04_COMB4.md +1 -0
  65. excel_codegen/examples/nastran/generated/cc_summary_05_LC5.md +1 -0
  66. excel_codegen/examples/nastran/nastran_case_control.xlsx +0 -0
  67. excel_codegen/examples/nastran/nastran_case_control.yaml +141 -0
  68. excel_codegen/examples/nastran/nastran_case_control_render.bat +22 -0
  69. excel_codegen/examples/nastran/nastran_case_control_render.sh +23 -0
  70. excel_codegen/excel_io.py +1885 -0
  71. excel_codegen/formula.py +674 -0
  72. excel_codegen/formula_eval.py +685 -0
  73. excel_codegen/jinja_env.py +92 -0
  74. excel_codegen/models.py +1008 -0
  75. excel_codegen/renderer.py +558 -0
  76. excel_codegen/utils.py +225 -0
  77. excel_codegen-0.9.0.dist-info/METADATA +179 -0
  78. excel_codegen-0.9.0.dist-info/RECORD +82 -0
  79. excel_codegen-0.9.0.dist-info/WHEEL +5 -0
  80. excel_codegen-0.9.0.dist-info/entry_points.txt +2 -0
  81. excel_codegen-0.9.0.dist-info/licenses/LICENSE +21 -0
  82. 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