excel2ppt-studio 0.7.1__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.
@@ -0,0 +1,3 @@
1
+ """Excel2PPT Studio 核心包。"""
2
+
3
+ __version__ = "0.7.1"
@@ -0,0 +1,3 @@
1
+ from excel2ppt_studio.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,312 @@
1
+ """命令行入口:``e2p inspect|suggest|build``。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+
11
+ from .core.excel_images import ExcelImageError
12
+ from .core.excel_reader import ExcelDataError, read_excel
13
+ from .core.mapping import ProfileError, build_profile, load_profile, save_profile
14
+ from .core.model import Profile
15
+ from .core.renderer import RenderError, render
16
+ from .core.template_health import check_template
17
+ from .core.template_scanner import TemplateError, scan_template
18
+ from .core.verifier import VerifyError, verify_file
19
+
20
+
21
+ def _print_fields(info) -> None:
22
+ print(f"模板:{info.path}")
23
+ print(
24
+ f"第 {info.slide_index} 页 / 共 {info.slide_count} 页 "
25
+ f"尺寸 {info.width / 360000:.2f}×{info.height / 360000:.2f} cm"
26
+ )
27
+ if info.warnings:
28
+ print("警告:")
29
+ for w in info.warnings:
30
+ print(f" - {w}")
31
+ print()
32
+ header = f"{'ID':<12}{'类型':<8}{'角色':<10}{'位置(cm)':<26}{'内容 / 槽位'}"
33
+ print(header)
34
+ print("-" * 96)
35
+ for f in info.fields:
36
+ x, y, w, h = f.rect.cm
37
+ pos = f"({x},{y}) {w}×{h}"
38
+ if f.kind == "table" and f.table:
39
+ content = f"表格 {f.table.rows}×{f.table.cols}"
40
+ elif f.kind == "image":
41
+ content = f"图片 {f.image.width_px}×{f.image.height_px}" if f.image else "图片"
42
+ elif f.kind == "chart":
43
+ content = "图表"
44
+ else:
45
+ content = (f.text or "").replace("\n", " / ")[:40]
46
+ slots = f" [槽位: {', '.join(f.slot_names)}]" if f.slot_names else ""
47
+ print(f"{f.id:<12}{f.kind:<8}{f.role:<10}{pos:<26}{content}{slots}")
48
+ print()
49
+ print(f"共 {len(info.fields)} 个元素,其中带槽位 {sum(1 for f in info.fields if f.slots)} 个。")
50
+
51
+
52
+ def cmd_inspect(args: argparse.Namespace) -> int:
53
+ info = scan_template(args.template, args.slide)
54
+ if args.json:
55
+ with open(args.json, "w", encoding="utf-8") as f:
56
+ json.dump(info.to_dict(), f, ensure_ascii=False, indent=2)
57
+ f.write("\n")
58
+ print(f"已写出:{args.json}")
59
+ else:
60
+ _print_fields(info)
61
+ return 0
62
+
63
+
64
+ def cmd_suggest(args: argparse.Namespace) -> int:
65
+ info = scan_template(args.template, args.slide)
66
+ sheet = read_excel(args.excel, args.sheet or None, args.header_row)
67
+ profile, notes = build_profile(
68
+ args.template,
69
+ args.excel,
70
+ info,
71
+ sheet.sheet,
72
+ sheet.columns,
73
+ args.threshold,
74
+ header_row=args.header_row,
75
+ )
76
+ print(f"Excel:{os.path.abspath(args.excel)} / 工作表「{sheet.sheet}」")
77
+ print(f"数据行:{len(sheet.rows)} 列:{', '.join(sheet.column_names)}")
78
+ print()
79
+ total_slots = sum(len(f.slots) for f in info.fields if f.kind == "text")
80
+ print(f"自动匹配 {len(profile.bindings)} 项(文本槽位共 {total_slots} 个)")
81
+ for note in notes:
82
+ print(f" ! {note}")
83
+ out = args.out or "profile.json"
84
+ save_profile(profile, out)
85
+ print(f"\nProfile 已保存:{os.path.abspath(out)}")
86
+ print("请人工检查后执行:e2p build --profile <该文件> --out <输出.pptx>")
87
+ return 0
88
+
89
+
90
+ def cmd_titles(args: argparse.Namespace) -> int:
91
+ from .core.titles import TitleError, generate_titles, load_titles, resolve_api_key
92
+
93
+ sheet = read_excel(args.excel, args.sheet or None, args.header_row)
94
+ if args.column not in sheet.column_names:
95
+ print(f"错误:列不存在:{args.column}。可用列:{'、'.join(sheet.column_names)}", file=sys.stderr)
96
+ return 1
97
+
98
+ def to_text(value) -> str:
99
+ if value is None:
100
+ return ""
101
+ if hasattr(value, "isoformat"):
102
+ return value.isoformat()
103
+ return str(value).strip()
104
+
105
+ items = []
106
+ for row in sheet.rows[: args.limit or None]:
107
+ text = to_text(row.get(args.column))
108
+ if text:
109
+ items.append((to_text(row.get(args.key)) or str(len(items) + 1), text))
110
+
111
+ try:
112
+ if args.from_file:
113
+ titles = load_titles(args.from_file)
114
+ else:
115
+ titles = generate_titles(
116
+ items,
117
+ resolve_api_key(args.api_key),
118
+ args.base_url,
119
+ args.model,
120
+ )
121
+ except TitleError as exc:
122
+ print(f"错误:{exc}", file=sys.stderr)
123
+ return 1
124
+
125
+ out = args.out or "titles.json"
126
+ with open(out, "w", encoding="utf-8") as f:
127
+ json.dump(titles, f, ensure_ascii=False, indent=1)
128
+ f.write("\n")
129
+ print(f"已生成 {len(titles)} 条短标题 → {os.path.abspath(out)}")
130
+ long_titles = [(k, v) for k, v in titles.items() if len(v) > 16]
131
+ if long_titles:
132
+ print(f"警告:{len(long_titles)} 条超过 16 字,建议人工压缩。")
133
+ return 0
134
+
135
+
136
+ def cmd_build(args: argparse.Namespace) -> int:
137
+ profile: Profile = load_profile(args.profile)
138
+ excel = args.excel or profile.excel
139
+ template = args.template or profile.template
140
+ if not excel or not template:
141
+ print("错误:Profile 缺少 Excel 或模板路径,请用 --excel/--template 指定。", file=sys.stderr)
142
+ return 1
143
+
144
+ def progress(done: int, total: int) -> None:
145
+ if not args.quiet and not getattr(args, "json", False):
146
+ print(f"\r渲染 {done}/{total}", end="", flush=True)
147
+
148
+ result = render(template, profile, out=args.out, excel=excel, on_progress=progress)
149
+ if args.json:
150
+ print(
151
+ json.dumps(
152
+ {
153
+ "ok": True,
154
+ "output": result.output,
155
+ "rows": result.rows,
156
+ "slides": result.slides,
157
+ "replaced": result.replaced,
158
+ "warnings": result.warnings,
159
+ },
160
+ ensure_ascii=False,
161
+ )
162
+ )
163
+ return 0
164
+ if not args.quiet:
165
+ print()
166
+ print(f"完成:{result.rows} 行数据 → {result.slides} 页幻灯片,替换 {result.replaced} 处")
167
+ if result.warnings:
168
+ print("警告:")
169
+ for w in result.warnings:
170
+ print(f" - {w}")
171
+ print(f"输出:{result.output}")
172
+ return 0
173
+
174
+
175
+ def cmd_web(args: argparse.Namespace) -> int:
176
+ from .web.app import run
177
+
178
+ run(host=args.host, port=args.port, open_browser=not args.no_browser)
179
+ return 0
180
+
181
+
182
+ def cmd_verify(args: argparse.Namespace) -> int:
183
+ result = verify_file(
184
+ args.template,
185
+ args.output,
186
+ slide_index=args.slide,
187
+ allow_image_resize=not args.strict_images,
188
+ max_pages=args.max_pages,
189
+ )
190
+ if getattr(args, "json", False):
191
+ data = result.to_dict()
192
+ data["ok"] = result.ok
193
+ print(json.dumps(data, ensure_ascii=False))
194
+ return 0 if result.ok else 1
195
+ print(f"校验:{result.pages_checked} 页 / {result.shapes_checked} 个元素")
196
+ for issue in result.issues:
197
+ page = f"(第 {issue.page + 1} 页)" if issue.page is not None else ""
198
+ print(f" [{issue.level}] {issue.message}{page}")
199
+ if result.ok:
200
+ print("结论:通过(无结构性差异)")
201
+ return 0
202
+ print("结论:失败(存在版式/格式差异)")
203
+ return 1
204
+
205
+
206
+ def cmd_doctor(args: argparse.Namespace) -> int:
207
+ report = check_template(args.template, args.slide, args.excel)
208
+ if getattr(args, "json", False):
209
+ print(json.dumps(report.to_dict(), ensure_ascii=False))
210
+ return 0 if not report.errors else 1
211
+ print(f"模板体检:{report.score} 分({report.template})")
212
+ for issue in report.issues:
213
+ tag = {"error": "错误", "warning": "警告", "info": "信息"}.get(issue.level, issue.level)
214
+ ref = f" [{issue.field_id}]" if issue.field_id else ""
215
+ print(f" [{tag}] {issue.message}{ref}")
216
+ return 0 if not report.errors else 1
217
+
218
+
219
+ def build_parser() -> argparse.ArgumentParser:
220
+ parser = argparse.ArgumentParser(
221
+ prog="e2p",
222
+ description="Excel2PPT Studio:按单页 PPT 模板把 Excel 逐行生成多页幻灯片。",
223
+ )
224
+ sub = parser.add_subparsers(dest="command", required=True)
225
+
226
+ p_inspect = sub.add_parser("inspect", help="查看模板元素清单")
227
+ p_inspect.add_argument("template", help="模板 .pptx 路径")
228
+ p_inspect.add_argument("--slide", type=int, default=0, help="母版页索引,从 0 开始(默认 0)")
229
+ p_inspect.add_argument("--json", help="把清单写成 JSON")
230
+ p_inspect.set_defaults(func=cmd_inspect)
231
+
232
+ p_suggest = sub.add_parser("suggest", help="按列名自动生成初始映射")
233
+ p_suggest.add_argument("--excel", required=True, help="Excel 路径")
234
+ p_suggest.add_argument("--template", required=True, help="模板 .pptx 路径")
235
+ p_suggest.add_argument("--slide", type=int, default=0, help="母版页索引(默认 0)")
236
+ p_suggest.add_argument("--sheet", help="工作表名(默认第一个)")
237
+ p_suggest.add_argument("--header-row", type=int, default=1, help="表头行号(默认 1)")
238
+ p_suggest.add_argument("--threshold", type=int, default=60, help="相似度阈值 0-100(默认 60)")
239
+ p_suggest.add_argument("--out", help="Profile 输出路径(默认 profile.json)")
240
+ p_suggest.set_defaults(func=cmd_suggest)
241
+
242
+ p_titles = sub.add_parser("titles", help="用 AI 生成 ≤16 字语义短标题")
243
+ p_titles.add_argument("--excel", required=True, help="Excel 路径")
244
+ p_titles.add_argument("--sheet", help="工作表名(默认第一个)")
245
+ p_titles.add_argument("--column", required=True, help="问题描述列名")
246
+ p_titles.add_argument("--key", default="序号", help="序号列名(默认「序号」)")
247
+ p_titles.add_argument("--header-row", type=int, default=1, help="表头行号(默认 1)")
248
+ p_titles.add_argument("--limit", type=int, default=0, help="只处理前 N 行(0 = 全部)")
249
+ p_titles.add_argument("--out", help="输出 JSON(默认 titles.json)")
250
+ p_titles.add_argument("--api-key", help="DeepSeek/OpenAI 兼容 API key(或 DEEPSEEK_API_KEY)")
251
+ p_titles.add_argument("--base-url", default="https://api.deepseek.com", help="API 基址")
252
+ p_titles.add_argument("--model", default="deepseek-chat", help="模型名")
253
+ p_titles.add_argument("--from-file", help="不调 API,直接读取已生成的 {序号:标题} JSON")
254
+ p_titles.set_defaults(func=cmd_titles)
255
+
256
+ p_build = sub.add_parser("build", help="按 Profile 生成 PPT")
257
+ p_build.add_argument("--profile", required=True, help="Profile JSON 路径")
258
+ p_build.add_argument("--excel", help="覆盖 Profile 里的 Excel 路径")
259
+ p_build.add_argument("--template", help="覆盖 Profile 里的模板路径")
260
+ p_build.add_argument("--out", help="输出 .pptx 路径")
261
+ p_build.add_argument("--quiet", action="store_true", help="不打印进度")
262
+ p_build.add_argument("--json", action="store_true", help="输出 JSON(供脚本/插件解析)")
263
+ p_build.set_defaults(func=cmd_build)
264
+
265
+ p_web = sub.add_parser("web", help="启动本地 Web 向导")
266
+ p_web.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
267
+ p_web.add_argument("--port", type=int, default=8765, help="端口(默认 8765)")
268
+ p_web.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
269
+ p_web.set_defaults(func=cmd_web)
270
+
271
+ p_verify = sub.add_parser("verify", help="校验输出与模板是否零漂移")
272
+ p_verify.add_argument("--template", required=True, help="模板 .pptx")
273
+ p_verify.add_argument("--output", required=True, help="生成结果 .pptx")
274
+ p_verify.add_argument("--slide", type=int, default=0, help="模板母版页索引(默认 0)")
275
+ p_verify.add_argument("--max-pages", type=int, default=0, help="最多校验几页(0 = 全部)")
276
+ p_verify.add_argument(
277
+ "--strict-images",
278
+ action="store_true",
279
+ help="图片尺寸变化也视为错误(默认只告警,因为 contain 会等比缩放)",
280
+ )
281
+ p_verify.add_argument("--json", action="store_true", help="输出 JSON(供脚本/插件解析)")
282
+ p_verify.set_defaults(func=cmd_verify)
283
+
284
+ p_doctor = sub.add_parser("doctor", help="模板体检:结构、可映射性、格式一致性")
285
+ p_doctor.add_argument("--template", required=True, help="模板 .pptx")
286
+ p_doctor.add_argument("--slide", type=int, default=0, help="模板页索引(默认 0)")
287
+ p_doctor.add_argument("--excel", help="可选:Excel 路径,用于交叉检查图片列")
288
+ p_doctor.add_argument("--json", action="store_true", help="输出 JSON")
289
+ p_doctor.set_defaults(func=cmd_doctor)
290
+ return parser
291
+
292
+
293
+ def main(argv: Sequence[str] | None = None) -> int:
294
+ parser = build_parser()
295
+ args = parser.parse_args(argv)
296
+ if getattr(args, "json", False):
297
+ # JSON 供脚本/插件解析:固定 UTF-8,避免 Windows 管道按 GBK 编码
298
+ for stream in (sys.stdout, sys.stderr):
299
+ if hasattr(stream, "reconfigure"):
300
+ stream.reconfigure(encoding="utf-8")
301
+ try:
302
+ return int(args.func(args))
303
+ except (TemplateError, ExcelDataError, ExcelImageError, ProfileError, RenderError, VerifyError) as exc:
304
+ print(f"错误:{exc}", file=sys.stderr)
305
+ return 1
306
+ except KeyboardInterrupt:
307
+ print("已取消。", file=sys.stderr)
308
+ return 130
309
+
310
+
311
+ if __name__ == "__main__":
312
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """核心能力:解析、映射、渲染。"""
@@ -0,0 +1,85 @@
1
+ """从 xlsx 包内提取 WPS「嵌入单元格图片」(DISPIMG)。
2
+
3
+ 与 `excel2ppt` 技能同源,改为抛异常而非 ``sys.exit``,并支持内容哈希缓存。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import os
10
+ import re
11
+ import zipfile
12
+
13
+
14
+ class ExcelImageError(Exception):
15
+ """Excel 内嵌图片提取失败(可读中文信息)。"""
16
+
17
+
18
+ def extract_cell_images(
19
+ xlsx_path: str, out_dir: str, only: set[str] | None = None
20
+ ) -> dict[str, str]:
21
+ """提取 ``xl/cellimages.xml`` 引用的图片,返回 ``{图片ID: 本地路径}``。
22
+
23
+ ``only`` 非空时只落盘这些 ID,避免为预览一页解压整包 300MB 图片。
24
+ 图片按内容 SHA256 命名,重复内容只落盘一次。
25
+ """
26
+ if not os.path.isfile(xlsx_path):
27
+ raise ExcelImageError(f"Excel 文件不存在:{xlsx_path}")
28
+ os.makedirs(out_dir, exist_ok=True)
29
+
30
+ id_to_file: dict[str, str] = {}
31
+ skipped = 0
32
+ with zipfile.ZipFile(xlsx_path, "r") as z:
33
+ names = set(z.namelist())
34
+ if "xl/cellimages.xml" not in names:
35
+ raise ExcelImageError(
36
+ "该 Excel 内没有 xl/cellimages.xml,未发现 WPS「嵌入单元格图片」。\n"
37
+ " 典型原因:①图片是浮动图片而非嵌入单元格;②文件经其他软件转存丢失了 cellimages。\n"
38
+ " 处理:在 WPS 中选中图片设为「嵌入单元格」后另存,或改用无图模式。"
39
+ )
40
+ if "xl/_rels/cellimages.xml.rels" not in names:
41
+ raise ExcelImageError("Excel 缺少 xl/_rels/cellimages.xml.rels,cellimages 引用关系丢失。")
42
+
43
+ ci = z.read("xl/cellimages.xml").decode("utf-8")
44
+ ids = re.findall(r'name="(ID_[A-Fa-f0-9]+)"', ci)
45
+ rids = re.findall(r'r:embed="(rId\d+)"', ci)
46
+
47
+ rels = z.read("xl/_rels/cellimages.xml.rels").decode("utf-8")
48
+ rid_to_media = dict(
49
+ re.findall(r'Id="(rId\d+)"[^>]*Target="(media/image\d+\.\w+)"', rels)
50
+ )
51
+
52
+ for image_id, rid in zip(ids, rids, strict=False):
53
+ if only is not None and image_id not in only:
54
+ continue
55
+ media = rid_to_media.get(rid)
56
+ if not media:
57
+ skipped += 1
58
+ continue
59
+ member = f"xl/{media}"
60
+ if member not in names:
61
+ skipped += 1
62
+ continue
63
+ data = z.read(member)
64
+ digest = hashlib.sha256(data).hexdigest()[:16]
65
+ ext = media.rsplit(".", 1)[-1].lower()
66
+ path = os.path.join(out_dir, f"{digest}.{ext}")
67
+ if not os.path.exists(path):
68
+ with open(path, "wb") as f:
69
+ f.write(data)
70
+ id_to_file[image_id] = path
71
+
72
+ if skipped:
73
+ # 调用方负责转成 WARNING;这里只保留计数,不静默
74
+ id_to_file["__skipped__"] = str(skipped)
75
+ return id_to_file
76
+
77
+
78
+ def list_dispimg_ids(xlsx_path: str) -> dict[str, str]:
79
+ """只解析 ID → media 成员名,不解压图片(用于快速识别图片列)。"""
80
+ with zipfile.ZipFile(xlsx_path, "r") as z:
81
+ if "xl/cellimages.xml" not in z.namelist():
82
+ return {}
83
+ ci = z.read("xl/cellimages.xml").decode("utf-8")
84
+ ids = re.findall(r'name="(ID_[A-Fa-f0-9]+)"', ci)
85
+ return {i: "" for i in ids}
@@ -0,0 +1,256 @@
1
+ """读取 Excel:表头校验、类型推断、行数据。
2
+
3
+ 约定:首行(``header_row``)必须是表头;表头不得为空或重复。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import datetime as _dt
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from openpyxl import load_workbook
14
+
15
+ from .model import Column
16
+
17
+ _IMAGE_ID = re.compile(r"^ID_[A-Fa-f0-9]{6,}$")
18
+ _DISPIMG_FORMULA = re.compile(r"DISPIMG\(\s*\"?(ID_[A-Fa-f0-9]+)\"?", re.IGNORECASE)
19
+ _IMAGE_PATH = re.compile(r"\.(png|jpe?g|bmp|gif|webp)$", re.IGNORECASE)
20
+ _MAX_SCAN = 200
21
+
22
+
23
+ class ExcelDataError(Exception):
24
+ """Excel 结构或数据不符合约定。"""
25
+
26
+
27
+ @dataclass
28
+ class SheetData:
29
+ path: str
30
+ sheet: str
31
+ columns: list[Column] = field(default_factory=list)
32
+ rows: list[dict[str, Any]] = field(default_factory=list)
33
+ warnings: list[str] = field(default_factory=list)
34
+
35
+ @property
36
+ def column_names(self) -> list[str]:
37
+ return [c.name for c in self.columns]
38
+
39
+ @property
40
+ def header_columns(self) -> dict[str, str]:
41
+ return {c.name: c.name for c in self.columns}
42
+
43
+
44
+ def _cell_text(value: Any) -> str:
45
+ if value is None:
46
+ return ""
47
+ if isinstance(value, (_dt.datetime, _dt.date)):
48
+ return value.isoformat(sep=" ") if isinstance(value, _dt.datetime) else value.isoformat()
49
+ return str(value).strip()
50
+
51
+
52
+ def _infer_dtype(name: str, values: list[Any]) -> str:
53
+ sample = [v for v in values if v is not None and _cell_text(v) != ""]
54
+ if not sample:
55
+ return "text"
56
+ texts = [_cell_text(v) for v in sample]
57
+ if any(_IMAGE_ID.match(t) or _DISPIMG_FORMULA.search(t) for t in texts):
58
+ return "image"
59
+ path_like = sum(1 for t in texts if _IMAGE_PATH.search(t))
60
+ if path_like >= max(1, int(len(texts) * 0.6)):
61
+ return "image"
62
+ nums = 0
63
+ dates = 0
64
+ bools = 0
65
+ for v in sample:
66
+ if isinstance(v, bool):
67
+ bools += 1
68
+ elif isinstance(v, (int, float)):
69
+ nums += 1
70
+ elif isinstance(v, (_dt.datetime, _dt.date)):
71
+ dates += 1
72
+ total = len(sample)
73
+ if bools == total:
74
+ return "bool"
75
+ if dates >= total * 0.8:
76
+ return "date"
77
+ if nums >= total * 0.8:
78
+ return "number"
79
+ return "text"
80
+
81
+
82
+ def _read_header(ws, header_row: int) -> tuple[list[str], list[str]]:
83
+ values = [ws.cell(row=header_row, column=c).value for c in range(1, ws.max_column + 1)]
84
+ # 右侧因残留格式撑出的空列不算表头:从尾部裁掉,中间的空列仍然报错
85
+ while values and _cell_text(values[-1]) == "":
86
+ values.pop()
87
+ names: list[str] = []
88
+ warnings: list[str] = []
89
+ seen: set[str] = set()
90
+ for idx, v in enumerate(values, start=1):
91
+ text = _cell_text(v)
92
+ if not text:
93
+ raise ExcelDataError(
94
+ f"第 {idx} 列表头为空(表头行 = 第 {header_row} 行)。"
95
+ "请补全表头,或把 header_row 指到真正的表头行。"
96
+ )
97
+ if text in seen:
98
+ # 真实台账常见重名列(如两个「归档率」),自动加序号并在结果里告警
99
+ suffix = 2
100
+ while f"{text}({suffix})" in seen:
101
+ suffix += 1
102
+ renamed = f"{text}({suffix})"
103
+ warnings.append(f"表头「{text}」重复,已重命名为「{renamed}」")
104
+ text = renamed
105
+ seen.add(text)
106
+ names.append(text)
107
+ if not names:
108
+ raise ExcelDataError(f"第 {header_row} 行没有检测到表头。")
109
+ return names, warnings
110
+
111
+
112
+ def _header_merged_ranges(path: str, sheet: str, header_row: int) -> list[str]:
113
+ """仅在表头出现空单元格时调用:用非只读模式确认是否由合并导致。"""
114
+ try:
115
+ wb = load_workbook(path, data_only=True, read_only=False)
116
+ except Exception: # noqa: BLE001 - 检测失败不影响主流程
117
+ return []
118
+ try:
119
+ ws = wb[sheet]
120
+ return [
121
+ str(rng)
122
+ for rng in ws.merged_cells.ranges
123
+ if rng.min_row <= header_row <= rng.max_row
124
+ ]
125
+ except Exception: # noqa: BLE001
126
+ return []
127
+ finally:
128
+ try:
129
+ wb.close()
130
+ except Exception: # noqa: BLE001
131
+ pass
132
+
133
+
134
+ def _uncached_formula_count(
135
+ path: str, sheet: str, header_row: int, max_col: int
136
+ ) -> int:
137
+ """统计「有公式但没有缓存值」的单元格数(文件未在 Excel/WPS 中重算保存)。"""
138
+ count = 0
139
+ wb_f = None
140
+ wb_v = None
141
+ try:
142
+ wb_f = load_workbook(path, data_only=False, read_only=True)
143
+ wb_v = load_workbook(path, data_only=True, read_only=True)
144
+ ws_f = wb_f[sheet]
145
+ ws_v = wb_v[sheet]
146
+ for row in ws_f.iter_rows(min_row=header_row + 1, max_col=max_col):
147
+ for cell in row:
148
+ value = cell.value
149
+ if isinstance(value, str) and value.startswith("="):
150
+ cached = ws_v.cell(row=cell.row, column=cell.column).value
151
+ if cached in (None, ""):
152
+ count += 1
153
+ except Exception: # noqa: BLE001 - 公式扫描失败不影响主流程
154
+ return 0
155
+ finally:
156
+ for wb in (wb_f, wb_v):
157
+ if wb is not None:
158
+ try:
159
+ wb.close()
160
+ except Exception: # noqa: BLE001
161
+ pass
162
+ return count
163
+
164
+
165
+ def read_excel(
166
+ path: str,
167
+ sheet: str | None = None,
168
+ header_row: int = 1,
169
+ ) -> SheetData:
170
+ """读取指定工作表,返回列元数据与逐行字典。"""
171
+ try:
172
+ wb_values = load_workbook(path, data_only=True, read_only=True)
173
+ except Exception as exc: # noqa: BLE001 - 统一转为可读错误
174
+ raise ExcelDataError(f"无法打开 Excel:{path}({exc})") from exc
175
+
176
+ try:
177
+ if sheet is None:
178
+ sheet = wb_values.sheetnames[0]
179
+ if sheet not in wb_values.sheetnames:
180
+ raise ExcelDataError(
181
+ f"工作表不存在:{sheet}。可用工作表:{'、'.join(wb_values.sheetnames)}"
182
+ )
183
+ ws = wb_values[sheet]
184
+ try:
185
+ names, header_warnings = _read_header(ws, header_row)
186
+ except ExcelDataError as exc:
187
+ # 只有表头为空时才值得用非只读模式检查合并(大文件打开代价高)
188
+ merged = _header_merged_ranges(path, sheet, header_row)
189
+ if merged:
190
+ raise ExcelDataError(
191
+ f"表头行(第 {header_row} 行)存在合并单元格:{'、'.join(merged[:5])}。"
192
+ "请先在 Excel/WPS 中取消合并,把表头拆成单行后再试。"
193
+ ) from exc
194
+ raise
195
+
196
+ raw_rows: list[list[Any]] = []
197
+ for row in ws.iter_rows(min_row=header_row + 1, max_col=len(names), values_only=True):
198
+ if all(_cell_text(v) == "" for v in row):
199
+ continue
200
+ raw_rows.append(list(row))
201
+
202
+ if not raw_rows:
203
+ raise ExcelDataError(f"工作表「{sheet}」在表头之后没有有效数据行。")
204
+
205
+ warnings: list[str] = list(header_warnings)
206
+ if any(v is None for r in raw_rows for v in r):
207
+ uncached = _uncached_formula_count(path, sheet, header_row, len(names))
208
+ if uncached:
209
+ warnings.append(
210
+ f"{uncached} 个公式单元格没有缓存值(文件可能未在 Excel/WPS 中重算保存),"
211
+ "这些单元格会被当作空值处理。"
212
+ )
213
+
214
+ columns: list[Column] = []
215
+ for c_idx, name in enumerate(names):
216
+ values = [r[c_idx] for r in raw_rows[:_MAX_SCAN]]
217
+ dtype = _infer_dtype(name, values)
218
+ samples = [_cell_text(v) for v in values if _cell_text(v)][:5]
219
+ columns.append(Column(index=c_idx, name=name, dtype=dtype, samples=samples))
220
+
221
+ rows: list[dict[str, Any]] = []
222
+ for r in raw_rows:
223
+ rows.append({name: r[c_idx] for c_idx, name in enumerate(names)})
224
+
225
+ return SheetData(
226
+ path=path, sheet=sheet, columns=columns, rows=rows, warnings=warnings
227
+ )
228
+ finally:
229
+ wb_values.close()
230
+
231
+
232
+ def read_dispimg_formulas(path: str, sheet: str | None = None) -> dict[tuple[int, int], str]:
233
+ """读取公式文本,返回 ``{(行, 列): 图片ID}``,用于识别 DISPIMG 单元格。
234
+
235
+ ``sheet`` 为 None 时按工作表顺序查找,返回第一个含 DISPIMG 的工作表结果
236
+ (台账常把「使用说明」放在第一页,不能只看第一页)。
237
+ """
238
+ wb = load_workbook(path, data_only=False, read_only=True)
239
+ try:
240
+ candidates = [sheet] if sheet else list(wb.sheetnames)
241
+ for name in candidates:
242
+ if name not in wb.sheetnames:
243
+ continue
244
+ ws = wb[name]
245
+ out: dict[tuple[int, int], str] = {}
246
+ for row in ws.iter_rows():
247
+ for cell in row:
248
+ if isinstance(cell.value, str):
249
+ m = _DISPIMG_FORMULA.search(cell.value)
250
+ if m:
251
+ out[(cell.row, cell.column)] = m.group(1)
252
+ if out:
253
+ return out
254
+ return {}
255
+ finally:
256
+ wb.close()