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,685 @@
1
+ """公式求值器:把 ``engine: excel`` 写出的公式在 Python 里算一遍。
2
+
3
+ 为什么需要它
4
+ ------------
5
+ 公式模式的工作簿里,``Output`` 表每一格是 Excel 公式,而 ``openpyxl`` **不会算公式**。
6
+ 如果 ``check`` 只比"公式文本是否与当前 YAML 一致",就会留下一个空档:
7
+
8
+ * 工具侧:只比公式;
9
+ * 用户侧:打开 Excel 看到的那段代码,**谁也没验过**它等于 ``--outdir`` 导出的那份。
10
+
11
+ 本模块实现公式子集(``&`` 拼接 / ``IF`` / ``ISBLANK`` / ``TEXT`` / ``INDEX``+``MATCH`` /
12
+ 整列引用 / 单元格引用 / 字符串与数字字面量)的一个小求值器,把输出表里的公式逐格算成文本,
13
+ 再与 :func:`excel_codegen.renderer.render_all` 的结果逐行比对。两边一致 ⇒
14
+ "Excel 里看到的"与"命令行导出的"是同一段代码。
15
+
16
+ 它**解析的是工作簿里真实的公式文本**(不是重新生成一遍),所以能抓到"列标指错"
17
+ "该用 ISBLANK 却用了 =''" 这类**生成端**的 bug —— 这是它存在的意义。
18
+
19
+ 局限
20
+ ----
21
+ * 参数单元格如果本身是公式(用户手写 `=L/10`),离线读不到值 → 抛
22
+ :class:`FormulaEvalError`,调用方应当降级为"只比公式文本"并给出提示。
23
+ * ``TEXT()`` 只实现本工具生成的两种格式串(``0`` / ``0.############``)。
24
+ * 不实现的函数/语法一律报错,不猜。
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from collections.abc import Mapping, Sequence
31
+ from typing import Any, Protocol
32
+
33
+ from .utils import RenderError, column_index_to_letter, to_text
34
+
35
+ __all__ = [
36
+ "Cell",
37
+ "ColRef",
38
+ "Evaluator",
39
+ "FormulaEvalError",
40
+ "RowRef",
41
+ "WorkbookReader",
42
+ "evaluate_formula",
43
+ "evaluate_template_values",
44
+ ]
45
+
46
+
47
+ class FormulaEvalError(RenderError):
48
+ """公式无法离线求值(语法超出子集,或引用了公式单元格)。"""
49
+
50
+
51
+ # --------------------------------------------------------------------------- #
52
+ # 词法
53
+ # --------------------------------------------------------------------------- #
54
+ _TOKEN_RE = re.compile(
55
+ r"""
56
+ (?P<string>"(?:[^"]|"")*")
57
+ | (?P<sheet>'(?:[^']|'')*')
58
+ | (?P<number>\d+(?:\.\d*)?)
59
+ | (?P<ident>[A-Za-z_][A-Za-z0-9_.]*)
60
+ | (?P<punct>[&(),!=:$+\-*/^<>])
61
+ | (?P<space>\s+)
62
+ """,
63
+ re.VERBOSE,
64
+ )
65
+
66
+
67
+ #: ``A2`` 这种"列标+行号"挤在一个词法 token 里的形态。
68
+ _CELL_RE = re.compile(r"([A-Za-z]{1,3})(\d+)")
69
+
70
+
71
+ def tokenize(text: str) -> list[tuple[str, str]]:
72
+ out: list[tuple[str, str]] = []
73
+ index = 0
74
+ while index < len(text):
75
+ match = _TOKEN_RE.match(text, index)
76
+ if not match:
77
+ raise FormulaEvalError(f"不能识别的公式片段: {text[index : index + 30]!r}")
78
+ index = match.end()
79
+ if match.lastgroup == "space":
80
+ continue
81
+ out.append((match.lastgroup or "", match.group()))
82
+ return out
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # 值模型
87
+ # --------------------------------------------------------------------------- #
88
+ class Cell:
89
+ """单元格引用(或 INDEX 的结果):值 + 是否真空白(``ISBLANK`` 用)。"""
90
+
91
+ __slots__ = ("blank", "value", "where")
92
+
93
+ def __init__(self, value: Any, blank: bool, where: str = "") -> None:
94
+ self.value = value
95
+ self.blank = blank
96
+ self.where = where
97
+
98
+ def __repr__(self) -> str: # pragma: no cover - 调试友好
99
+ return f"Cell({self.value!r}, blank={self.blank}, {self.where})"
100
+
101
+
102
+ class ColRef:
103
+ """整列引用(只作为 ``INDEX`` / ``MATCH`` 的参数出现)。
104
+
105
+ ``end`` 不为空时是**整列区间**(``$A:$ZZ``)—— 纵向布局的 ``INDEX`` 拿它当二维矩形用。
106
+ """
107
+
108
+ __slots__ = ("column", "end", "sheet")
109
+
110
+ def __init__(self, sheet: str, column: str, end: str | None = None) -> None:
111
+ self.sheet = sheet
112
+ self.column = column
113
+ self.end = end
114
+
115
+ def __repr__(self) -> str: # pragma: no cover - 调试友好
116
+ suffix = f":{self.end}" if self.end else ""
117
+ return f"ColRef({self.sheet}!{self.column}{suffix})"
118
+
119
+
120
+ class RowRef:
121
+ """整行引用(``'Local Parameter'!$1:$1``)—— 纵向布局的 ``MATCH`` 拿它按变量名找列。"""
122
+
123
+ __slots__ = ("row", "sheet")
124
+
125
+ def __init__(self, sheet: str, row: int) -> None:
126
+ self.sheet = sheet
127
+ self.row = row
128
+
129
+ def __repr__(self) -> str: # pragma: no cover - 调试友好
130
+ return f"RowRef({self.sheet}!{self.row}:{self.row})"
131
+
132
+
133
+ def as_text(value: Any) -> str:
134
+ """Excel 把值拼进字符串时的形态。
135
+
136
+ 数值走 General 规则 = **最多 15 位有效数字**,这正是 :func:`utils.to_text` 的规则,
137
+ 所以这里直接复用它(``True``/``False`` 仍然按 Excel 的 ``TRUE``/``FALSE``)。
138
+ """
139
+ if isinstance(value, bool):
140
+ return "TRUE" if value else "FALSE"
141
+ return to_text(value)
142
+
143
+
144
+ def format_number(value: Any, fmt: str) -> str:
145
+ """只实现本工具生成的两种格式串:``0`` 与 ``0.############``。"""
146
+ if value is None or value == "":
147
+ return ""
148
+ try:
149
+ number = float(value)
150
+ except (TypeError, ValueError):
151
+ return str(value)
152
+ if fmt == "0":
153
+ return str(round(number))
154
+ if fmt.startswith("0."):
155
+ decimals = len(fmt) - 2
156
+ text = f"{number:.{decimals}f}".rstrip("0").rstrip(".")
157
+ return text if text not in ("", "-") else "0"
158
+ raise FormulaEvalError(f"不支持的数字格式 {fmt!r}")
159
+
160
+
161
+ # --------------------------------------------------------------------------- #
162
+ # 数据源
163
+ # --------------------------------------------------------------------------- #
164
+ class SheetReader(Protocol):
165
+ """求值器眼里的"工作簿"。"""
166
+
167
+ def cell_value(self, sheet: str, column: int, row: int) -> tuple[Any, bool]:
168
+ """返回 ``(值, 是否真空白)``。"""
169
+
170
+ def name_row(self, sheet: str, variable: str) -> int:
171
+ """在 A 列按**变量名**查行号(等同于 MATCH)。"""
172
+
173
+ def name_column(self, sheet: str, variable: str) -> int:
174
+ """在第 1 行按**变量名**查列号(纵向布局的 MATCH)。"""
175
+
176
+
177
+ class WorkbookReader:
178
+ """从 ``openpyxl`` 工作簿读值。
179
+
180
+ 参数格里如果是**公式**(例如派生参数 ``derived`` 写成的公式),会递归求值 ——
181
+ 所以"公式引用公式"也能算出来;出现循环引用会明确报错。
182
+ """
183
+
184
+ def __init__(self, workbook, *, evaluate=None) -> None:
185
+ self.workbook = workbook
186
+ #: 由 :class:`Evaluator` 装上:公式文本 -> 文本
187
+ self.evaluate = evaluate
188
+ self._name_rows: dict[str, dict[str, int]] = {}
189
+ self._name_columns: dict[str, dict[str, int]] = {}
190
+ self._cells: dict[tuple[str, int, int], tuple[Any, bool]] = {}
191
+ self._active: set[tuple[str, int, int]] = set()
192
+
193
+ def bind_evaluator(self, evaluate) -> None:
194
+ """把求值器挂上来(嵌套公式要用)。"""
195
+ self.evaluate = evaluate
196
+
197
+ def cell_value(self, sheet: str, column: int, row: int) -> tuple[Any, bool]:
198
+ key = (sheet, column, row)
199
+ if key in self._cells:
200
+ return self._cells[key]
201
+ try:
202
+ worksheet = self.workbook[sheet]
203
+ except KeyError as exc:
204
+ raise FormulaEvalError(f"公式引用了不存在的工作表 {sheet!r}") from exc
205
+
206
+ value = worksheet.cell(row=row, column=column).value
207
+ if isinstance(value, str) and value.startswith("="):
208
+ if self.evaluate is None:
209
+ raise FormulaEvalError(f"{sheet}!{column_index_to_letter(column)}{row} 是公式,但没有可用的求值器")
210
+ if key in self._active:
211
+ raise FormulaEvalError(f"{sheet}!{column_index_to_letter(column)}{row} 出现循环引用")
212
+ self._active.add(key)
213
+ try:
214
+ result = (self.evaluate(value), False)
215
+ finally:
216
+ self._active.discard(key)
217
+ else:
218
+ result = (value, value is None or value == "")
219
+ self._cells[key] = result
220
+ return result
221
+
222
+ def name_row(self, sheet: str, variable: str) -> int:
223
+ cache = self._name_rows.setdefault(sheet, {})
224
+ if not cache:
225
+ try:
226
+ worksheet = self.workbook[sheet]
227
+ except KeyError as exc:
228
+ raise FormulaEvalError(f"公式引用了不存在的工作表 {sheet!r}") from exc
229
+ for row in range(1, worksheet.max_row + 1):
230
+ text = worksheet.cell(row=row, column=1).value
231
+ if text is not None and str(text).strip():
232
+ cache.setdefault(str(text).strip(), row)
233
+ if variable not in cache:
234
+ raise FormulaEvalError(f"{sheet} 表里没有变量 {variable!r}")
235
+ return cache[variable]
236
+
237
+ def name_column(self, sheet: str, variable: str) -> int:
238
+ cache = self._name_columns.setdefault(sheet, {})
239
+ if not cache:
240
+ try:
241
+ worksheet = self.workbook[sheet]
242
+ except KeyError as exc:
243
+ raise FormulaEvalError(f"公式引用了不存在的工作表 {sheet!r}") from exc
244
+ for column in range(1, worksheet.max_column + 1):
245
+ text = worksheet.cell(row=1, column=column).value
246
+ if text is not None and str(text).strip():
247
+ cache.setdefault(str(text).strip(), column)
248
+ if variable not in cache:
249
+ raise FormulaEvalError(f"{sheet} 表第 1 行里没有变量 {variable!r}")
250
+ return cache[variable]
251
+
252
+
253
+ # --------------------------------------------------------------------------- #
254
+ # 求值
255
+ # --------------------------------------------------------------------------- #
256
+ class Evaluator:
257
+ """递归下降求值器(只覆盖本工具生成的公式子集)。"""
258
+
259
+ def __init__(self, reader: SheetReader, sheet_names: Mapping[str, str]) -> None:
260
+ self.reader = reader
261
+ #: 小写表名 -> 真实表名(Excel 的表名匹配不区分大小写)
262
+ self.sheets = {name.strip().lower(): name for name in sheet_names.values()}
263
+ #: 当前词法单元与游标。**可重入**:参数格里本身是公式时,
264
+ #: 嵌套求值会保存/恢复这一对,所以这里要先有个初值。
265
+ self.tokens: list[tuple[str, str]] = []
266
+ self.index = 0
267
+ bind = getattr(reader, "bind_evaluator", None)
268
+ if callable(bind):
269
+ bind(self.evaluate) # 让"公式格里的公式"也能递归求值
270
+
271
+ # -- 入口 -------------------------------------------------------------- #
272
+ def evaluate(self, formula: Any) -> str:
273
+ if not isinstance(formula, str) or not formula.startswith("="):
274
+ return as_text(formula)
275
+ # 可重入:嵌套求值(参数格本身是公式)会递归调用本方法
276
+ saved = (self.tokens, self.index)
277
+ self.tokens = tokenize(formula[1:])
278
+ self.index = 0
279
+ try:
280
+ value = self.comparison()
281
+ if self.index != len(self.tokens):
282
+ raise FormulaEvalError(f"公式没有解析完: {self.tokens[self.index :]}")
283
+ return as_text(self._scalar(value))
284
+ finally:
285
+ self.tokens, self.index = saved
286
+
287
+ # -- 语法 -------------------------------------------------------------- #
288
+ # Excel 的优先级:比较 < 拼接(&) < 加减 < 乘除 < 幂 < 一元 < 项
289
+ def comparison(self):
290
+ left = self.concat()
291
+ while True:
292
+ if self._peek("="):
293
+ self._next()
294
+ right = self.concat()
295
+ left = as_text(self._scalar(left)) == as_text(self._scalar(right))
296
+ elif self._peek("!"):
297
+ self._next()
298
+ self._expect("=")
299
+ right = self.concat()
300
+ left = as_text(self._scalar(left)) != as_text(self._scalar(right))
301
+ elif self._peek("<") or self._peek(">"):
302
+ operator = self._next()[1]
303
+ if self._peek("="):
304
+ self._next()
305
+ operator += "="
306
+ elif operator == "<" and self._peek(">"):
307
+ self._next()
308
+ operator = "<>"
309
+ right = self.concat()
310
+ left = self._compare(left, right, operator)
311
+ else:
312
+ break
313
+ return left
314
+
315
+ def _compare(self, left, right, operator: str) -> bool:
316
+ """``<`` / ``>`` / ``<=`` / ``>=`` / ``<>``。
317
+
318
+ Excel 对**数值**按数值比、对**文本**按字典序比 —— 这里照做(能读成数字就当数字)。
319
+ 行内 ``{% if %}`` 编译出来的条件会用到这些运算符。
320
+ """
321
+ a, b = self._scalar(left), self._scalar(right)
322
+ try:
323
+ x, y = self._number(a), self._number(b)
324
+ except FormulaEvalError:
325
+ x, y = as_text(a), as_text(b)
326
+ if operator == "<":
327
+ return x < y
328
+ if operator == ">":
329
+ return x > y
330
+ if operator == "<=":
331
+ return x <= y
332
+ if operator == ">=":
333
+ return x >= y
334
+ if operator == "<>":
335
+ return x != y
336
+ raise FormulaEvalError(f"不支持的比较运算符 {operator!r}")
337
+
338
+ def concat(self):
339
+ left = self.additive()
340
+ while self._peek("&"):
341
+ self._next()
342
+ right = self.additive()
343
+ left = as_text(self._scalar(left)) + as_text(self._scalar(right))
344
+ return left
345
+
346
+ def additive(self):
347
+ left = self.multiplicative()
348
+ while self._peek("+") or self._peek("-"):
349
+ operator = self._next()[1]
350
+ right = self.multiplicative()
351
+ left = (
352
+ self._number(left) + self._number(right)
353
+ if operator == "+"
354
+ else self._number(left) - self._number(right)
355
+ )
356
+ return left
357
+
358
+ def multiplicative(self):
359
+ left = self.power()
360
+ while self._peek("*") or self._peek("/"):
361
+ operator = self._next()[1]
362
+ right = self.power()
363
+ if operator == "*":
364
+ left = self._number(left) * self._number(right)
365
+ else:
366
+ divisor = self._number(right)
367
+ if divisor == 0:
368
+ raise FormulaEvalError("公式里出现除零")
369
+ left = self._number(left) / divisor
370
+ return left
371
+
372
+ def power(self):
373
+ left = self.unary()
374
+ while self._peek("^"):
375
+ self._next()
376
+ right = self.unary()
377
+ left = self._number(left) ** self._number(right)
378
+ return left
379
+
380
+ def unary(self):
381
+ if self._peek("-"):
382
+ self._next()
383
+ return -self._number(self.unary())
384
+ if self._peek("+"):
385
+ self._next()
386
+ return self._number(self.unary())
387
+ if self._peek("("):
388
+ self._next()
389
+ value = self.comparison()
390
+ self._expect(")")
391
+ return value
392
+ return self.term()
393
+
394
+ def _number(self, value):
395
+ """取数值(Cell 取值;文本按 Excel 的隐式转换规则)。"""
396
+ raw = self._scalar(value)
397
+ if raw is None or raw == "":
398
+ return 0 # Excel 把空单元格当 0 参与算术
399
+ if isinstance(raw, bool):
400
+ return 1 if raw else 0
401
+ try:
402
+ return float(raw)
403
+ except (TypeError, ValueError):
404
+ raise FormulaEvalError(f"{raw!r} 不是数值,无法参与算术") from None
405
+
406
+ def term(self):
407
+ kind, text = self._next()
408
+ if kind == "punct" and text == "$":
409
+ # 绝对行号当数字用:纵向输入 + 纵向输出时 INDEX 的行号是 `$3`(见 formula.local_cell)。
410
+ # 单元格引用(`$A$1`)走 reference(),不会落到这里 —— 它前面一定带着表名。
411
+ if self._peek("$"):
412
+ self._next()
413
+ number_kind, number_text = self._next()
414
+ if number_kind != "number":
415
+ raise FormulaEvalError(f"$ 后面应该是行号,实际是 {number_text!r}")
416
+ return float(number_text) if "." in number_text else int(number_text)
417
+ if kind == "string":
418
+ return text[1:-1].replace('""', '"')
419
+ if kind == "number":
420
+ return float(text) if "." in text else int(text)
421
+ if kind == "sheet" or (kind == "ident" and self._peek("!")):
422
+ return self.reference(kind, text)
423
+ if kind == "ident":
424
+ upper = text.upper()
425
+ if not self._peek("("):
426
+ if upper in {"TRUE", "FALSE"}:
427
+ return upper == "TRUE"
428
+ raise FormulaEvalError(f"裸标识符 {text!r} 无法求值")
429
+ self._next() # 吃掉 "("
430
+ if upper == "IF":
431
+ condition = self.comparison()
432
+ self._expect(",")
433
+ yes = self.comparison()
434
+ self._expect(",")
435
+ no = self.comparison()
436
+ self._expect(")")
437
+ return yes if self.truthy(condition) else no
438
+ if upper in {"AND", "OR"}:
439
+ values = [self.comparison()]
440
+ while self._peek(","):
441
+ self._next()
442
+ values.append(self.comparison())
443
+ self._expect(")")
444
+ flags = [self.truthy(item) for item in values]
445
+ return all(flags) if upper == "AND" else any(flags)
446
+ if upper == "NOT":
447
+ value = self.comparison()
448
+ self._expect(")")
449
+ return not self.truthy(value)
450
+ if upper == "ISBLANK":
451
+ argument = self.comparison()
452
+ self._expect(")")
453
+ return bool(argument.blank) if isinstance(argument, Cell) else False
454
+ if upper == "TEXT":
455
+ value = self.comparison()
456
+ self._expect(",")
457
+ fmt = self.comparison()
458
+ self._expect(")")
459
+ return format_number(self._scalar(value), self._scalar(fmt))
460
+ if upper == "INDEX":
461
+ ref = self.comparison()
462
+ self._expect(",")
463
+ row = self.comparison()
464
+ column = None
465
+ if self._peek(","):
466
+ self._next()
467
+ column = self.comparison()
468
+ self._expect(")")
469
+ if not isinstance(ref, ColRef):
470
+ raise FormulaEvalError("INDEX 的第一个参数不是整列引用")
471
+ row_number = int(self._scalar(row))
472
+ column_number = _column_number(ref.column) if column is None else int(self._scalar(column))
473
+ value, blank = self.reader.cell_value(ref.sheet, column_number, row_number)
474
+ letter = column_index_to_letter(column_number)
475
+ return Cell(value, blank, f"{ref.sheet}!{letter}{row_number}")
476
+ if upper == "MATCH":
477
+ needle = self.comparison()
478
+ self._expect(",")
479
+ ref = self.comparison()
480
+ self._expect(",")
481
+ self.comparison() # 0(精确匹配)
482
+ self._expect(")")
483
+ if isinstance(ref, RowRef):
484
+ return self.reader.name_column(ref.sheet, str(self._scalar(needle)))
485
+ if not isinstance(ref, ColRef):
486
+ raise FormulaEvalError("MATCH 的第二个参数不是整列 / 整行引用")
487
+ return self.reader.name_row(ref.sheet, str(self._scalar(needle)))
488
+ if upper in {"MIN", "MAX", "ABS", "TRUNC", "MOD"}:
489
+ arguments = [self.comparison()]
490
+ while self._peek(","):
491
+ self._next()
492
+ arguments.append(self.comparison())
493
+ self._expect(")")
494
+ numbers = [self._number(argument) for argument in arguments]
495
+ if upper == "MIN":
496
+ return min(numbers)
497
+ if upper == "MAX":
498
+ return max(numbers)
499
+ if upper == "ABS":
500
+ return abs(numbers[0])
501
+ if upper == "TRUNC":
502
+ return int(numbers[0])
503
+ divisor = numbers[1]
504
+ if divisor == 0:
505
+ raise FormulaEvalError("MOD 的除数为 0")
506
+ return numbers[0] - divisor * int(numbers[0] / divisor)
507
+ raise FormulaEvalError(f"不支持的函数 {upper}()")
508
+ raise FormulaEvalError(f"看不懂的记号 {kind}={text!r}")
509
+
510
+ def reference(self, kind: str, text: str):
511
+ sheet: str | None = None
512
+ if kind == "sheet":
513
+ sheet = text[1:-1].replace("''", "'")
514
+ self._expect("!")
515
+ if self._peek("$"):
516
+ self._next()
517
+ token_kind, column = self._next()
518
+ if token_kind == "number":
519
+ # 整行引用:'sheet'!$1:$1
520
+ if not self._peek(":"):
521
+ raise FormulaEvalError(f"看不懂的引用 {column!r}")
522
+ self._next()
523
+ if self._peek("$"):
524
+ self._next()
525
+ _, second = self._next()
526
+ if not str(second).lstrip("$") == str(column).lstrip("$"):
527
+ raise FormulaEvalError(f"只支持整行引用,不支持行区间 {column}:{second}")
528
+ if sheet is None:
529
+ raise FormulaEvalError("公式里出现了没有表名的整行引用")
530
+ return RowRef(sheet, int(str(column).lstrip("$")))
531
+ if token_kind != "ident":
532
+ raise FormulaEvalError(f"引用里缺列标: {column!r}")
533
+ merged = _CELL_RE.fullmatch(column)
534
+ if merged is not None and not self._peek(":"):
535
+ # 列标 + 行号被词法器吃成了一个 token('Local Parameter'!A2,
536
+ # 纵向布局的 case_name 引用就是这样)。A:A 那种整列引用不会匹配到这里。
537
+ if sheet is None:
538
+ raise FormulaEvalError("公式里出现了没有表名的单元格引用")
539
+ letter, row_text = merged.group(1).upper(), merged.group(2)
540
+ value, blank = self.reader.cell_value(sheet, _column_number(letter), int(row_text))
541
+ return Cell(value, blank, f"{sheet}!{letter}{row_text}")
542
+ if self._peek(":"):
543
+ self._next()
544
+ if self._peek("$"):
545
+ self._next()
546
+ _, second = self._next()
547
+ if sheet is None:
548
+ raise FormulaEvalError("公式里出现了没有表名的整列引用")
549
+ if second.upper() == column.upper():
550
+ return ColRef(sheet, column.upper())
551
+ return ColRef(sheet, column.upper(), second.upper())
552
+ if self._peek("$"):
553
+ self._next()
554
+ row_kind, row_text = self._next()
555
+ if row_kind != "number":
556
+ raise FormulaEvalError(f"引用里缺行号: {row_text!r}")
557
+ if sheet is None:
558
+ raise FormulaEvalError("公式里出现了没有表名的单元格引用")
559
+ row = int(float(row_text))
560
+ value, blank = self.reader.cell_value(sheet, _column_number(column), row)
561
+ return Cell(value, blank, f"{sheet}!{column}{row}")
562
+
563
+ # -- 小工具 ------------------------------------------------------------ #
564
+ def _next(self) -> tuple[str, str]:
565
+ if self.index >= len(self.tokens):
566
+ raise FormulaEvalError("公式意外结束")
567
+ token = self.tokens[self.index]
568
+ self.index += 1
569
+ return token
570
+
571
+ def _peek(self, punct: str) -> bool:
572
+ return self.index < len(self.tokens) and self.tokens[self.index] == ("punct", punct)
573
+
574
+ def _expect(self, punct: str) -> None:
575
+ if not self._peek(punct):
576
+ got = self.tokens[self.index] if self.index < len(self.tokens) else "结束"
577
+ raise FormulaEvalError(f"期望 {punct!r},实际 {got}")
578
+ self.index += 1
579
+
580
+ @staticmethod
581
+ def _scalar(value: Any) -> Any:
582
+ return value.value if isinstance(value, Cell) else value
583
+
584
+ @staticmethod
585
+ def truthy(value: Any) -> bool:
586
+ if isinstance(value, Cell):
587
+ return value.blank is False and value.value not in (0, "", None)
588
+ if isinstance(value, str):
589
+ return value.upper() not in ("", "FALSE", "0")
590
+ return bool(value)
591
+
592
+
593
+ def _column_number(letter: str) -> int:
594
+ number = 0
595
+ for char in letter.upper():
596
+ if not char.isalpha():
597
+ raise FormulaEvalError(f"非法列标 {letter!r}")
598
+ number = number * 26 + (ord(char) - ord("A") + 1)
599
+ return number
600
+
601
+
602
+ # --------------------------------------------------------------------------- #
603
+ # 面向工作簿的便捷入口
604
+ # --------------------------------------------------------------------------- #
605
+ def sheet_names_of(config) -> list[str]:
606
+ names = [
607
+ config.excel.sheets.global_,
608
+ config.excel.sheets.local,
609
+ *config.excel.sheets.outputs,
610
+ ]
611
+ for optional in (config.excel.template_sheet, config.excel.howto_sheet):
612
+ if optional:
613
+ names.append(optional)
614
+ return names
615
+
616
+
617
+ def evaluate_formula(formula: Any, *, reader: SheetReader, config) -> str:
618
+ """求值单格公式(主要给测试用)。"""
619
+ evaluator = Evaluator(reader, {name: name for name in sheet_names_of(config)})
620
+ return evaluator.evaluate(formula)
621
+
622
+
623
+ def evaluate_template_values(
624
+ workbook,
625
+ config,
626
+ template,
627
+ case_names: Sequence[str],
628
+ ) -> dict[str, list[str]]:
629
+ """把某个模板在输出表里的公式逐格算出来:``{case 名: [每行文本]}``。
630
+
631
+ 读的是**工作簿里真实的公式文本**,所以能发现"公式本身写错了"(列标、引用、函数)。
632
+
633
+ ``case_names`` 是**这个模板实际渲染出来的 Case,按输出顺序**(``case_filter`` 跳过的
634
+ 不算)—— 与写回时的列/行一一对应。表头行(``write_case_headers``)开着时会**核对**
635
+ 每一列/行是不是这个名字:对不上就报错,而不是悄悄把别的工况的值当成它的
636
+ (传子集 / 表是旧的都算对不上)。
637
+ """
638
+ from .utils import parse_cell
639
+
640
+ evaluator = Evaluator(WorkbookReader(workbook), {name: name for name in sheet_names_of(config)})
641
+ worksheet = workbook[template.output_sheet]
642
+ column, row = parse_cell(template.start_cell)
643
+ horizontal = template.direction == "horizontal"
644
+ header_at = None
645
+ if template.write_case_headers:
646
+ # 与 _write_horizontal / _write_vertical 写表头的位置保持一致
647
+ if horizontal and row > 1:
648
+ header_at = ("row", row - 1)
649
+ elif not horizontal and column > 1:
650
+ header_at = ("column", column - 1)
651
+
652
+ blocks: dict[str, list[str]] = {}
653
+
654
+ for offset, case_name in enumerate(case_names):
655
+ if header_at is not None:
656
+ where = (header_at[1], column + offset) if header_at[0] == "row" else (row + offset, header_at[1])
657
+ actual = worksheet.cell(row=where[0], column=where[1]).value
658
+ if isinstance(actual, str) and actual.strip() and actual.strip() != case_name:
659
+ axis = "列" if horizontal else "行"
660
+ raise FormulaEvalError(
661
+ f"模板 {template.name!r} 的第 {offset + 1} 个{axis}表头是 {actual.strip()!r},"
662
+ f"但现在要读的是 {case_name!r} —— 输出表与参数对不上"
663
+ "(传的 Case 子集?表是旧的?请重跑 --write-excel)"
664
+ )
665
+
666
+ lines: list[str] = []
667
+ if horizontal:
668
+ current = row
669
+ while True:
670
+ value = worksheet.cell(row=current, column=column + offset).value
671
+ if value is None:
672
+ break
673
+ lines.append(evaluator.evaluate(value))
674
+ current += 1
675
+ else:
676
+ current = column
677
+ target_row = row + offset
678
+ while True:
679
+ value = worksheet.cell(row=target_row, column=current).value
680
+ if value is None:
681
+ break
682
+ lines.append(evaluator.evaluate(value))
683
+ current += 1
684
+ blocks[case_name] = lines
685
+ return blocks