sdpy-kit 0.1.7__tar.gz → 0.1.9__tar.gz

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 (43) hide show
  1. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/PKG-INFO +7 -4
  2. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/__init__.py +1 -1
  3. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/model_client.py +14 -6
  4. sdpy_kit-0.1.9/kit/docx_kit.py +896 -0
  5. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/pyproject.toml +5 -2
  6. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/.gitignore +0 -0
  7. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/README.md +0 -0
  8. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/__init__.py +0 -0
  9. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/base_service.py +0 -0
  10. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/chat_service.py +0 -0
  11. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/embedding_service.py +0 -0
  12. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/exceptions.py +0 -0
  13. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/failover.py +0 -0
  14. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/pool.py +0 -0
  17. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/profiles.py +0 -0
  18. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/rerank_service.py +0 -0
  19. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/runner.py +0 -0
  20. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/ai/types.py +0 -0
  21. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/common_kit.py +0 -0
  22. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/config/__init__.py +0 -0
  23. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/config/kit_config.py +0 -0
  24. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/config/log_config.py +0 -0
  25. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/__init__.py +0 -0
  26. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/base_db_kit.py +0 -0
  27. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/crud.py +0 -0
  28. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/engine.py +0 -0
  29. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/exceptions.py +0 -0
  30. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/models/__init__.py +0 -0
  31. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/models/base.py +0 -0
  32. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/mysql_kit.py +0 -0
  33. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/pg_kit.py +0 -0
  34. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/query.py +0 -0
  35. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/session.py +0 -0
  36. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/sqlite_kit.py +0 -0
  37. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/db/transaction.py +0 -0
  38. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/doc_kit.py +0 -0
  39. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/file_kit.py +0 -0
  40. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/mcp_kit.py +0 -0
  41. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/mineru_kit.py +0 -0
  42. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/redis_kit.py +0 -0
  43. {sdpy_kit-0.1.7 → sdpy_kit-0.1.9}/kit/zvec_kit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/MCP/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -24,6 +24,7 @@ Requires-Dist: sqlmodel>=0.0.39
24
24
  Provides-Extra: ai
25
25
  Requires-Dist: httpx>=0.28.1; extra == 'ai'
26
26
  Provides-Extra: all
27
+ Requires-Dist: docxtpl>=0.20.2; extra == 'all'
27
28
  Requires-Dist: fastmcp>=4.0.3; extra == 'all'
28
29
  Requires-Dist: firecrawl-anydoc>=0.2.4; extra == 'all'
29
30
  Requires-Dist: httpx>=0.28.1; extra == 'all'
@@ -40,6 +41,8 @@ Provides-Extra: doc
40
41
  Requires-Dist: firecrawl-anydoc>=0.2.4; extra == 'doc'
41
42
  Requires-Dist: markdown-it-py>=3.0; extra == 'doc'
42
43
  Requires-Dist: pimd[pdfa]>=2.2.5; extra == 'doc'
44
+ Provides-Extra: docx
45
+ Requires-Dist: docxtpl>=0.20.2; extra == 'docx'
43
46
  Provides-Extra: mcp
44
47
  Requires-Dist: fastmcp>=4.0.3; extra == 'mcp'
45
48
  Provides-Extra: mineru
@@ -50,9 +53,9 @@ Provides-Extra: pg
50
53
  Requires-Dist: psycopg2-binary>=2.9.12; extra == 'pg'
51
54
  Provides-Extra: redis
52
55
  Requires-Dist: redis>=5.0.0; extra == 'redis'
53
- Provides-Extra: sqliteai-vector
54
- Requires-Dist: numpy>=2.0; extra == 'sqliteai-vector'
55
- Requires-Dist: sqliteai-vector>=1.1.0; extra == 'sqliteai-vector'
56
+ Provides-Extra: sqliteai
57
+ Requires-Dist: numpy>=2.0; extra == 'sqliteai'
58
+ Requires-Dist: sqliteai-vector>=1.1.0; extra == 'sqliteai'
56
59
  Provides-Extra: zvec
57
60
  Requires-Dist: zvec>=0.6.0; extra == 'zvec'
58
61
  Description-Content-Type: text/markdown
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.1.7"
26
+ __version__ = "0.1.9"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -219,18 +219,26 @@ class ModelClient:
219
219
  json=payload,
220
220
  timeout=None,
221
221
  ) as resp:
222
- resp.raise_for_status()
222
+ if resp.is_error:
223
+ # 错误响应体必须在流上下文内读取;离开后访问 .text 会抛
224
+ # ResponseNotRead 并掩盖真实状态码(0.1.7 缺陷)
225
+ body = (await resp.aread()).decode("utf-8", errors="replace")[:200]
226
+ retry_after = (
227
+ _parse_retry_after(resp.headers.get("Retry-After"))
228
+ if resp.status_code == 429
229
+ else None
230
+ )
231
+ raise AIRequestError(
232
+ f"流式 HTTP 错误 {resp.status_code}: {body}",
233
+ status_code=resp.status_code,
234
+ retry_after=retry_after,
235
+ )
223
236
  async for line in resp.aiter_lines():
224
237
  parsed = await self._parse_sse_line(line)
225
238
  if parsed is not None:
226
239
  yield parsed
227
240
  except httpx.TimeoutException as exc:
228
241
  raise AITimeoutError(f"流式请求超时: {exc}") from exc
229
- except httpx.HTTPStatusError as exc:
230
- raise AIRequestError(
231
- f"流式 HTTP 错误 {exc.response.status_code}: {exc.response.text[:200]}",
232
- status_code=exc.response.status_code,
233
- ) from exc
234
242
  except httpx.HTTPError as exc:
235
243
  raise AIRequestError(f"流式请求失败: {exc}") from exc
236
244
 
