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,674 @@
1
+ """公式引擎:把"纯替换"型模板编译成 Excel 公式(``engine: excel``)。
2
+
3
+ 设计边界(A 档:只做占位符替换)
4
+ --------------------------------
5
+ 支持的占位符(仅这些):
6
+
7
+ ========================= ==========================================================
8
+ ``{{ x }}`` ``Prefix & Value & Suffix``(与快照模式的组合值一致)
9
+ ``{{ x.value }}`` 取值列(原始值)
10
+ ``{{ x.text }}`` 同 ``.value``(对应 Python 侧 ``VarValue.text``)
11
+ ``{{ x.prefix }}`` Prefix 列
12
+ ``{{ x.suffix }}`` Suffix 列
13
+ ``{{ case_name }}`` 当前 Case 名(取 Local 表表头)
14
+ ``{{ template_name }}`` 模板名(常量)
15
+ ========================= ==========================================================
16
+
17
+ 出现 ``|`` 过滤器 / 运算表达式 / 函数调用等一律**报错并指出行号内容**,
18
+ 提示该模板继续用 ``engine: snapshot``。
19
+
20
+ **行内 ``{% if %}``**(0.6.0 起)
21
+ --------------------------------
22
+ ``{% if 条件 %}A{% else %}B{% endif %}`` 可以写,条件是**比较或逻辑表达式**,
23
+ 会被翻译成 Excel 的 ``IF(...)``。但有一条硬约束:**必须整段写在同一行内** ——
24
+ 公式模式是"一行模板 → 一个单元格",跨行的分支会改变行数,没法映射到固定单元格。
25
+ ``{% for %}`` / ``{% set %}`` 等仍然报错。
26
+
27
+ 条件里的**变量名指它的取值**(相当于 Python 侧的 ``VarValue.value``),不是
28
+ Prefix+Value+Suffix 的组合值 —— 这样 ``draft > 20`` 才是数值比较。
29
+ ``{{ }}`` 里那套组合值语义不变。
30
+
31
+ 两条路径的一致性
32
+ ----------------
33
+ * 变量用 ``INDEX/MATCH`` 按**变量名**定位,用户在 Global / Local 表里插行删行都不会指错。
34
+ * 空单元格回落 YAML ``default``:公式写成 ``IF(ref="", default, ref)``,与 Python 侧一致。
35
+ * ``type: int`` / ``float`` 用 ``TEXT()`` 规范化,避免出现 ``340.0`` 这种尾巴。
36
+ * ``{{ x }}`` 的组合顺序与 Python 侧一致:Prefix + Value + Suffix。
37
+
38
+ 已知差异(见 docs/template_guide.md)
39
+ ------------------------------------
40
+ * ``type: bool`` 在 Excel 里是 ``TRUE`` / ``FALSE``,Python 侧渲染成 ``true`` / ``false``。
41
+ * ``TEXT()`` 的格式串受区域设置影响。
42
+ * 公式上限 8192 字符/格,超过会报错并提示拆行或改用快照模式。
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import re
48
+ from collections.abc import Sequence
49
+ from dataclasses import dataclass
50
+ from typing import Any
51
+
52
+ from jinja2 import TemplateSyntaxError, nodes
53
+
54
+ from .jinja_env import build_environment
55
+ from .models import ProjectConfig, TemplateDef, VariableDef
56
+ from .utils import RenderError, column_index_to_letter, to_text
57
+
58
+ __all__ = [
59
+ "LONG_FORMULA_WARN",
60
+ "MAX_FORMULA_CHARS",
61
+ "FormulaError",
62
+ "compile_formulas",
63
+ "compile_line",
64
+ "excel_literal",
65
+ "guard_default",
66
+ "guarded_lookup",
67
+ "local_cell",
68
+ "lookup_expr",
69
+ "lookup_expr_at",
70
+ ]
71
+
72
+ #: Excel 单个公式的字符上限(留一点余量)。
73
+ MAX_FORMULA_CHARS = 8000
74
+
75
+ #: 公式长度**警告**阈值:一个 ``{{ x }}`` 大约展开 300–400 字符,
76
+ #: 超过这个量级的行虽然能编译,但人已经读不懂了,建议拆行。
77
+ LONG_FORMULA_WARN = 3000
78
+
79
+ #: 一次匹配出 ``{{ 表达式 }}`` / ``{% 标签 %}`` / ``{# 注释 #}``
80
+ _TAG_RE = re.compile(r"\{\{(?P<expr>.*?)\}\}|\{%(?P<tag>.*?)%\}|\{#(?P<comment>.*?)#\}", re.DOTALL)
81
+ _EXPR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?$")
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class _Token:
86
+ """模板一行的词法单元:``text``(字面文本)/ ``expr``(``{{ }}``)/ ``tag``(``{% %}``)/ ``comment``。"""
87
+
88
+ kind: str
89
+ value: str
90
+
91
+
92
+ #: 与 excel_io 的列约定一致
93
+ _GLOBAL_COLUMNS = {"value": 2, "prefix": 4, "suffix": 5}
94
+ _LOCAL_COLUMNS = {"prefix": 3, "suffix": 4} # value 列随 Case 变化
95
+ _VALUE_ALIASES = frozenset({"value", "text"})
96
+ #: 成员表(第三层作用域)里第一个变量所在的列(B 列);A 列是成员名
97
+ _GROUP_FIRST_COLUMN = 2
98
+ #: 数值形态**不用 TEXT()**:Excel 的 TEXT() 会把二进制尾巴原样打出来
99
+ #: (``TEXT(20.559,"0.###############")`` 是 ``20.559000000000001``),
100
+ #: 而单元格/拼接的**隐式转换**走的是 General 规则 = **最多 15 位有效数字** ——
101
+ #: 这正是 :func:`utils.to_text` 的规则。两边因此逐字一致(见 docs/template_guide.md §14.2)。
102
+
103
+
104
+ class FormulaError(RenderError):
105
+ """模板无法编译成 Excel 公式(超出"纯替换"子集,或变量信息不足)。"""
106
+
107
+
108
+ # --------------------------------------------------------------------------- #
109
+ # 基础片段
110
+ # --------------------------------------------------------------------------- #
111
+ def _quote_sheet(sheet: str) -> str:
112
+ return "'" + str(sheet).replace("'", "''") + "'"
113
+
114
+
115
+ def _quote_text(text: Any) -> str:
116
+ return '"' + to_text(text).replace('"', '""') + '"'
117
+
118
+
119
+ def excel_literal(value: Any) -> str:
120
+ """把 YAML 默认值写成 Excel 字面量。"""
121
+ if value is None:
122
+ return '""'
123
+ if isinstance(value, bool):
124
+ return "TRUE" if value else "FALSE"
125
+ if isinstance(value, (int, float)):
126
+ return repr(value)
127
+ text = to_text(value)
128
+ return '""' if text == "" else _quote_text(text)
129
+
130
+
131
+ def lookup_expr(sheet: str, variable: str, column: int, *, absolute: bool) -> str:
132
+ """``INDEX('Local Parameter'!E:E,MATCH("port",'Local Parameter'!$A:$A,0))``。
133
+
134
+ ``absolute=False`` 时列标不加 ``$`` —— 把输出单元格向右拖,Case 列会跟着走。
135
+ """
136
+ quoted = _quote_sheet(sheet)
137
+ return lookup_expr_at(sheet, f"MATCH({_quote_text(variable)},{quoted}!$A:$A,0)", column, absolute=absolute)
138
+
139
+
140
+ def lookup_expr_at(sheet: str, row_expr: str, column: int, *, absolute: bool) -> str:
141
+ """行号由调用方给(``MATCH(...)`` 或别的表达式)的 ``INDEX``。
142
+
143
+ 成员表(第三层作用域,指南 §18)用的就是它:行不是"变量名",而是
144
+ ``MATCH(<这个 Case 用哪个成员>, 'Tank Data'!$A:$A, 0)``。
145
+ """
146
+ letter = column_index_to_letter(column)
147
+ anchor = f"${letter}:${letter}" if absolute else f"{letter}:{letter}"
148
+ return f"INDEX({_quote_sheet(sheet)}!{anchor},{row_expr})"
149
+
150
+
151
+ def local_cell(
152
+ sheet: str,
153
+ variable: str,
154
+ *,
155
+ column: int | None = None,
156
+ row: int | None = None,
157
+ absolute: bool = True,
158
+ ) -> str:
159
+ """Local 表里"某个变量 × 某个 Case"那一格的引用(横向 / 纵向两种形态)。
160
+
161
+ * **横向**(``column``,一个工况一列):
162
+ ``INDEX(sheet!E:E, MATCH("port", sheet!$A:$A, 0))``
163
+ —— ``absolute=False`` 时列标不加 ``$``,右拉就换到下一个 Case。
164
+ * **纵向**(``row``,一个工况一行):
165
+ ``INDEX(sheet!$A:$ZZ, 2, MATCH("port", sheet!$1:$1, 0))``
166
+ —— ``absolute=False`` 时行号不加 ``$``,下拉就换到下一个 Case。
167
+
168
+ 两种形态都只做一次"按**变量名**定位",所以插/删变量列(行)都不会指错。
169
+ """
170
+ quoted = _quote_sheet(sheet)
171
+ if column is not None:
172
+ letter = column_index_to_letter(column)
173
+ anchor = f"${letter}:${letter}" if absolute else f"{letter}:{letter}"
174
+ return f"INDEX({quoted}!{anchor},MATCH({_quote_text(variable)},{quoted}!$A:$A,0))"
175
+ if row is None:
176
+ raise FormulaError(f"给 {variable!r} 定位时要给 column 或 row")
177
+ row_ref = f"${row}" if absolute else f"{row}"
178
+ return f"INDEX({quoted}!$A:$ZZ,{row_ref},MATCH({_quote_text(variable)},{quoted}!$1:$1,0))"
179
+
180
+
181
+ def guarded_lookup(
182
+ sheet: str,
183
+ variable: str,
184
+ column: int,
185
+ default: Any,
186
+ *,
187
+ absolute: bool = True,
188
+ ) -> str:
189
+ """``lookup_expr`` + "空单元格回落 YAML 默认值"的保护。
190
+
191
+ 这是**参数表里派生参数格**用的引用形态(``derived`` 求值也走同一套语义)。
192
+ """
193
+ return _with_default(lookup_expr(sheet, variable, column, absolute=absolute), default)
194
+
195
+
196
+ def _with_default(expr: str, default: Any) -> str:
197
+ """空单元格回落 YAML 默认值(与 Python 侧一致)。
198
+
199
+ ⚠ 必须用 ``ISBLANK`` 而不是 ``expr=""``:``INDEX`` 对**空单元格**返回数值 ``0``
200
+ (不是空串),用 ``=""`` 判断会把空前缀变成 ``0``,生成 ``0STM32F1030`` 这种垃圾。
201
+ 另外再补一个 ``=""`` 分支,覆盖"长度为零的文本"。
202
+ """
203
+ if to_text(default) == "":
204
+ return f'IF(ISBLANK({expr}),"",{expr})'
205
+ literal = excel_literal(default)
206
+ return f'IF(ISBLANK({expr}),{literal},IF({expr}="",{literal},{expr}))'
207
+
208
+
209
+ #: 取不到就退回默认值:派生表达式算不出时用 YAML 里的默认值。
210
+ guard_default = _with_default
211
+
212
+
213
+ def _typed(expr: str, definition: VariableDef | None) -> str:
214
+ """数值规范化:交给 Excel 的隐式转换(General = 最多 15 位有效数字)。
215
+
216
+ 与 :func:`utils.to_text` 的规则一致,所以 ``{{ x }}`` 在 Excel 里与导出文件里
217
+ 是同一个字符串;``type`` 只影响 Python 侧的取值转换,不再往公式里塞 ``TEXT()``。
218
+ """
219
+ return expr
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class _VarRef:
224
+ """一个变量在公式里的定位信息。"""
225
+
226
+ sheet: str
227
+ name: str
228
+ definition: VariableDef | None
229
+ row_expr: str
230
+ value_column: int
231
+ #: 成员表(第三层作用域)的变量没有"前缀/后缀列" —— 它的前缀后缀只来自 YAML
232
+ prefix_column: int | None
233
+ suffix_column: int | None
234
+ value_column_is_relative: bool
235
+ #: 纵向布局(一行一个工况):用 ``local_cell`` 的"行"形态定位
236
+ transposed: bool = False
237
+ #: 纵向布局下"这个 Case 的行号"(横向布局用不到)
238
+ value_row: int | None = None
239
+ #: 纵向布局下相对行 / 绝对行
240
+ value_row_is_relative: bool = False
241
+
242
+ def _lookup(self, column: int, *, absolute: bool) -> str:
243
+ if self.transposed:
244
+ assert self.value_row is not None
245
+ return local_cell(
246
+ self.sheet,
247
+ self.name,
248
+ row=self.value_row,
249
+ absolute=not self.value_row_is_relative,
250
+ )
251
+ return lookup_expr_at(self.sheet, self.row_expr, column, absolute=absolute)
252
+
253
+ def value(self) -> str:
254
+ base = self._lookup(self.value_column, absolute=not self.value_column_is_relative)
255
+ default = self.definition.default if self.definition else ""
256
+ return _typed(_with_default(base, default), self.definition)
257
+
258
+ def decor(self, which: str) -> str:
259
+ column = self.prefix_column if which == "prefix" else self.suffix_column
260
+ default = getattr(self.definition, which, "") if self.definition else ""
261
+ if column is None:
262
+ # 成员表的变量:前缀/后缀写死在 YAML 里,表里没有这一列
263
+ return _quote_text(default)
264
+ return _with_default(self._lookup(column, absolute=True), default)
265
+
266
+ def combined(self) -> str:
267
+ return f"{self.decor('prefix')}&{self.value()}&{self.decor('suffix')}"
268
+
269
+
270
+ # --------------------------------------------------------------------------- #
271
+ # 编译器
272
+ # --------------------------------------------------------------------------- #
273
+ class _Compiler:
274
+ """把某个模板的行编译成公式;缓存变量定位信息。"""
275
+
276
+ def __init__(
277
+ self,
278
+ config: ProjectConfig,
279
+ template_name: str,
280
+ *,
281
+ relative_case_column: bool = True,
282
+ ) -> None:
283
+ self.config = config
284
+ self.template_name = template_name
285
+ #: 横向布局:一个 Case 一列,向右拖公式应该跟着换 Case(相对列)。
286
+ #: 纵向布局:向右拖是换"同一 Case 的下一行",Case 列必须锁死(绝对列)。
287
+ self.relative_case_column = relative_case_column
288
+ #: 纵向布局(Local 表一行一个工况):"case_axis" 那个参数其实是**行号**
289
+ self.transposed = config.excel.local_direction == "vertical"
290
+ self._globals = {item.name: item for item in config.global_variables}
291
+ self._locals = {item.name: item for item in config.local_variables}
292
+ self._group_config = config.group
293
+ self._group = {item.name: item for item in config.group_variables}
294
+ self._cache: dict[tuple[str, int], _VarRef] = {}
295
+
296
+ def _member_lookup(self, case_axis: int) -> str:
297
+ """这个 Case 用哪个成员:把 key 变量(local)按普通方式查出来。"""
298
+ group = self._group_config
299
+ assert group is not None
300
+ definition = self._locals[group.key]
301
+ sheet = self.config.excel.sheets.local
302
+ quoted = _quote_sheet(sheet)
303
+ ref = _VarRef(
304
+ sheet=sheet,
305
+ name=group.key,
306
+ definition=definition,
307
+ row_expr=f"MATCH({_quote_text(group.key)},{quoted}!$A:$A,0)",
308
+ value_column=case_axis,
309
+ prefix_column=None if self.transposed else _LOCAL_COLUMNS["prefix"],
310
+ suffix_column=None if self.transposed else _LOCAL_COLUMNS["suffix"],
311
+ value_column_is_relative=self.relative_case_column,
312
+ transposed=self.transposed,
313
+ value_row=case_axis if self.transposed else None,
314
+ value_row_is_relative=self.relative_case_column,
315
+ )
316
+ return ref.value()
317
+
318
+ # -- 变量 -------------------------------------------------------------- #
319
+ def resolve(self, name: str, case_axis: int) -> _VarRef:
320
+ key = (name, case_axis)
321
+ if key in self._cache:
322
+ return self._cache[key]
323
+
324
+ def name_match(sheet: str, variable: str) -> str:
325
+ quoted = _quote_sheet(sheet)
326
+ return f"MATCH({_quote_text(variable)},{quoted}!$A:$A,0)"
327
+
328
+ if name in self._globals:
329
+ sheet = self.config.excel.sheets.global_
330
+ ref = _VarRef(
331
+ sheet=sheet,
332
+ name=name,
333
+ definition=self._globals[name],
334
+ row_expr=name_match(sheet, name),
335
+ value_column=_GLOBAL_COLUMNS["value"],
336
+ prefix_column=_GLOBAL_COLUMNS["prefix"],
337
+ suffix_column=_GLOBAL_COLUMNS["suffix"],
338
+ value_column_is_relative=False,
339
+ )
340
+ elif name in self._locals:
341
+ sheet = self.config.excel.sheets.local
342
+ ref = _VarRef(
343
+ sheet=sheet,
344
+ name=name,
345
+ definition=self._locals[name],
346
+ row_expr=name_match(sheet, name),
347
+ value_column=case_axis,
348
+ prefix_column=None if self.transposed else _LOCAL_COLUMNS["prefix"],
349
+ suffix_column=None if self.transposed else _LOCAL_COLUMNS["suffix"],
350
+ value_column_is_relative=self.relative_case_column,
351
+ transposed=self.transposed,
352
+ value_row=case_axis if self.transposed else None,
353
+ value_row_is_relative=self.relative_case_column,
354
+ )
355
+ elif self._group is not None and name in self._group:
356
+ group = self._group_config
357
+ assert group is not None
358
+ sheet = group.sheet
359
+ quoted = _quote_sheet(sheet)
360
+ ref = _VarRef(
361
+ sheet=sheet,
362
+ name=name,
363
+ definition=self._group[name],
364
+ # 行 = "这个 Case 用哪个成员"(再按 A 列找行)—— 一次一维查找
365
+ row_expr=f"MATCH({self._member_lookup(case_axis)},{quoted}!$A:$A,0)",
366
+ value_column=_GROUP_FIRST_COLUMN + group.names.index(name),
367
+ prefix_column=None,
368
+ suffix_column=None,
369
+ value_column_is_relative=False,
370
+ )
371
+ else:
372
+ raise FormulaError(
373
+ f"模板 {self.template_name!r} 的公式模式引用了未在 YAML 中定义的变量 {name!r}:"
374
+ "公式需要知道它是 global 还是 local,请把它写进 variables"
375
+ "(快照模式允许表里临时出现的变量,公式模式不允许)"
376
+ )
377
+ self._cache[key] = ref
378
+ return ref
379
+
380
+ # -- 表达式 ------------------------------------------------------------ #
381
+ def expression(self, raw: str, *, case_axis: int, line: str) -> str:
382
+ expr = raw.strip()
383
+ if "|" in expr:
384
+ raise FormulaError(
385
+ f"模板 {self.template_name!r} 的公式模式不支持过滤器:{{{{ {expr} }}}};"
386
+ "请把该模板改回 engine: snapshot,或把过滤器换成等价的单元格拼接"
387
+ )
388
+ if expr == "template_name":
389
+ return _quote_text(self.template_name)
390
+ if expr == "case_name":
391
+ sheet = _quote_sheet(self.config.excel.sheets.local)
392
+ if self.transposed:
393
+ # 纵向布局:Case 名在 A 列,行号就是"这个 Case 的轴"
394
+ anchor = f"A{case_axis}" if self.relative_case_column else f"$A${case_axis}"
395
+ else:
396
+ column = column_index_to_letter(case_axis)
397
+ anchor = f"{column}$1" if self.relative_case_column else f"${column}$1"
398
+ return f"{sheet}!{anchor}"
399
+
400
+ match = _EXPR_RE.match(expr)
401
+ if not match:
402
+ raise FormulaError(
403
+ f"模板 {self.template_name!r} 的公式模式只支持 "
404
+ "变量 / 变量.value / .text / .prefix / .suffix / case_name / template_name,"
405
+ f"这一行出现了 {expr!r}(行内容:{line.strip()!r})"
406
+ )
407
+
408
+ name, attribute = match.group(1), match.group(2)
409
+ ref = self.resolve(name, case_axis)
410
+ if attribute is None:
411
+ return ref.combined()
412
+ if attribute in _VALUE_ALIASES:
413
+ return ref.value()
414
+ if attribute == "prefix":
415
+ return ref.decor("prefix")
416
+ if attribute == "suffix":
417
+ return ref.decor("suffix")
418
+ raise FormulaError(
419
+ f"模板 {self.template_name!r} 的公式模式不支持属性 .{attribute}(变量 {name!r}):"
420
+ "只支持 .value / .text / .prefix / .suffix"
421
+ )
422
+
423
+ # -- 行 ---------------------------------------------------------------- #
424
+ def line(self, line: str, *, case_axis: int) -> str:
425
+ tokens = self._tokenize(line)
426
+ body = self._compile_tokens(tokens, case_axis=case_axis, line=line)
427
+ formula = "=" + body
428
+ if len(formula) > MAX_FORMULA_CHARS:
429
+ raise FormulaError(
430
+ f"模板 {self.template_name!r} 编译出的公式过长({len(formula)} > "
431
+ f"{MAX_FORMULA_CHARS} 字符):{line.strip()[:60]!r}…;请拆成多行或改用 engine: snapshot"
432
+ )
433
+ return formula
434
+
435
+ # -- 分词 -------------------------------------------------------------- #
436
+ def _tokenize(self, line: str) -> list[_Token]:
437
+ tokens: list[_Token] = []
438
+ cursor = 0
439
+ for match in _TAG_RE.finditer(line):
440
+ if match.start() > cursor:
441
+ tokens.append(_Token("text", line[cursor : match.start()]))
442
+ if match.group("expr") is not None:
443
+ tokens.append(_Token("expr", match.group("expr")))
444
+ elif match.group("tag") is not None:
445
+ tokens.append(_Token("tag", match.group("tag").strip()))
446
+ cursor = match.end()
447
+ if cursor < len(line):
448
+ tokens.append(_Token("text", line[cursor:]))
449
+
450
+ for token in tokens:
451
+ if token.kind == "text" and any(mark in token.value for mark in ("{{", "}}", "{%", "%}", "{#", "#}")):
452
+ raise FormulaError(
453
+ f"模板 {self.template_name!r} 的标记没有闭合:{token.value.strip()[:40]!r};"
454
+ f"行内容:{line.strip()!r}"
455
+ )
456
+ return tokens
457
+
458
+ # -- 条件({% if %})---------------------------------------------------- #
459
+ #: 条件里可以直接写的名字(它们没有"取值/前缀/后缀"之分)
460
+ _CONDITION_SPECIAL_NAMES = frozenset({"case_name", "template_name"})
461
+
462
+ def condition(self, source: str, *, case_axis: int, line: str) -> str:
463
+ """把 ``{% if %}`` 的条件翻译成 Excel 的布尔表达式。
464
+
465
+ **条件里的变量必须写属性**(``x.value`` / ``x.text`` / ``x.prefix`` / ``x.suffix``)。
466
+ 这不是洁癖:快照模式里裸变量是 ``VarValue`` 对象,拿它跟数字比会直接报 TypeError,
467
+ 而 ``x.value`` 在两边都是一个"纯值" —— 只有这种写法能让两种引擎逐字一致。
468
+ 规则与 ``case_filter`` 一致(那里也是"要数值比较请用 .value")。
469
+ """
470
+ from .derived import DerivedError, to_excel # 延迟导入:derived 依赖 formula,避免循环
471
+
472
+ expression = source.strip()
473
+ if not expression:
474
+ raise FormulaError(f"模板 {self.template_name!r} 的 {{{{ if }}}} 后面没有条件;行内容:{line.strip()!r}")
475
+
476
+ env = build_environment()
477
+ try:
478
+ ast = env.parse("{{ " + expression + " }}")
479
+ except TemplateSyntaxError as exc:
480
+ raise FormulaError(
481
+ f"模板 {self.template_name!r} 的 {{{{ if }}}} 条件语法错误:{exc.message};条件:{expression!r}"
482
+ ) from exc
483
+ body = getattr(ast, "body", [])
484
+ if len(body) != 1 or not isinstance(body[0], nodes.Output) or len(body[0].nodes) != 1:
485
+ raise FormulaError(f"模板 {self.template_name!r} 的 {{{{ if }}}} 条件必须是单个表达式:{expression!r}")
486
+ node = body[0].nodes[0]
487
+ self._require_attribute_access(node, expression, line)
488
+
489
+ def resolve(name: str, attribute: str | None = None) -> str:
490
+ return self._condition_ref(name, attribute, case_axis)
491
+
492
+ try:
493
+ excel = to_excel(
494
+ expression,
495
+ name=f"{self.template_name} 的 {{{{ if }}}} 条件",
496
+ resolve=resolve,
497
+ env=env,
498
+ condition=True,
499
+ )
500
+ except DerivedError as exc:
501
+ raise FormulaError(f"{exc};行内容:{line.strip()!r}") from exc
502
+
503
+ if isinstance(node, (nodes.Compare, nodes.And, nodes.Or, nodes.Not)):
504
+ return excel
505
+ if isinstance(node, (nodes.Name, nodes.Getattr)):
506
+ # 按真假判断:数值比 0,其余比空串(与 Python 侧 bool(value) 的直觉一致)
507
+ # 注意别把引号写进 f-string 的表达式里 —— 那是 Python 3.12 才允许的语法,
508
+ # 而本项目的支持下限是 3.11(ruff 的 target-version 会拦住,见 pyproject.toml)。
509
+ compared = "0" if self._is_numeric_node(node) else '""'
510
+ return f"({excel}<>{compared})"
511
+ if isinstance(node, nodes.Const):
512
+ numeric = isinstance(node.value, (int, float)) and not isinstance(node.value, bool)
513
+ compared = "0" if numeric else '""'
514
+ return f"({excel}<>{compared})"
515
+ raise FormulaError(
516
+ f"模板 {self.template_name!r} 的 {{{{ if }}}} 条件必须是比较或逻辑表达式"
517
+ f'(例如 draft.value > 20、kind.value == "EXT"、not flag.value),'
518
+ f"或直接写一个值按真假判断;当前条件:{expression!r}"
519
+ )
520
+
521
+ def _condition_ref(self, name: str, attribute: str | None, case_axis: int) -> str:
522
+ if attribute is None and name in self._CONDITION_SPECIAL_NAMES:
523
+ return self.expression(name, case_axis=case_axis, line=name)
524
+ ref = self.resolve(name, case_axis)
525
+ if attribute is None:
526
+ return ref.combined()
527
+ if attribute in _VALUE_ALIASES:
528
+ return ref.value()
529
+ if attribute == "prefix":
530
+ return ref.decor("prefix")
531
+ if attribute == "suffix":
532
+ return ref.decor("suffix")
533
+ raise FormulaError(
534
+ f"模板 {self.template_name!r} 的 {{{{ if }}}} 条件里不支持 .{attribute}"
535
+ f"(变量 {name!r}):只支持 .value / .text / .prefix / .suffix"
536
+ )
537
+
538
+ def _require_attribute_access(self, node, expression: str, line: str) -> None:
539
+ """条件里的变量必须带属性;裸变量(除 ``case_name`` / ``template_name``)直接报错。"""
540
+
541
+ def walk(current, parent) -> None:
542
+ if (
543
+ isinstance(current, nodes.Name)
544
+ and not isinstance(parent, nodes.Getattr)
545
+ and current.name not in self._CONDITION_SPECIAL_NAMES
546
+ ):
547
+ raise FormulaError(
548
+ f"模板 {self.template_name!r} 的 {{{{ if }}}} 条件里裸写了变量 "
549
+ f"{current.name!r}:请指明要比较哪一部分 —— 数值比较写 "
550
+ f"{current.name}.value(快照模式里裸变量是 VarValue 对象,"
551
+ f"拿它跟数字比会直接报错);条件:{expression!r}"
552
+ )
553
+ for child in current.iter_child_nodes():
554
+ walk(child, current)
555
+
556
+ walk(node, None)
557
+
558
+ def _is_numeric_node(self, node) -> bool:
559
+ if isinstance(node, nodes.Getattr) and isinstance(node.node, nodes.Name):
560
+ return self._is_numeric(node.node.name)
561
+ return False
562
+
563
+ def _is_numeric(self, name: str) -> bool:
564
+ definition = self._globals.get(name) or self._locals.get(name)
565
+ if definition is None:
566
+ return False
567
+ if definition.type in ("int", "float"):
568
+ return True
569
+ default = definition.default
570
+ return isinstance(default, (int, float)) and not isinstance(default, bool)
571
+
572
+ # -- 递归编译 ---------------------------------------------------------- #
573
+ def _compile_tokens(self, tokens: list[_Token], *, case_axis: int, line: str) -> str:
574
+ index = 0
575
+
576
+ def join(parts: list[str]) -> str:
577
+ return "&".join(parts) if parts else '""'
578
+
579
+ def parse_block(stop: tuple[str, ...]) -> str:
580
+ nonlocal index
581
+ parts: list[str] = []
582
+ while index < len(tokens):
583
+ token = tokens[index]
584
+ if token.kind == "tag":
585
+ words = token.value.split()
586
+ keyword = words[0] if words else ""
587
+ if keyword in stop:
588
+ return join(parts)
589
+ if keyword == "if":
590
+ index += 1
591
+ parts.append(parse_if())
592
+ continue
593
+ if keyword in ("else", "elif", "endif"):
594
+ raise FormulaError(
595
+ f"模板 {self.template_name!r} 里的 {{% {keyword} %}} 没有对应的 "
596
+ f"{{% if %}};行内容:{line.strip()!r}"
597
+ )
598
+ raise FormulaError(
599
+ f"模板 {self.template_name!r} 的公式模式不支持 {{% {keyword} %}}:"
600
+ "行内只支持 {% if %} / {% else %} / {% endif %}(因为一行对应一个单元格,"
601
+ "循环与跨行分支没法用单元格引用表达);请把该模板改回 engine: snapshot。"
602
+ f"行内容:{line.strip()!r}"
603
+ )
604
+ index += 1
605
+ if token.kind == "text":
606
+ if token.value:
607
+ parts.append(_quote_text(token.value))
608
+ elif token.kind == "expr":
609
+ try:
610
+ parts.append(self.expression(token.value, case_axis=case_axis, line=line))
611
+ except FormulaError as exc:
612
+ # 统一带上出错的行,方便在几十行的模板里定位
613
+ raise FormulaError(f"{exc};行内容:{line.strip()!r}") from exc
614
+ # kind == "comment":整段丢掉
615
+ if stop:
616
+ raise FormulaError(
617
+ f"模板 {self.template_name!r} 的 {{{{ if }}}} 没有闭合(这一行少了 "
618
+ f"{{% endif %}})。公式模式**每行对应一个单元格**,所以 {{% if %}} 必须"
619
+ f"写在同一行内 —— 要写跨行的 {{{{ if }}}} 就给这个模板加 engine: snapshot;"
620
+ f"行内容:{line.strip()!r}"
621
+ )
622
+ return join(parts)
623
+
624
+ def parse_if() -> str:
625
+ nonlocal index
626
+ condition_source = tokens[index - 1].value[len("if") :].strip()
627
+ then_expr = parse_block(("else", "elif", "endif"))
628
+ else_expr = '""'
629
+ if tokens[index].value.split()[0] == "else":
630
+ index += 1
631
+ else_expr = parse_block(("endif",))
632
+ index += 1 # 跳过 endif
633
+ condition = self.condition(condition_source, case_axis=case_axis, line=line)
634
+ return f"IF({condition},{then_expr},{else_expr})"
635
+
636
+ return parse_block(())
637
+
638
+
639
+ # --------------------------------------------------------------------------- #
640
+ # 对外 API
641
+ # --------------------------------------------------------------------------- #
642
+ def compile_line(
643
+ line: str,
644
+ *,
645
+ config: ProjectConfig,
646
+ template_name: str,
647
+ case_axis: int,
648
+ relative_case_column: bool = True,
649
+ ) -> str:
650
+ """编译单独一行(主要给测试用)。"""
651
+ return _Compiler(config, template_name, relative_case_column=relative_case_column).line(line, case_axis=case_axis)
652
+
653
+
654
+ def compile_formulas(
655
+ template: TemplateDef,
656
+ config: ProjectConfig,
657
+ *,
658
+ case_axes: Sequence[int],
659
+ source: str | None = None,
660
+ ) -> list[list[str]]:
661
+ """按 Case 编译整个模板:``[case][line] = 公式``。
662
+
663
+ :param source: 外部模板文件(``template_file``)的源码;不给则用内联 ``code``。
664
+ """
665
+ text = template.source_code if source is None else source
666
+ if not text.strip():
667
+ raise FormulaError(f"模板 {template.name!r} 没有可编译的源码(内联 code 为空且未提供 template_file 内容)")
668
+ compiler = _Compiler(
669
+ config,
670
+ template.name,
671
+ relative_case_column=template.direction == "horizontal",
672
+ )
673
+ lines = text.splitlines()
674
+ return [[compiler.line(line, case_axis=column) for line in lines] for column in case_axes]