@@ -0,0 +1,896 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-09-14
21
+ """docx 模板动态填充工具类,基于 docxtpl 实现章节与内容全动态的模板渲染
22
+
23
+ 本模块封装 docxtpl,提供「Word 模板 + 结构化数据 → 成品 docx」的模板填充
24
+ 服务:章节数量、层级、标题、正文段落、多级列表、表格全部由运行时数据决定,
25
+ 模板只承载版式骨架(封面、页眉页脚、字体、编号绑定)。
26
+
27
+ 与 DocKit 的边界:DocKit 负责格式转换(含 Markdown → Word 整篇渲染),本模块
28
+ 负责模板填充,二者互不调用。
29
+
30
+ 设计要点:
31
+ - 混合方案:章节循环由模板中的 ``{%p for %}`` 承载,非文本块(列表/表格)
32
+ 以私有使用区字符组成的 marker 段落承载「位置 + 载荷」,渲染后由 python-docx
33
+ 后处理原位转换,规避 docxtpl 无法表达动态层级与富内容的限制。
34
+ - 章节标题段使用锚点样式(默认 ``DocxSectionTitle``),渲染后按 ``level``
35
+ 重设为 ``Heading N``,使模板自身绑定的 Word 多级列表自动编号生效。
36
+ - 模板渲染开启 ``autoescape``,正文文本中的 ``&`` / ``<`` / ``>`` 不会破坏 XML。
37
+ - 变量值中的控制字符语义沿用 docxtpl 约定:``\\n`` 段内软换行、``\\a`` 新段落。
38
+ - 全静态方法、无模块级可变状态(除常量),线程安全。
39
+ - 路径参数统一支持 str 与 pathlib.Path。
40
+ - 有序列表按「连续组」克隆独立编号定义,避免 Word 把多个列表串成一条编号序列。
41
+ - 模板样式统一::meth:`DocxKit.unify_font` 统一字体、:meth:`DocxKit.unify_heading_color`
42
+ 统一标题颜色,产物继承模板样式;主标题段由 :meth:`DocxKit.create_template` 的
43
+ ``title`` 参数生成(居中、加粗)。
44
+ """
45
+
46
+ import re
47
+ from collections.abc import Mapping, Sequence
48
+ from copy import deepcopy
49
+ from dataclasses import dataclass, field
50
+ from pathlib import Path
51
+
52
+ try:
53
+ from docx import Document
54
+ from docx.document import Document as DocxDocument
55
+ from docx.enum.style import WD_STYLE_TYPE
56
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
57
+ from docx.oxml import OxmlElement
58
+ from docx.oxml.ns import qn
59
+ from docx.oxml.xmlchemy import BaseOxmlElement
60
+ from docx.shared import Pt
61
+ from docx.styles.style import BaseStyle, ParagraphStyle
62
+ from docx.table import Table as DocxTableElement
63
+ from docx.text.paragraph import Paragraph as DocxParagraphElement
64
+ from docxtpl import DocxTemplate
65
+ except ImportError as e:
66
+ raise ImportError(
67
+ "docx_kit 需要可选依赖,请安装:pip install sdpy-kit[docx]"
68
+ ) from e
69
+
70
+ from kit.config.log_config import get_default_logger
71
+
72
+ logger = get_default_logger(__name__)
73
+
74
+ # ---------------------------------------------------------------- 常量
75
+
76
+ # marker 前缀:U+E000(Unicode 私有使用区),正常正文几乎不可能出现,
77
+ # 用于在纯文本占位符中承载「非文本块」的类型、序号与载荷。
78
+ _MARKER_KEY = "\ue000docxkit"
79
+ _MARKER_SEP = "\ue000"
80
+ _MARKER_RE = re.compile(
81
+ r"^\ue000docxkit:(?P<kind>table|list):(?P<index>\d+)\ue000(?P<payload>.*)$",
82
+ re.DOTALL,
83
+ )
84
+
85
+ _DEFAULT_ANCHOR_STYLE = "DocxSectionTitle"
86
+ _DEFAULT_TABLE_STYLE = "Table Grid"
87
+ _SECTION_VARIABLE = "sections"
88
+
89
+ _MIN_LEVEL = 1
90
+ _MAX_HEADING_LEVEL = 9
91
+ _MAX_LIST_LEVEL = 3
92
+
93
+ # 变量值中的控制字符语义(docxtpl 约定)
94
+ _SOFT_BREAK = "\n"
95
+ _PARAGRAPH_BREAK = "\a"
96
+
97
+
98
+ # ---------------------------------------------------------------- 异常
99
+
100
+
101
+ class DocxError(Exception):
102
+ """docx 模板填充异常
103
+
104
+ 覆盖模板缺失锚点样式、数据非法、渲染失败、后处理失败,以及字体统一 /
105
+ 标题颜色统一失败等场景;消息均为可直接定位问题的中文描述。
106
+ """
107
+
108
+
109
+ # ---------------------------------------------------------------- 数据模型
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class DocxParagraph:
114
+ """正文段落块
115
+
116
+ Attributes:
117
+ text: 段落文本;``\\n`` 为段内软换行,``\\a`` 为分段。
118
+ """
119
+
120
+ text: str
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class DocxListItem:
125
+ """列表项块
126
+
127
+ Attributes:
128
+ text: 列表项文本(``\\n`` 为项内软换行)
129
+ level: 列表层级 1..3,对应 List Bullet/List Number 的第 N 级样式
130
+ ordered: True 为有序列表(List Number),False 为无序列表(List Bullet)
131
+ """
132
+
133
+ text: str
134
+ level: int = 1
135
+ ordered: bool = False
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class DocxTable:
140
+ """表格块
141
+
142
+ Attributes:
143
+ headers: 表头单元格文本,长度即列数(不可为空)
144
+ rows: 数据行,每行长度必须等于表头长度
145
+ style: 表格样式名,None 时使用 ``DocxKit.TABLE_STYLE``
146
+ """
147
+
148
+ headers: tuple[str, ...]
149
+ rows: tuple[tuple[str, ...], ...] = ()
150
+ style: str | None = None
151
+
152
+
153
+ type DocxBlock = DocxParagraph | DocxListItem | DocxTable
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class DocxSection:
158
+ """章节
159
+
160
+ Attributes:
161
+ title: 章节标题文本
162
+ level: 章节层级 1..9,对应 ``Heading N`` 段落样式
163
+ blocks: 章节内容块,按给定顺序输出(段落 / 列表 / 表格可任意交错)
164
+ """
165
+
166
+ title: str
167
+ level: int = 1
168
+ blocks: tuple[DocxBlock, ...] = ()
169
+
170
+
171
+ @dataclass(frozen=True, slots=True)
172
+ class DocxReport:
173
+ """待渲染文档
174
+
175
+ Attributes:
176
+ sections: 章节序列,数量与层级完全动态
177
+ variables: 额外的模板静态变量(封面标题、单位、日期等),
178
+ 与章节上下文合并后交给 docxtpl,键名勿与 ``sections`` 冲突
179
+ """
180
+
181
+ sections: tuple[DocxSection, ...] = ()
182
+ variables: Mapping[str, object] = field(default_factory=dict)
183
+
184
+
185
+ # ---------------------------------------------------------------- 内部工具
186
+
187
+
188
+ def _style_matches(style: ParagraphStyle | None, target: str) -> bool:
189
+ """样式匹配:同时接受样式名与样式 ID(Word 中二者可能不同)"""
190
+ if style is None:
191
+ return False
192
+ return style.name == target or style.style_id == target
193
+
194
+
195
+ def _paragraph_styles(document: DocxDocument) -> dict[str, BaseStyle]:
196
+ """收集文档中的段落样式(样式名 + 样式 ID 双键,便于两种写法命中)"""
197
+ styles: dict[str, BaseStyle] = {}
198
+ for style in document.styles:
199
+ if style.type != WD_STYLE_TYPE.PARAGRAPH:
200
+ continue
201
+ styles[style.name] = style
202
+ if style.style_id:
203
+ styles.setdefault(style.style_id, style)
204
+ return styles
205
+
206
+
207
+ def _require_docx_path(raw_path: str | Path, what: str) -> Path:
208
+ """校验并解析 .docx 文件路径"""
209
+ path = Path(raw_path).resolve()
210
+ if not path.exists():
211
+ raise DocxError(f"{what}不存在: {path}")
212
+ if not path.is_file():
213
+ raise DocxError(f"{what}路径不是有效文件: {path}")
214
+ if path.suffix.lower() != ".docx":
215
+ raise DocxError(
216
+ f"{what}必须为 .docx 文件,当前后缀: {path.suffix or '<无扩展名>'}"
217
+ )
218
+ return path
219
+
220
+
221
+ def _require_dest_path(raw_path: str | Path) -> Path:
222
+ """校验并解析目标路径(父目录自动创建)"""
223
+ dest = Path(raw_path).resolve()
224
+ if dest.suffix.lower() != ".docx":
225
+ raise DocxError(
226
+ f"目标路径必须为 .docx 文件,当前后缀: {dest.suffix or '<无扩展名>'}"
227
+ )
228
+ dest.parent.mkdir(parents=True, exist_ok=True)
229
+ return dest
230
+
231
+
232
+ def _list_style_name(ordered: bool, level: int) -> str:
233
+ """列表样式名:level 1 → List Bullet/List Number,2/3 → 追加空格与层级"""
234
+ base = "List Number" if ordered else "List Bullet"
235
+ return base if level <= 1 else f"{base} {level}"
236
+
237
+
238
+ def _resolve_heading_styles(available: Mapping[str, BaseStyle]) -> dict[int, str]:
239
+ """为 level 1..9 解析可用的 Heading 样式名(缺失时逐级向下回退)"""
240
+ resolved: dict[int, str] = {}
241
+ for level in range(_MIN_LEVEL, _MAX_HEADING_LEVEL + 1):
242
+ for candidate in range(level, 0, -1):
243
+ name = f"Heading {candidate}"
244
+ if name in available:
245
+ if candidate != level:
246
+ logger.warning(
247
+ f"模板缺少 Heading {level} 样式,层级 {level} 回退到 {name}"
248
+ )
249
+ resolved[level] = name
250
+ break
251
+ return resolved
252
+
253
+
254
+ def _resolve_list_style(
255
+ ordered: bool, level: int, available: Mapping[str, BaseStyle]
256
+ ) -> str:
257
+ """解析列表样式名(缺级时逐级向下回退,最终回退 List Paragraph)"""
258
+ base = _list_style_name(ordered, level)
259
+ candidates = [base]
260
+ candidates.extend(_list_style_name(ordered, lv) for lv in range(level - 1, 0, -1))
261
+ candidates.append("List Paragraph")
262
+ for name in candidates:
263
+ if name in available:
264
+ if name != base:
265
+ logger.warning(f"模板缺少 {base} 样式,列表样式回退到 {name}")
266
+ return name
267
+ raise DocxError(
268
+ "模板缺少列表样式(List Bullet / List Number / List Paragraph 均不存在),"
269
+ "请在模板中保留 Word 内置列表样式"
270
+ )
271
+
272
+
273
+ def _set_paragraph_text(paragraph: DocxParagraphElement, text: str) -> None:
274
+ """重设段落文本:清空原 run,``\\n`` 重建为段内软换行"""
275
+ for run in list(paragraph.runs):
276
+ run._r.getparent().remove(run._r)
277
+ run = paragraph.add_run()
278
+ parts = text.split(_SOFT_BREAK)
279
+ run.add_text(parts[0])
280
+ for part in parts[1:]:
281
+ run.add_break()
282
+ run.add_text(part)
283
+
284
+
285
+ # 字体统一涉及的 rFonts 属性:四项显式字体,以及需一并清除的主题引用
286
+ _FONT_ATTRIBUTES: tuple[str, ...] = ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs")
287
+ _THEME_FONT_ATTRIBUTES: tuple[str, ...] = (
288
+ "w:asciiTheme",
289
+ "w:hAnsiTheme",
290
+ "w:eastAsiaTheme",
291
+ "w:cstheme",
292
+ )
293
+ # 标题颜色统一需一并清除的主题颜色属性
294
+ _THEME_COLOR_ATTRIBUTES: tuple[str, ...] = (
295
+ "w:themeColor",
296
+ "w:themeTint",
297
+ "w:themeShade",
298
+ )
299
+
300
+
301
+ def _unify_rfonts(rfonts: BaseOxmlElement, font_name: str) -> None:
302
+ """把单个 rFonts 的字体设为指定值,并移除主题引用
303
+
304
+ Word 中主题引用(如 ``w:asciiTheme``)优先于显式字体,故须一并删除,
305
+ 否则文档仍会使用主题字体。
306
+ """
307
+ for attribute in _THEME_FONT_ATTRIBUTES:
308
+ if rfonts.get(qn(attribute)) is not None:
309
+ del rfonts.attrib[qn(attribute)]
310
+ for attribute in _FONT_ATTRIBUTES:
311
+ rfonts.set(qn(attribute), font_name)
312
+
313
+
314
+ # ---------------------------------------------------------------- 数据校验
315
+
316
+
317
+ def _validate_report(report: DocxReport) -> None:
318
+ """渲染前校验数据合法性(层级范围、表格形状)"""
319
+ for section_index, section in enumerate(report.sections, start=1):
320
+ if not _MIN_LEVEL <= section.level <= _MAX_HEADING_LEVEL:
321
+ raise DocxError(
322
+ f"第 {section_index} 个章节 level={section.level} 越界"
323
+ f"(允许 {_MIN_LEVEL}..{_MAX_HEADING_LEVEL})"
324
+ )
325
+ for block_index, block in enumerate(section.blocks, start=1):
326
+ if not isinstance(block, DocxTable):
327
+ continue
328
+ if not block.headers:
329
+ raise DocxError(
330
+ f"第 {section_index} 个章节第 {block_index} 个表格表头为空"
331
+ )
332
+ for row_index, row in enumerate(block.rows, start=1):
333
+ if len(row) != len(block.headers):
334
+ raise DocxError(
335
+ f"第 {section_index} 个章节第 {block_index} 个表格第 "
336
+ f"{row_index} 行列数 {len(row)} 与表头列数 "
337
+ f"{len(block.headers)} 不一致"
338
+ )
339
+
340
+
341
+ # ---------------------------------------------------------------- 上下文编译
342
+
343
+
344
+ def _compile_sections(
345
+ report: DocxReport,
346
+ ) -> tuple[list[dict[str, str]], dict[int, DocxBlock]]:
347
+ """把章节树编译为模板上下文与「marker 序号 → 块对象」注册表
348
+
349
+ 段落块直接写入正文文本;列表与表格块替换为 marker 段落,
350
+ 其真实内容保留在注册表中供后处理使用。
351
+ """
352
+ registry: dict[int, DocxBlock] = {}
353
+ sections: list[dict[str, str]] = []
354
+ counter = 0
355
+ for section in report.sections:
356
+ parts: list[str] = []
357
+ for block in section.blocks:
358
+ match block:
359
+ case DocxParagraph(text=text):
360
+ parts.append(text)
361
+ case DocxListItem():
362
+ parts.append(
363
+ f"{_MARKER_KEY}:list:{counter}{_MARKER_SEP}{block.text}"
364
+ )
365
+ registry[counter] = block
366
+ counter += 1
367
+ case DocxTable():
368
+ parts.append(f"{_MARKER_KEY}:table:{counter}{_MARKER_SEP}")
369
+ registry[counter] = block
370
+ counter += 1
371
+ sections.append({"title": section.title, "body": _PARAGRAPH_BREAK.join(parts)})
372
+ return sections, registry
373
+
374
+
375
+ def _build_context(
376
+ report: DocxReport, sections: list[dict[str, str]]
377
+ ) -> dict[str, object]:
378
+ """合并静态变量与章节上下文(章节上下文优先级更高)"""
379
+ context: dict[str, object] = dict(report.variables)
380
+ if _SECTION_VARIABLE in context:
381
+ logger.warning(f"variables 中的 {_SECTION_VARIABLE!r} 将被章节数据覆盖")
382
+ context[_SECTION_VARIABLE] = sections
383
+ return context
384
+
385
+
386
+ # ---------------------------------------------------------------- 后处理
387
+
388
+
389
+ def _convert_list_item(
390
+ styles: Mapping[str, BaseStyle], paragraph: DocxParagraphElement, item: DocxListItem
391
+ ) -> int:
392
+ """marker 段 → 列表项:重写文本并按层级设置列表样式
393
+
394
+ Returns:
395
+ int: 实际生效的列表层级(越界时已回退),供编号定义设置使用
396
+ """
397
+ level = item.level
398
+ if not _MIN_LEVEL <= level <= _MAX_LIST_LEVEL:
399
+ logger.warning(f"列表层级 {level} 越界,回退到 {_MAX_LIST_LEVEL}")
400
+ level = _MAX_LIST_LEVEL
401
+ style_name = _resolve_list_style(item.ordered, level, styles)
402
+ _set_paragraph_text(paragraph, item.text)
403
+ paragraph.style = styles[style_name]
404
+ return level
405
+
406
+
407
+ def _convert_table(
408
+ document: DocxDocument, paragraph: DocxParagraphElement, data: DocxTable
409
+ ) -> None:
410
+ """marker 段 → 表格:原位插入并把 marker 段删除
411
+
412
+ 连续表格之间补一个空段落,避免 Word 把相邻表格合并渲染。
413
+ """
414
+ table: DocxTableElement = document.add_table(
415
+ rows=1 + len(data.rows), cols=len(data.headers)
416
+ )
417
+ style_name = data.style or _DEFAULT_TABLE_STYLE
418
+ try:
419
+ table.style = style_name
420
+ except KeyError as e:
421
+ raise DocxError(
422
+ f"模板缺少表格样式: {style_name}(可通过 DocxTable.style 指定其他样式)"
423
+ ) from e
424
+ for column_index, header in enumerate(data.headers):
425
+ table.cell(0, column_index).text = header
426
+ for row_index, row in enumerate(data.rows, start=1):
427
+ for column_index, value in enumerate(row):
428
+ table.cell(row_index, column_index).text = value
429
+
430
+ marker_element = paragraph._p
431
+ marker_element.addnext(table._tbl)
432
+ marker_element.getparent().remove(marker_element)
433
+ # 相邻表格之间补空段落,避免 Word 把两张表合并渲染:
434
+ # 后一张表插入时其 marker 段尚未转换,故插入后按前后兄弟双向检测
435
+ following = table._tbl.getnext()
436
+ if following is not None and following.tag == qn("w:tbl"):
437
+ table._tbl.addnext(OxmlElement("w:p"))
438
+ previous = table._tbl.getprevious()
439
+ if previous is not None and previous.tag == qn("w:tbl"):
440
+ table._tbl.addprevious(OxmlElement("w:p"))
441
+
442
+
443
+ def _style_num_id(style: BaseStyle) -> str | None:
444
+ """取样式引用的 numId(编号定义 ID)"""
445
+ ppr = style.element.find(qn("w:pPr"))
446
+ if ppr is None:
447
+ return None
448
+ num_pr = ppr.find(qn("w:numPr"))
449
+ if num_pr is None:
450
+ return None
451
+ num_id = num_pr.find(qn("w:numId"))
452
+ return None if num_id is None else num_id.get(qn("w:val"))
453
+
454
+
455
+ def _clone_ordered_numbering(
456
+ document: DocxDocument, styles: Mapping[str, BaseStyle]
457
+ ) -> int | None:
458
+ """为当前有序列表克隆一份独立编号定义,返回新 numId
459
+
460
+ Word 按 numId 连续计数:同一 numId 下的所有段落会串成一条编号序列,
461
+ 因此文档中多个有序列表会连号(1..N)。每个有序列表组克隆一份 num
462
+ 定义(引用同一 abstractNum 以保持编号格式),即可各自从 1 开始。
463
+ 模板缺少编号定义时返回 None,保留 Word 默认行为。
464
+ """
465
+ list_number = styles.get("List Number")
466
+ base_num_id = _style_num_id(list_number) if list_number is not None else None
467
+ if base_num_id is None:
468
+ logger.warning("模板 List Number 样式未引用编号定义,有序列表可能连续编号")
469
+ return None
470
+
471
+ try:
472
+ numbering = document.part.numbering_part.element
473
+ except (AttributeError, KeyError, NotImplementedError, ValueError) as e:
474
+ logger.warning(f"文档缺少编号定义(numbering.xml),有序列表可能连续编号: {e}")
475
+ return None
476
+
477
+ existing = numbering.findall(qn("w:num"))
478
+ base = next(
479
+ (num for num in existing if num.get(qn("w:numId")) == base_num_id), None
480
+ )
481
+ if base is None:
482
+ logger.warning(f"编号定义中缺少 numId={base_num_id},有序列表可能连续编号")
483
+ return None
484
+
485
+ new_id = max((int(num.get(qn("w:numId")) or 0) for num in existing), default=0) + 1
486
+ new_num = deepcopy(base)
487
+ new_num.set(qn("w:numId"), str(new_id))
488
+ numbering.append(new_num)
489
+ return new_id
490
+
491
+
492
+ def _apply_numbering(paragraph: DocxParagraphElement, level: int, num_id: int) -> None:
493
+ """为段落写入段落级 numPr(指向独立编号定义,覆盖样式级编号)"""
494
+ ppr = paragraph._p.get_or_add_pPr()
495
+ num_pr = OxmlElement("w:numPr")
496
+ ilvl = OxmlElement("w:ilvl")
497
+ ilvl.set(qn("w:val"), str(max(level - 1, 0)))
498
+ num_id_element = OxmlElement("w:numId")
499
+ num_id_element.set(qn("w:val"), str(num_id))
500
+ num_pr.append(ilvl)
501
+ num_pr.append(num_id_element)
502
+ # pPr 子元素顺序有约束:numPr 须紧随 pStyle
503
+ style_element = ppr.find(qn("w:pStyle"))
504
+ if style_element is not None:
505
+ style_element.addnext(num_pr)
506
+ else:
507
+ ppr.insert(0, num_pr)
508
+
509
+
510
+ def _post_process(
511
+ dest: Path,
512
+ sections: tuple[DocxSection, ...],
513
+ registry: Mapping[int, DocxBlock],
514
+ anchor_style: str,
515
+ ) -> None:
516
+ """渲染后处理:锚点标题段改 Heading N,marker 段转列表/表格
517
+
518
+ 有序列表按「连续组」分配独立编号定义,避免 Word 把多个列表串成一条
519
+ 编号序列(1..N)。
520
+ """
521
+ document = Document(str(dest))
522
+ styles = _paragraph_styles(document)
523
+ heading_styles = _resolve_heading_styles(styles)
524
+ if not heading_styles:
525
+ raise DocxError(
526
+ "模板中不存在 Heading 1..9 段落样式,无法设置章节标题层级,"
527
+ "请在模板中保留 Word 内置标题样式"
528
+ )
529
+
530
+ title_count = 0
531
+ block_count = 0
532
+ ordered_num_id: int | None = None
533
+ in_ordered_list = False
534
+ for paragraph in document.paragraphs:
535
+ text = paragraph.text
536
+ if text.startswith(_MARKER_KEY):
537
+ matched = _MARKER_RE.match(text)
538
+ if matched is None:
539
+ raise DocxError(f"无法解析的内容标记段: {text[:40]!r}")
540
+ index = int(matched.group("index"))
541
+ block = registry.get(index)
542
+ if block is None:
543
+ raise DocxError(f"内容标记引用了不存在的块序号: {index}")
544
+ match block:
545
+ case DocxListItem(ordered=True):
546
+ level = _convert_list_item(styles, paragraph, block)
547
+ if not in_ordered_list:
548
+ ordered_num_id = _clone_ordered_numbering(document, styles)
549
+ if ordered_num_id is not None:
550
+ _apply_numbering(paragraph, level, ordered_num_id)
551
+ in_ordered_list = True
552
+ case DocxListItem():
553
+ _convert_list_item(styles, paragraph, block)
554
+ in_ordered_list = False
555
+ case DocxTable():
556
+ _convert_table(document, paragraph, block)
557
+ in_ordered_list = False
558
+ block_count += 1
559
+ continue
560
+ # 标题、正文、模板静态段均视为打断有序列表组
561
+ in_ordered_list = False
562
+ if _style_matches(paragraph.style, anchor_style):
563
+ if title_count >= len(sections):
564
+ logger.warning(f"锚点标题段多于章节数据,忽略: {text[:30]!r}")
565
+ continue
566
+ level = sections[title_count].level
567
+ paragraph.style = styles[heading_styles[level]]
568
+ title_count += 1
569
+
570
+ if title_count < len(sections):
571
+ raise DocxError(
572
+ f"模板锚点标题段不足:章节数据 {len(sections)} 个,"
573
+ f"模板仅匹配到 {title_count} 个(锚点样式 {anchor_style!r})"
574
+ )
575
+ document.save(str(dest))
576
+ logger.info(f"后处理完成:{title_count} 个章节标题、{block_count} 个富内容块")
577
+
578
+
579
+ # ---------------------------------------------------------------- 工具类
580
+
581
+
582
+ class DocxKit:
583
+ """docx 模板动态填充工具类
584
+
585
+ 方法一览:
586
+
587
+ - :meth:`create_template`:生成标准模板骨架(可含居中主标题段)
588
+ - :meth:`unify_font`:统一文档字体(默认宋体)
589
+ - :meth:`unify_heading_color`:统一标题颜色(默认黑色)
590
+ - :meth:`inspect_template`:列出模板未声明变量,便于排查
591
+ - :meth:`render`:按数据渲染模板并落盘
592
+
593
+ 使用方式:
594
+ # 1. 生成标准模板骨架(可继续在 Word 中美化样式)
595
+ DocxKit.create_template("templates/report.docx", title="{{ title }}")
596
+
597
+ # 2. 统一模板样式(可重复执行,幂等)
598
+ DocxKit.unify_font("templates/report.docx")
599
+ DocxKit.unify_heading_color("templates/report.docx")
600
+
601
+ # 3. 组织数据并渲染
602
+ report = DocxReport(
603
+ sections=(
604
+ DocxSection(
605
+ title="第一章 概述",
606
+ level=1,
607
+ blocks=(
608
+ DocxParagraph("本章介绍项目背景。"),
609
+ DocxListItem("要点一"),
610
+ DocxTable(headers=("名称", "数值"), rows=(("甲", "1"),)),
611
+ ),
612
+ ),
613
+ ),
614
+ variables={"project": "SDPY"},
615
+ )
616
+ DocxKit.render("templates/report.docx", "out/report.docx", report)
617
+
618
+ 模板约定(详见 docs/agents/modules/docx-kit.md):
619
+ {%p for section in sections %}
620
+ [样式 DocxSectionTitle] {{ section.title }}
621
+ {{ section.body }}
622
+ {%p endfor %}
623
+
624
+ 其中含 ``{%p %}`` 的两个段落会被 docxtpl 删除;章节标题段必须使用锚点
625
+ 样式,渲染后由本模块按 level 改设为 ``Heading N``,模板自身把
626
+ ``Heading 1..9`` 绑定多级列表即可获得自动编号。
627
+ """
628
+
629
+ TITLE_ANCHOR_STYLE: str = _DEFAULT_ANCHOR_STYLE
630
+ TABLE_STYLE: str = _DEFAULT_TABLE_STYLE
631
+ TITLE_FONT_SIZE: int = 16 # 主标题字号(磅,约三号)
632
+
633
+ @staticmethod
634
+ def create_template(
635
+ dest_path: str | Path,
636
+ *,
637
+ title: str | None = None,
638
+ leading_paragraphs: Sequence[str] = (),
639
+ anchor_style: str | None = None,
640
+ ) -> str:
641
+ """生成标准模板骨架(含章节循环与锚点样式),供后续美化样式
642
+
643
+ 生成内容:可选的主标题段(居中、加粗、三号字)、其余前置静态段落
644
+ (可写 ``{{ 变量 }}`` 占位符)、``{%p for %}`` 循环标签段、锚点样式
645
+ 标题段、正文段、``{%p endfor %}`` 标签段。生成后可直接用 Word 调整
646
+ 页面/页眉页脚等,或直接交给 :meth:`render` 使用。
647
+
648
+ Args:
649
+ dest_path: 目标 .docx 路径(父目录自动创建)
650
+ title: 文档主标题文本(可为 ``{{ 变量 }}`` 占位符);传入时该段
651
+ 居中、加粗、字号 :attr:`TITLE_FONT_SIZE`,并排在其余前置段落之前
652
+ leading_paragraphs: 其余前置静态段落文本列表,缺省为空
653
+ anchor_style: 章节标题锚点样式名,None 时使用
654
+ :attr:`TITLE_ANCHOR_STYLE`
655
+
656
+ Returns:
657
+ str: 实际写入的模板文件绝对路径
658
+
659
+ Raises:
660
+ DocxError: 目标后缀不是 .docx
661
+
662
+ Example:
663
+ >>> DocxKit.create_template("tpl.docx", title="{{ title }}")
664
+ """
665
+ dest = _require_dest_path(dest_path)
666
+ style_name = anchor_style or _DEFAULT_ANCHOR_STYLE
667
+
668
+ document = Document()
669
+ if title is not None:
670
+ title_paragraph = document.add_paragraph()
671
+ title_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
672
+ title_run = title_paragraph.add_run(title)
673
+ title_run.bold = True
674
+ title_run.font.size = Pt(DocxKit.TITLE_FONT_SIZE)
675
+ title_paragraph.paragraph_format.space_after = Pt(12)
676
+ for text in leading_paragraphs:
677
+ document.add_paragraph(text)
678
+ styles = _paragraph_styles(document)
679
+ if style_name not in styles:
680
+ style = document.styles.add_style(style_name, WD_STYLE_TYPE.PARAGRAPH)
681
+ style.base_style = document.styles["Normal"]
682
+
683
+ document.add_paragraph(f"{{%p for section in {_SECTION_VARIABLE} %}}")
684
+ document.add_paragraph("{{ section.title }}", style=style_name)
685
+ document.add_paragraph("{{ section.body }}")
686
+ document.add_paragraph("{%p endfor %}")
687
+ document.save(str(dest))
688
+
689
+ logger.info(f"✅ 模板骨架已生成: {dest}")
690
+ return str(dest)
691
+
692
+ @staticmethod
693
+ def unify_font(docx_path: str | Path, *, font_name: str = "宋体") -> str:
694
+ """把文档默认字体与全部样式的字体统一为指定字体
695
+
696
+ 设置范围:``docDefaults`` 与文档内**所有样式**的 ``w:rFonts``(含表格
697
+ 样式条件格式 ``tblStylePr`` 内的),ascii、hAnsi、eastAsia、cs 四项
698
+ 均设为 font_name,并移除主题字体引用(主题引用优先级高于显式字体)。
699
+ 模板经此处理后,正文、标题、列表与表格单元格的中英文一致使用该字体;
700
+ 渲染产物继承模板样式,无需重复处理。
701
+
702
+ Args:
703
+ docx_path: 待处理的 .docx 路径(模板或产物)
704
+ font_name: 统一后的字体名(Word 字体名),默认宋体
705
+
706
+ Returns:
707
+ str: 处理后的文件绝对路径(原地覆盖)
708
+
709
+ Raises:
710
+ DocxError: 路径非法或文档处理失败
711
+
712
+ Example:
713
+ >>> DocxKit.unify_font("tpl.docx") # 统一为宋体
714
+ >>> DocxKit.unify_font("tpl.docx", font_name="黑体")
715
+ """
716
+ path = _require_docx_path(docx_path, "文档")
717
+ try:
718
+ document = Document(str(path))
719
+ defaults = document.styles.element.find(qn("w:docDefaults"))
720
+ if defaults is not None:
721
+ rpr_default = defaults.find(qn("w:rPrDefault"))
722
+ default_rpr = (
723
+ None if rpr_default is None else rpr_default.find(qn("w:rPr"))
724
+ )
725
+ if default_rpr is not None:
726
+ rfonts = default_rpr.find(qn("w:rFonts"))
727
+ if rfonts is None:
728
+ rfonts = OxmlElement("w:rFonts")
729
+ default_rpr.insert(0, rfonts)
730
+ _unify_rfonts(rfonts, font_name)
731
+ for style in document.styles:
732
+ # 编号样式(_NumberingStyle)等无字体对象,仅处理其 rFonts
733
+ font = getattr(style, "font", None)
734
+ if font is not None:
735
+ font.name = font_name
736
+ # 覆盖样式顶层 rPr 与表格样式条件格式(tblStylePr)内的全部 rFonts
737
+ style_fonts = list(style.element.iter(qn("w:rFonts"))) or [
738
+ style.element.get_or_add_rPr().get_or_add_rFonts()
739
+ ]
740
+ for rfonts in style_fonts:
741
+ _unify_rfonts(rfonts, font_name)
742
+ document.save(str(path))
743
+ except Exception as e: # noqa: BLE001 - python-docx 异常统一包装
744
+ raise DocxError(f"字体统一失败: {path.name}: {e}") from e
745
+
746
+ logger.info(f"✅ 字体已统一为 {font_name}: {path}")
747
+ return str(path)
748
+
749
+ @staticmethod
750
+ def unify_heading_color(docx_path: str | Path, *, color: str = "000000") -> str:
751
+ """把 Heading 1..9 样式的字体颜色统一为指定色值
752
+
753
+ Word 内置标题样式默认使用主题蓝(``2F5496`` + ``themeColor="accent1"``),
754
+ 本方法把 ``Heading 1..9`` 的 ``w:color`` 改为指定 RGB,并移除
755
+ ``themeColor`` / ``themeTint`` / ``themeShade`` 主题引用,使各层级标题
756
+ 显示为同一颜色。模板经此处理后,渲染产物的标题同步生效。
757
+
758
+ Args:
759
+ docx_path: 待处理的 .docx 路径(模板或产物)
760
+ color: 6 位十六进制 RGB(不带 ``#``),默认 ``000000``(黑色)
761
+
762
+ Returns:
763
+ str: 处理后的文件绝对路径(原地覆盖)
764
+
765
+ Raises:
766
+ DocxError: 路径非法或文档处理失败
767
+
768
+ Example:
769
+ >>> DocxKit.unify_heading_color("tpl.docx") # 标题黑色
770
+ >>> DocxKit.unify_heading_color("tpl.docx", color="1F3864") # 深蓝
771
+ """
772
+ path = _require_docx_path(docx_path, "文档")
773
+ try:
774
+ document = Document(str(path))
775
+ styles = _paragraph_styles(document)
776
+ changed = 0
777
+ for level in range(_MIN_LEVEL, _MAX_HEADING_LEVEL + 1):
778
+ style = styles.get(f"Heading {level}")
779
+ if style is None:
780
+ continue
781
+ color_element = style.element.get_or_add_rPr().get_or_add_color()
782
+ color_element.set(qn("w:val"), color)
783
+ for attribute in _THEME_COLOR_ATTRIBUTES:
784
+ if color_element.get(qn(attribute)) is not None:
785
+ del color_element.attrib[qn(attribute)]
786
+ changed += 1
787
+ document.save(str(path))
788
+ except Exception as e: # noqa: BLE001 - python-docx 异常统一包装
789
+ raise DocxError(f"标题颜色统一失败: {path.name}: {e}") from e
790
+
791
+ logger.info(f"✅ 标题颜色已统一为 #{color}: {path}({changed} 个标题样式)")
792
+ return str(path)
793
+
794
+ @staticmethod
795
+ def inspect_template(
796
+ template_path: str | Path,
797
+ *,
798
+ context: Mapping[str, object] | None = None,
799
+ ) -> tuple[str, ...]:
800
+ """列出模板中未被上下文满足的变量(用于模板调试)
801
+
802
+ Args:
803
+ template_path: 模板 .docx 路径
804
+ context: 已有上下文;传入时仅返回未被满足的变量,None 时返回全部
805
+
806
+ Returns:
807
+ tuple[str, ...]: 按名称排序的变量名
808
+
809
+ Raises:
810
+ DocxError: 模板路径非法或解析失败
811
+
812
+ Example:
813
+ >>> DocxKit.inspect_template("tpl.docx")
814
+ ('project', 'sections')
815
+ """
816
+ tpl_path = _require_docx_path(template_path, "模板")
817
+ template = DocxTemplate(str(tpl_path))
818
+ try:
819
+ variables = template.get_undeclared_template_variables(context=context)
820
+ except Exception as e: # noqa: BLE001 - Jinja/XML 解析异常统一包装
821
+ raise DocxError(f"模板解析失败: {tpl_path.name}: {e}") from e
822
+ return tuple(sorted(variables))
823
+
824
+ @staticmethod
825
+ def render(
826
+ template_path: str | Path,
827
+ dest_path: str | Path,
828
+ report: DocxReport,
829
+ *,
830
+ anchor_style: str | None = None,
831
+ strict: bool = True,
832
+ ) -> str:
833
+ """按数据渲染模板并落盘(核心方法)
834
+
835
+ 流程:校验模板与数据 → 编译上下文 → docxtpl 渲染(autoescape)
836
+ → 后处理(锚点标题段改 ``Heading N``、marker 段转列表/表格、有序列表
837
+ 按组分配独立编号)→ 落盘。
838
+
839
+ Args:
840
+ template_path: 模板 .docx 路径
841
+ dest_path: 目标 .docx 路径(父目录自动创建)
842
+ report: 文档数据(章节树 + 静态变量)
843
+ anchor_style: 章节标题锚点样式名,None 时使用
844
+ :attr:`TITLE_ANCHOR_STYLE`
845
+ strict: True 时模板存在未满足变量则抛错;False 时仅告警并按
846
+ 空值渲染
847
+
848
+ Returns:
849
+ str: 实际写入的目标文件绝对路径
850
+
851
+ Raises:
852
+ DocxError: 模板非法、缺锚点样式、数据非法、变量未满足(strict)、
853
+ 渲染或后处理失败
854
+
855
+ Example:
856
+ >>> DocxKit.render("tpl.docx", "out.docx", report)
857
+ """
858
+ tpl_path = _require_docx_path(template_path, "模板")
859
+ dest = _require_dest_path(dest_path)
860
+ style_name = anchor_style or _DEFAULT_ANCHOR_STYLE
861
+
862
+ _validate_report(report)
863
+
864
+ template_document = Document(str(tpl_path))
865
+ template_styles = _paragraph_styles(template_document)
866
+ if style_name not in template_styles:
867
+ raise DocxError(
868
+ f"模板缺少章节标题锚点样式 {style_name!r}:"
869
+ "请用 DocxKit.create_template() 生成骨架,把章节标题段设为该样式,"
870
+ "或通过 anchor_style 参数指定模板中实际使用的样式名"
871
+ )
872
+
873
+ sections_context, registry = _compile_sections(report)
874
+ context = _build_context(report, sections_context)
875
+
876
+ template = DocxTemplate(str(tpl_path))
877
+ missing = tuple(template.get_undeclared_template_variables(context=context))
878
+ if missing:
879
+ message = f"模板存在未满足的变量: {', '.join(missing)}"
880
+ if strict:
881
+ raise DocxError(
882
+ message + "(可通过 variables 提供,或传 strict=False)"
883
+ )
884
+ logger.warning(message + "(strict=False,将按空值渲染)")
885
+
886
+ logger.info(f"开始渲染模板: {tpl_path.name}({len(report.sections)} 个章节)")
887
+ try:
888
+ template.render(context, autoescape=True)
889
+ template.save(str(dest))
890
+ except Exception as e: # noqa: BLE001 - docxtpl/Jinja 异常统一包装
891
+ raise DocxError(f"模板渲染失败: {tpl_path.name}: {e}") from e
892
+
893
+ _post_process(dest, report.sections, registry, style_name)
894
+
895
+ logger.info(f"✅ 文档生成成功: {dest}")
896
+ return str(dest)
@@ -43,18 +43,19 @@ dynamic = ["version"]
43
43
  # 全部可用,报错信息会提示安装命令:
44
44
  # pg → pg_kit(psycopg2)
45
45
  # mysql → mysql_kit(pymysql)
46
- # sqliteai-vector → SQLiteKit 向量方法(numpy/sqliteai-vector,SQLite 向量扩展,业务按需)
46
+ # sqliteai → SQLiteKit 向量方法(numpy/sqliteai-vector,SQLite 向量扩展,业务按需)
47
47
  # zvec → zvec_kit(zvec,阿里独立向量库,与 SQLite 向量扩展完全独立)
48
48
  # redis → redis_kit
49
49
  # mcp → mcp_kit(fastmcp)
50
50
  # ai → kit/ai 服务池(httpx)
51
51
  # doc → doc_kit(firecrawl-anydoc/pimd/markdown-it-py,fpdf2 由 pimd[pdfa] 带入)
52
+ # docx → docx_kit(docxtpl + python-docx,模板动态填充;与 doc 的格式转换能力独立)
52
53
  # mineru → mineru_kit(requests)
53
54
  # 基础版 = config/log + kit.db 核心,SQLite 常规操作用标准库 sqlite3,零额外依赖。
54
55
  [project.optional-dependencies]
55
56
  pg = ["psycopg2-binary>=2.9.12"]
56
57
  mysql = ["pymysql>=1.2.0"]
57
- sqliteai-vector = [
58
+ sqliteai = [
58
59
  "numpy>=2.0",
59
60
  "sqliteai-vector>=1.1.0",
60
61
  ]
@@ -67,6 +68,7 @@ doc = [
67
68
  "pimd[pdfa]>=2.2.5",
68
69
  "markdown-it-py>=3.0",
69
70
  ]
71
+ docx = ["docxtpl>=0.20.2"]
70
72
  mineru = ["requests>=2.34.0"]
71
73
  all = [
72
74
  "psycopg2-binary>=2.9.12",
@@ -80,6 +82,7 @@ all = [
80
82
  "firecrawl-anydoc>=0.2.4",
81
83
  "pimd[pdfa]>=2.2.5",
82
84
  "markdown-it-py>=3.0",
85
+ "docxtpl>=0.20.2",
83
86
  "requests>=2.34.0",
84
87
  ]
85
88
  [tool.hatch.version]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes