Quellcode durchsuchen

feat(export): 新增样式管理与基于 JSON 的文档样式系统

- 从可配置 JSON 路径加载样式文件(default_style_file 配置项)
- 实现样式映射与查找函数,支持按样式 ID 和名称双向检索
- 在导出请求/响应 Schema 中新增 style_id 字段,支持样式选择
- 将 JSON 中的自定义样式注入 Word 文档样式元素(upsert 语义)
- 实现 JSON 到 lxml 元素的反向转换,用于样式定义重建
- 实现 Markdown 到样式化文档的转换,支持标题与内联格式
- 支持特殊字符处理与 Unicode 全角/半角规范化
- 文档生成时自动处理文件命名与临时文件管理
- 创建 tmp 目录并提供默认样式模板(default.json)及示例输出
- 更新后端 API 文档,补充导出接口规范
- 阶段 0:后端固定使用默认样式;阶段 1 将通过 style_id 支持用户上传样式
chensiyu vor 4 Tagen
Ursprung
Commit
5e6105a4da

BIN
app/__pycache__/config.cpython-311.pyc


BIN
app/api/v1/__pycache__/export.cpython-311.pyc


+ 2 - 0
app/api/v1/export.py

@@ -41,12 +41,14 @@ async def export_document(body: ExportDocRequest) -> dict:
41 41
     result = await export_doc(
42 42
         file_name=body.file_name,
43 43
         content=body.content,
44
+        style_id=body.style_id,
44 45
     )
45 46
 
46 47
     resp = ExportDocResponse(
47 48
         download_url=result["download_url"],
48 49
         file_name=result["file_name"],
49 50
         expires_at=result["expires_at"],
51
+        style_id=result["style_id"],
50 52
     )
51 53
     return _ok(resp.model_dump(by_alias=True))
52 54
 

+ 3 - 0
app/config.py

@@ -14,6 +14,9 @@ class Settings(BaseSettings):
14 14
     # 服务对外访问地址(生成下载链接用)
15 15
     base_url: str = "http://192.168.0.200:8000"
16 16
 
17
+    # 默认样式文件路径(阶段 0 固定使用;阶段 1 作为未指定 styleId 时的兜底)
18
+    default_style_file: str = "./tmp/default.json"
19
+
17 20
     class Config:
18 21
         env_file = ".env"
19 22
         env_file_encoding = "utf-8"

BIN
app/schemas/__pycache__/export.cpython-311.pyc


+ 3 - 0
app/schemas/export.py

@@ -8,6 +8,8 @@ class ExportDocRequest(BaseModel):
8 8
     format: Literal["doc"] = Field(..., description="阶段 0 固定为 doc")
9 9
     content: str = Field(..., description="文档当前内容(Markdown)")
10 10
     document_id: Optional[str] = Field(None, alias="documentId", description="关联文档 ID,传入时同步更新草稿")
11
+    # 阶段 0 暂不生效,后端固定使用 default.json;阶段 1 起传入有效 ID 则使用用户上传的样式
12
+    style_id: Optional[str] = Field(None, alias="styleId", description="样式 ID;不传时使用默认样式")
11 13
 
12 14
     model_config = {"populate_by_name": True}
13 15
 
@@ -16,5 +18,6 @@ class ExportDocResponse(BaseModel):
16 18
     download_url: str = Field(..., serialization_alias="downloadUrl")
17 19
     file_name: str = Field(..., serialization_alias="fileName")
18 20
     expires_at: int = Field(..., serialization_alias="expiresAt", description="Unix 毫秒时间戳")
21
+    style_id: str = Field(..., serialization_alias="styleId", description="实际使用的样式 ID")
19 22
 
20 23
     model_config = {"populate_by_name": True}

BIN
app/services/__pycache__/export_service.cpython-311.pyc


+ 193 - 46
app/services/export_service.py

@@ -1,65 +1,195 @@
1
-"""
2
-export_service.py
3
-将 Markdown 内容转换为 .docx 文件并返回可下载的临时链接。
4
-
5
-阶段 0:支持导出 .doc(实际生成 .docx,浏览器以 .doc 扩展名下载)。
6
-依赖:python-docx, mistune
7
-"""
1
+"""export_service.py — 将 Markdown 内容转换为 .docx 文件并返回可下载的临时链接。"""
8 2
 
9 3
 import io
4
+import json
10 5
 import secrets
11 6
 import time
12 7
 import unicodedata
13 8
 import urllib.parse
14 9
 from pathlib import Path
10
+from typing import Optional
15 11
 
16 12
 import mistune
17 13
 from docx import Document
18 14
 from docx.oxml import OxmlElement
19 15
 from docx.oxml.ns import qn
20 16
 from docx.shared import Pt, RGBColor
17
+from lxml import etree
21 18
 
22 19
 from app.config import settings
23 20
 from app.core.exceptions import ExportError
24 21
 
25 22
 
26 23
 # ------------------------------------------------------------------ #
24
+# 样式文件加载
25
+# ------------------------------------------------------------------ #
26
+
27
+def load_style_file(style_id: Optional[str] = None) -> dict:
28
+    """加载样式 JSON 文件并返回解析后的字典;style_id=None 时加载默认样式文件。"""
29
+    if style_id is None:
30
+        path = Path(settings.default_style_file)
31
+        if not path.exists():
32
+            raise ExportError(f"默认样式文件不存在: {path}")
33
+    else:
34
+        # 阶段 1 占位:届时改为从数据库查路径
35
+        # path = style_repo.get_file_path(style_id)
36
+        raise ExportError(f"样式 ID 暂不支持: {style_id}(阶段 1 功能)")
37
+
38
+    try:
39
+        with open(path, encoding="utf-8") as f:
40
+            return json.load(f)
41
+    except (OSError, json.JSONDecodeError) as exc:
42
+        raise ExportError(f"样式文件解析失败: {exc}") from exc
43
+
44
+
45
+def build_style_map(style_data: dict) -> dict[str, dict]:
46
+    """将样式列表转为双键映射(style_id 和 name 均可命中)。"""
47
+    mapping: dict[str, dict] = {}
48
+    for s in style_data.get("styles", []):
49
+        if s.get("style_id"):
50
+            mapping[s["style_id"]] = s
51
+        if s.get("name"):
52
+            mapping[s["name"]] = s
53
+    return mapping
54
+
55
+
56
+# ------------------------------------------------------------------ #
57
+# JSON ↔ lxml 互转
58
+# ------------------------------------------------------------------ #
59
+
60
+def dict_to_element(d: dict) -> etree._Element:
61
+    """将 element_to_dict() 产生的字典还原为 lxml Element(styles.py 的逆操作)。"""
62
+    tag = d["@tag"]
63
+    attrib = {k: v for k, v in d.get("@attrib", {}).items()}
64
+    elem = etree.Element(tag, attrib=attrib)
65
+
66
+    if d.get("#text"):
67
+        elem.text = d["#text"]
68
+    if d.get("#tail"):
69
+        elem.tail = d["#tail"]
70
+
71
+    for child_tag, child_val in d.get("@children", {}).items():
72
+        # 同标签多子节点时存为列表
73
+        items = child_val if isinstance(child_val, list) else [child_val]
74
+        for item in items:
75
+            if isinstance(item, dict):
76
+                child_elem = dict_to_element(item)
77
+                elem.append(child_elem)
78
+
79
+    return elem
80
+
81
+
82
+def inject_styles_from_json(doc: Document, style_data: dict) -> None:
83
+    """
84
+    将 JSON 中所有样式的 full_xml_definition 注入到文档的 <w:styles> 节点。
85
+    已存在相同 w:styleId 的样式先移除再插入(upsert 语义),确保完整替换。
86
+    没有 full_xml_definition 的条目跳过。
87
+    """
88
+    styles_element = doc.styles.element  # <w:styles> 根节点
89
+
90
+    for style_entry in style_data.get("styles", []):
91
+        xml_def = style_entry.get("full_xml_definition")
92
+        if not xml_def:
93
+            continue
94
+
95
+        # 从字典重建 lxml element
96
+        try:
97
+            new_elem = dict_to_element(xml_def)
98
+        except Exception:
99
+            continue  # 单条解析失败不中断整体
100
+
101
+        # 取出 w:styleId 属性,用于查找并移除旧节点
102
+        style_id_key = qn("w:styleId")
103
+        new_style_id = new_elem.get(style_id_key)
104
+        if new_style_id:
105
+            existing = styles_element.find(
106
+                f'.//{qn("w:style")}[@{qn("w:styleId")}="{new_style_id}"]'
107
+            )
108
+            if existing is not None:
109
+                styles_element.remove(existing)
110
+
111
+        styles_element.append(new_elem)
112
+
113
+
114
+# ------------------------------------------------------------------ #
115
+# 样式 ID 查找辅助
116
+# ------------------------------------------------------------------ #
117
+
118
+def _resolve_style_id(style_map: dict, *lookup_keys: str) -> Optional[str]:
119
+    """
120
+    按优先级依次查找多个候选 key,返回第一个命中条目的 style_id。
121
+    用于将语义名称(如 "Normal"、"Heading 1")映射到 JSON 中实际的 w:styleId。
122
+    """
123
+    for key in lookup_keys:
124
+        entry = style_map.get(key)
125
+        if entry and entry.get("style_id"):
126
+            return entry["style_id"]
127
+    return None
128
+
129
+
130
+# ------------------------------------------------------------------ #
27 131
 # Markdown → python-docx 渲染器
28 132
 # ------------------------------------------------------------------ #
29 133
 
30 134
 class DocxRenderer(mistune.BaseRenderer):
31 135
     """将 mistune AST token 流渲染到 python-docx Document 对象。"""
32 136
 
33
-    def __init__(self) -> None:
34
-        # 创建空白 Word 文档,设置默认字体
137
+    # Word 内置标题样式的英文名(用于查找 style_map 时的候选 key)
138
+    _HEADING_ALIASES = {
139
+        1: ["Heading 1", "heading 1"],
140
+        2: ["Heading 2", "heading 2"],
141
+        3: ["Heading 3", "heading 3"],
142
+        4: ["Heading 4", "heading 4"],
143
+        5: ["Heading 5", "heading 5"],
144
+        6: ["Heading 6", "heading 6"],
145
+    }
146
+
147
+    def __init__(self, style_map: dict, style_data: dict) -> None:
148
+        """初始化渲染器,注入完整样式定义并缓存常用样式 ID。"""
35 149
         super().__init__()
150
+        self.style_map = style_map
36 151
         self.doc = Document()
37
-        self._set_default_font("宋体", 12)
38 152
 
39
-    # ------ 字体初始化 ------ #
153
+        # 用 full_xml_definition 整体替换文档样式表,一次性应用所有格式
154
+        inject_styles_from_json(self.doc, style_data)
40 155
 
41
-    def _set_default_font(self, font_name: str, font_size: int) -> None:
42
-        # 修改 Normal 样式的全局默认字体和字号
43
-        style = self.doc.styles["Normal"]
44
-        style.font.name = font_name
45
-        style.font.size = Pt(font_size)
156
+        # 缓存 Normal 的 style_id,段落渲染时使用
157
+        self._normal_id: Optional[str] = _resolve_style_id(
158
+            style_map, "Normal", "1"
159
+        )
46 160
 
47 161
     # ------ 块级元素 ------ #
48 162
 
49 163
     def heading(self, token: dict, state: mistune.core.BlockState) -> str:
50
-        # # ## ### → Heading 1/2/3,从 token attrs 读取层级,字体颜色强制设为黑色
164
+        # # ## ### → 从 style_map 解析对应样式 ID;找不到则回退到 python-docx 内置标题
51 165
         level = token["attrs"]["level"]
52 166
         children = token.get("children", [])
53 167
         text = self._extract_text(children)
54
-        para = self.doc.add_heading(text, level=level)
55
-        for run in para.runs:
56
-            run.font.color.rgb = RGBColor(0x00, 0x00, 0x00)
168
+
169
+        aliases = self._HEADING_ALIASES.get(level, [f"Heading {level}", f"heading {level}"])
170
+        heading_style_id = _resolve_style_id(self.style_map, *aliases)
171
+
172
+        if heading_style_id:
173
+            # 用 w:styleId 直接引用已注入的样式,不再手动设颜色/字号
174
+            para = self.doc.add_paragraph(text)
175
+            try:
176
+                para.style = self._get_style_by_id(heading_style_id)
177
+            except KeyError:
178
+                pass  # 样式注入失败时保持默认样式,不中断渲染
179
+        else:
180
+            para = self.doc.add_heading(text, level=level)
181
+
57 182
         return ""
58 183
 
59 184
     def paragraph(self, token: dict, state: mistune.core.BlockState) -> str:
60
-        # 普通段落,内部可能含粗体/斜体等内联样式,交给 _render_inline_children 处理
185
+        # 普通段落,内联样式由 _render_inline_children 处理
61 186
         children = token.get("children", [])
62 187
         p = self.doc.add_paragraph()
188
+        if self._normal_id:
189
+            try:
190
+                p.style = self._get_style_by_id(self._normal_id)
191
+            except Exception:
192
+                pass
63 193
         self._render_inline_children(p, children)
64 194
         return ""
65 195
 
@@ -68,7 +198,7 @@ class DocxRenderer(mistune.BaseRenderer):
68 198
         return ""
69 199
 
70 200
     def thematic_break(self, token: dict, state: mistune.core.BlockState) -> str:
71
-        # --- 分隔线:python-docx 无直接 API,手动注入 XML 段落底部边框实现
201
+        # --- 分隔线:通过 XML 段落底部边框实现
72 202
         p = self.doc.add_paragraph()
73 203
         pPr = p._p.get_or_add_pPr()
74 204
         pBdr = OxmlElement("w:pBdr")
@@ -82,16 +212,24 @@ class DocxRenderer(mistune.BaseRenderer):
82 212
         return ""
83 213
 
84 214
     def block_quote(self, token: dict, state: mistune.core.BlockState) -> str:
85
-        # > 引用块 → Word Quote 样式(缩进+斜体)
215
+        # > 引用块 → Quote 样式(缩进+斜体)
86 216
         children = token.get("children", [])
87 217
         for child in children:
88 218
             text = self._extract_text(child.get("children", []))
89
-            p = self.doc.add_paragraph(style="Quote")
90
-            p.add_run(text)
219
+            quote_id = _resolve_style_id(self.style_map, "Quote", "Quote Char")
220
+            if quote_id:
221
+                p = self.doc.add_paragraph(text)
222
+                try:
223
+                    p.style = self._get_style_by_id(quote_id)
224
+                except Exception:
225
+                    p.style = "Quote"
226
+            else:
227
+                p = self.doc.add_paragraph(style="Quote")
228
+                p.add_run(text)
91 229
         return ""
92 230
 
93 231
     def block_code(self, token: dict, state: mistune.core.BlockState) -> str:
94
-        # 代码块 → No Spacing 样式,Courier New 10pt 深灰色
232
+        # 代码块 → No Spacing 样式,Courier New 10pt 深灰色(样式库通常无代码块样式,保持硬编码)
95 233
         code = token.get("raw", "")
96 234
         p = self.doc.add_paragraph(style="No Spacing")
97 235
         run = p.add_run(code)
@@ -101,16 +239,14 @@ class DocxRenderer(mistune.BaseRenderer):
101 239
         return ""
102 240
 
103 241
     def list(self, token: dict, state: mistune.core.BlockState) -> str:
104
-        # 列表入口,判断有序/无序和当前嵌套深度,具体渲染交给 _render_list_items
242
+        # 列表入口,判断有序/无序和嵌套深度,委托 _render_list_items 处理
105 243
         ordered = token["attrs"].get("ordered", False)
106 244
         depth = token["attrs"].get("depth", 1)
107 245
         self._render_list_items(token.get("children", []), ordered, depth)
108 246
         return ""
109 247
 
110
-    def _render_list_items(
111
-        self, items: list, ordered: bool, depth: int
112
-    ) -> None:
113
-        # 递归处理列表项;遇到嵌套子列表时 depth+1,对应 List Bullet/Number 2 样式
248
+    def _render_list_items(self, items: list, ordered: bool, depth: int) -> None:
249
+        # 递归处理列表项;嵌套子列表 depth+1,对应 List Bullet/Number 2 样式
114 250
         for item in items:
115 251
             children = item.get("children", [])
116 252
             for child in children:
@@ -128,8 +264,7 @@ class DocxRenderer(mistune.BaseRenderer):
128 264
                     self.doc.add_paragraph(text, style=style)
129 265
 
130 266
     def table(self, token: dict, state: mistune.core.BlockState) -> str:
131
-        # 表格渲染
132
-        # mistune AST 结构:table_head 直接含 table_cell;table_body 含 table_row → table_cell
267
+        # 表格渲染:table_head 直接含 table_cell;table_body 含 table_row → table_cell
133 268
         children = token.get("children", [])
134 269
         if not children:
135 270
             return ""
@@ -173,7 +308,7 @@ class DocxRenderer(mistune.BaseRenderer):
173 308
     # ------ 内联样式 ------ #
174 309
 
175 310
     def _render_inline_children(self, paragraph, children: list) -> None:
176
-        # 遍历段落内子节点,按类型设置 run 样式:粗体/斜体/删除线/行内代码/换行
311
+        # 遍历子节点,按类型设置 run 样式:粗体/斜体/删除线/行内代码/换行
177 312
         for child in children:
178 313
             ctype = child.get("type", "")
179 314
             raw = child.get("raw", "")
@@ -207,6 +342,13 @@ class DocxRenderer(mistune.BaseRenderer):
207 342
 
208 343
     # ------ 工具方法 ------ #
209 344
 
345
+    def _get_style_by_id(self, style_id: str):
346
+        """通过 w:styleId 从文档样式集中查找 python-docx Style 对象。"""
347
+        for style in self.doc.styles:
348
+            if style.style_id == style_id:
349
+                return style
350
+        raise KeyError(f"样式 ID 不存在: {style_id}")
351
+
210 352
     @staticmethod
211 353
     def _extract_text(children: list) -> str:
212 354
         # 递归提取纯文本,用于标题/列表/表格等不需要内联样式的场景
@@ -222,7 +364,7 @@ class DocxRenderer(mistune.BaseRenderer):
222 364
         return "".join(parts)
223 365
 
224 366
     def render_token(self, token: dict, state: mistune.core.BlockState) -> str:
225
-        # mistune 分发入口:按 token type 找对应方法,找不到则递归渲染子节点兜底
367
+        # mistune 分发入口:按 token type 找对应方法,找不到则递归子节点兜底
226 368
         ttype = token["type"]
227 369
         func = getattr(self, ttype, None)
228 370
         if func:
@@ -250,9 +392,9 @@ class DocxRenderer(mistune.BaseRenderer):
250 392
 # 公共接口
251 393
 # ------------------------------------------------------------------ #
252 394
 
253
-def markdown_to_docx_bytes(content: str) -> bytes:
254
-    """Markdown 字符串 .docx 字节流,全程内存操作不落盘。"""
255
-    renderer = DocxRenderer()
395
+def markdown_to_docx_bytes(content: str, style_map: dict, style_data: dict) -> bytes:
396
+    """Markdown 字符串 .docx 字节流,全程内存操作不落盘。"""
397
+    renderer = DocxRenderer(style_map=style_map, style_data=style_data)
256 398
     # 必须显式启用插件:mistune 默认不解析表格和删除线
257 399
     md = mistune.create_markdown(
258 400
         renderer=renderer,
@@ -267,7 +409,7 @@ def markdown_to_docx_bytes(content: str) -> bytes:
267 409
 
268 410
 def _safe_filename(name: str) -> str:
269 411
     """过滤文件名非法字符,规范化全角字符,返回安全的文件名。"""
270
-    name = unicodedata.normalize("NFKC", name)  # 全角转半角
412
+    name = unicodedata.normalize("NFKC", name)
271 413
     illegal = r'\/:*?"<>|'
272 414
     for ch in illegal:
273 415
         name = name.replace(ch, "_")
@@ -277,20 +419,24 @@ def _safe_filename(name: str) -> str:
277 419
 async def export_doc(
278 420
     file_name: str,
279 421
     content: str,
422
+    style_id: Optional[str] = None,
280 423
 ) -> dict:
281
-    """
282
-    导出入口:生成 .docx 写入临时目录,返回下载链接信息。
424
+    """导出入口:加载样式、生成 .docx 写入临时目录,返回 { download_url, file_name, expires_at, style_id }。"""
425
+    # 1. 加载样式文件,构建映射
426
+    style_data = load_style_file(style_id)
427
+    style_map = build_style_map(style_data)
428
+    actual_style_id = style_id or "default"
283 429
 
284
-    返回:{ download_url, file_name, expires_at }
285
-    """
430
+    # 2. Markdown → docx 字节流
286 431
     try:
287
-        doc_bytes = markdown_to_docx_bytes(content)
432
+        doc_bytes = markdown_to_docx_bytes(content, style_map, style_data)
288 433
     except Exception as exc:
289 434
         raise ExportError(f"Markdown 转换失败: {exc}") from exc
290 435
 
436
+    # 3. 写入临时目录
291 437
     safe_name = _safe_filename(file_name)
292 438
     token = secrets.token_urlsafe(8)
293
-    final_name = f"{safe_name}_{token}.doc"   # 磁盘文件名含中文原始名
439
+    final_name = f"{safe_name}_{token}.doc"
294 440
 
295 441
     tmp_dir = Path(settings.temp_dir)
296 442
     tmp_dir.mkdir(parents=True, exist_ok=True)
@@ -301,8 +447,8 @@ async def export_doc(
301 447
     except OSError as exc:
302 448
         raise ExportError(f"文件写入失败: {exc}") from exc
303 449
 
450
+    # 4. 生成下载链接
304 451
     expires_at_ms = int((time.time() + settings.export_link_expires) * 1000)
305
-    # 文件名中文部分编码进 URL,下载路由收到后 FastAPI 自动解码还原
306 452
     encoded_name = urllib.parse.quote(final_name, safe="-._~")
307 453
     download_url = f"{settings.base_url.rstrip('/')}/api/v1/files/{encoded_name}"
308 454
 
@@ -310,4 +456,5 @@ async def export_doc(
310 456
         "download_url": download_url,
311 457
         "file_name": final_name,
312 458
         "expires_at": expires_at_ms,
459
+        "style_id": actual_style_id,
313 460
     }

+ 172 - 26
docs/export-doc-content-mapping.md

@@ -16,6 +16,7 @@
16 16
 | 核心依赖 | `python-docx 1.1.2` · `mistune 3.0.2` |
17 17
 | 文件存储 | 服务器本地 `./tmp/` 目录(临时缓存,当前不自动清理) |
18 18
 | 下载链接有效期 | 默认 3600 秒(1 小时),由 `EXPORT_LINK_EXPIRES` 配置 |
19
+| 默认样式文件 | `./tmp/default.json`(阶段 0 固定使用;阶段 1 起支持用户选择) |
19 20
 
20 21
 ---
21 22
 
@@ -43,7 +44,8 @@
43 44
   "fileName": "2026年Q2季度报告",
44 45
   "format": "doc",
45 46
   "content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n...",
46
-  "documentId": "doc-abc123"
47
+  "documentId": "doc-abc123",
48
+  "styleId": null
47 49
 }
48 50
 ```
49 51
 
@@ -53,6 +55,7 @@
53 55
 | `format` | string | 是 | 阶段 0 固定为 `"doc"` |
54 56
 | `content` | string | 是 | 文档当前 Markdown 内容,最大 200KB |
55 57
 | `documentId` | string | 否 | 关联文档 ID,传入时后端同步更新草稿记录 |
58
+| `styleId` | string | 否 | 样式 ID;**阶段 0 暂不生效**,后端固定使用 `default.json`;阶段 1 起传入有效 ID 则使用用户上传的样式 |
56 59
 
57 60
 #### 响应
58 61
 
@@ -65,7 +68,8 @@
65 68
   "data": {
66 69
     "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc",
67 70
     "fileName": "2026年Q2季度报告.doc",
68
-    "expiresAt": 1749999999000
71
+    "expiresAt": 1749999999000,
72
+    "styleId": "default"
69 73
   }
70 74
 }
71 75
 ```
@@ -75,6 +79,7 @@
75 79
 | `downloadUrl` | string | 临时下载链接,有效期由 `expiresAt` 标注 |
76 80
 | `fileName` | string | 含扩展名的完整文件名 |
77 81
 | `expiresAt` | number | 链接过期时间,Unix 毫秒时间戳 |
82
+| `styleId` | string | 实际使用的样式 ID;阶段 0 固定返回 `"default"` |
78 83
 
79 84
 **错误响应**
80 85
 
@@ -151,31 +156,166 @@ PUT documents/{id}
151 156
     └─────┬─────┘
152 157
           |
153 158
           v
159
+  ┌─────────────────────────────┐
160
+  │        样式文件解析           │
161
+  │                             │
162
+  │  styleId 传入且有效?         │
163
+  │    Yes → 从样式库加载对应     │
164
+  │          JSON 文件           │
165
+  │    No  → 加载默认样式文件     │
166
+  │          ./tmp/default.json  │
167
+  │                             │
168
+  │  解析 JSON,提取各样式定义    │
169
+  │  (Normal、Heading1-6、      │
170
+  │   List Bullet/Number 等)    │
171
+  └─────────────┬───────────────┘
172
+                │
173
+                v
154 174
 export_service.export_doc()
155
-  1. markdown_to_docx_bytes(content)
156
-     - DocxRenderer 初始化
157
-       └── 设置默认字体:宋体 12pt
175
+  1. load_style_definitions(style_file)
176
+     - 读取样式 JSON(默认或用户指定)
177
+     - 构建 style_id -> 样式属性 映射表
178
+
179
+  2. markdown_to_docx_bytes(content, styles)
180
+     - DocxRenderer 初始化,注入样式映射
181
+       └── 应用 Normal 样式的字体和字号
182
+           (默认:宋体 12pt)
158 183
      - mistune 解析 Markdown -> AST
159 184
        plugins: table, strikethrough, url
160
-     - 遍历 token,映射到 python-docx
185
+     - 遍历 token,按映射表应用对应样式
161 186
 
162
-  2. _safe_filename(fileName)
187
+  3. _safe_filename(fileName)
163 188
      └── 过滤非法字符,规范化 Unicode
164 189
 
165
-  3. 写入 ./tmp/{name}_{token}.doc
190
+  4. 写入 ./tmp/{name}_{token}.doc
166 191
 
167
-  4. 生成 downloadUrl + expiresAt
192
+  5. 生成 downloadUrl + expiresAt
168 193
           |
169 194
           v
170 195
 返回 ExportDocResponse
171
-  { downloadUrl, fileName, expiresAt }
196
+  { downloadUrl, fileName, expiresAt, styleId }
197
+```
198
+
199
+### 样式加载优先级
200
+
201
+```
202
+请求携带 styleId(阶段 1)
203
+    └─ styleId 有效 → 使用用户样式库中对应的 JSON 文件
204
+    └─ styleId 无效 → 返回 404,不降级
205
+
206
+请求未携带 styleId(阶段 0 / 默认)
207
+    └─ 加载 ./tmp/default.json(由 DEFAULT_STYLE_FILE 配置)
208
+    └─ default.json 不存在 → 返回 500
172 209
 ```
173 210
 
174 211
 ---
175 212
 
176
-## 4. Markdown 与 Word 样式映射
213
+## 4. 默认样式文件(`default.json`)
214
+
215
+### 4.1 文件位置与用途
216
+
217
+| 项目 | 内容 |
218
+|------|------|
219
+| 文件路径 | `./tmp/default.json`(由 `DEFAULT_STYLE_FILE` 配置) |
220
+| 格式 | 样式 JSON,与用户上传文档提取的格式完全相同 |
221
+| 来源 | 从内置标准 Word 文档提取,随服务部署一同发布 |
222
+| 阶段 0 行为 | 所有导出请求固定使用此文件,不支持切换 |
223
+| 阶段 1 行为 | 用户未传 `styleId` 时作为兜底;用户可上传文档生成新样式并选用 |
224
+
225
+### 4.2 文件结构
226
+
227
+```json
228
+{
229
+  "source_file": "default.doc",
230
+  "total_styles": 164,
231
+  "styles": [
232
+    {
233
+      "name": "Normal",
234
+      "style_id": "Normal",
235
+      "type": "PARAGRAPH (1)",
236
+      "builtin": true,
237
+      "hidden": false,
238
+      "quick_style": true,
239
+      "priority": null,
240
+      "base_style": null,
241
+      "next_paragraph_style": "Normal",
242
+      "font": {
243
+        "name": "宋体",
244
+        "size_pt": 12.0,
245
+        "bold": null,
246
+        "italic": null,
247
+        "underline": null,
248
+        "color_rgb": null,
249
+        "strike": null,
250
+        "all_caps": null,
251
+        "small_caps": null
252
+      },
253
+      "paragraph_format": {
254
+        "alignment": null,
255
+        "left_indent_pt": null,
256
+        "right_indent_pt": null,
257
+        "first_line_indent_pt": null,
258
+        "space_before_pt": null,
259
+        "space_after_pt": null,
260
+        "line_spacing": null,
261
+        "keep_together": null,
262
+        "keep_with_next": null,
263
+        "page_break_before": null
264
+      }
265
+    },
266
+    {
267
+      "name": "Heading 1",
268
+      "style_id": "Heading1",
269
+      "type": "PARAGRAPH (1)",
270
+      "font": { "size_pt": 14.0, "bold": true, "color_rgb": "365F91" },
271
+      "paragraph_format": { "space_before_pt": 24.0, "space_after_pt": 0.0 }
272
+    }
273
+    // ... 其余 162 个样式定义
274
+  ]
275
+}
276
+```
177 277
 
178
-### 4.1 标题
278
+字段说明见 [text-editor-backend-api.md §4 数据结构](./text-editor-backend-api.md)。
279
+
280
+### 4.3 后端加载逻辑(伪代码)
281
+
282
+```python
283
+def load_style_file(style_id: str | None) -> dict:
284
+    """
285
+    阶段 0:style_id 始终为 None,返回 default.json
286
+    阶段 1:style_id 有值时从样式库查找对应 JSON 路径
287
+    """
288
+    if style_id is None:
289
+        path = settings.default_style_file   # ./tmp/default.json
290
+    else:
291
+        # 阶段 1:从数据库查路径,找不到抛 404
292
+        path = style_repo.get_file_path(style_id)
293
+
294
+    with open(path, encoding="utf-8") as f:
295
+        return json.load(f)
296
+
297
+
298
+def build_style_map(style_data: dict) -> dict[str, dict]:
299
+    """将样式列表转换为 style_id -> 样式属性 的映射,加速查询"""
300
+    return {s["style_id"]: s for s in style_data["styles"]}
301
+```
302
+
303
+### 4.4 样式应用规则
304
+
305
+导出时,`DocxRenderer` 按以下规则从样式映射中读取属性并应用到 `python-docx` 对象:
306
+
307
+| 优先级 | 规则 |
308
+|--------|------|
309
+| 1 | 使用样式 JSON 中对应 `style_id` 的字体和段落格式 |
310
+| 2 | JSON 中字段为 `null` 时,保留 `python-docx` 内置 Word 样式默认值 |
311
+| 3 | 标题颜色:JSON 有 `color_rgb` 时使用,否则强制黑色(覆盖 Word 内置蓝色) |
312
+| 4 | 找不到对应 `style_id` 时,回退到 `Normal` 样式 |
313
+
314
+---
315
+
316
+## 5. Markdown 与 Word 样式映射
317
+
318
+### 5.1 标题
179 319
 
180 320
 | Markdown | Word 样式 | python-docx 调用 |
181 321
 |----------|-----------|-----------------|
@@ -187,7 +327,7 @@ export_service.export_doc()
187 327
 
188 328
 > 所有标题颜色强制设为黑色,覆盖 Word 内置 Heading 样式的蓝色主题色。
189 329
 
190
-### 4.2 内联样式
330
+### 5.2 内联样式
191 331
 
192 332
 | Markdown | 语义 | python-docx 处理 |
193 333
 |----------|------|-----------------|
@@ -196,7 +336,7 @@ export_service.export_doc()
196 336
 | `~~文字~~` | 删除线 | `run.font.strike = True` |
197 337
 | `` `代码` `` | 行内代码 | `run.font.name = "Courier New"` |
198 338
 
199
-### 4.3 列表
339
+### 5.3 列表
200 340
 
201 341
 | Markdown | Word 样式 | python-docx 调用 |
202 342
 |----------|-----------|-----------------|
@@ -205,7 +345,7 @@ export_service.export_doc()
205 345
 | 二级无序缩进 | List Bullet 2 | `doc.add_paragraph(style="List Bullet 2")` |
206 346
 | 二级有序缩进 | List Number 2 | `doc.add_paragraph(style="List Number 2")` |
207 347
 
208
-### 4.4 块级元素
348
+### 5.4 块级元素
209 349
 
210 350
 | Markdown | Word 样式 | python-docx 处理 |
211 351
 |----------|-----------|-----------------|
@@ -214,7 +354,7 @@ export_service.export_doc()
214 354
 | `---` / `***` | 段落底部边框 | OxmlElement 注入 `w:pBdr` |
215 355
 | `\| 列 \| 列 \|` | Table Grid | `doc.add_table()`,表头行自动加粗 |
216 356
 
217
-### 4.5 表格 AST 结构说明
357
+### 5.5 表格 AST 结构说明
218 358
 
219 359
 mistune 3.x 解析表格后的 token 层级(需启用 `table` 插件):
220 360
 
@@ -233,7 +373,7 @@ table
233 373
 
234 374
 ---
235 375
 
236
-## 5. 临时文件说明
376
+## 6. 临时文件说明
237 377
 
238 378
 | 项目 | 内容 |
239 379
 |------|------|
@@ -249,30 +389,32 @@ table
249 389
 
250 390
 ---
251 391
 
252
-## 6. 配置项
392
+## 7. 配置项
253 393
 
254 394
 | 环境变量 | 默认值 | 说明 |
255 395
 |---------|--------|------|
256 396
 | `TEMP_DIR` | `./tmp` | 临时文件存储目录 |
257 397
 | `EXPORT_LINK_EXPIRES` | `3600` | 下载链接有效期(秒) |
258 398
 | `BASE_URL` | `http://192.168.0.200:8000` | 服务对外地址,用于拼接 `downloadUrl` |
399
+| `DEFAULT_STYLE_FILE` | `./tmp/default.json` | 默认样式文件路径;阶段 0 固定使用,阶段 1 作为兜底 |
259 400
 
260 401
 ---
261 402
 
262
-## 7. 涉及代码文件
403
+## 8. 涉及代码文件
263 404
 
264 405
 | 文件 | 职责 |
265 406
 |------|------|
266 407
 | `app/api/v1/export.py` | 路由层:参数接收、可选草稿同步、响应封装 |
267 408
 | `app/schemas/export.py` | 请求/响应 Pydantic 模型,字段别名(camelCase) |
268
-| `app/services/export_service.py` | 核心:`DocxRenderer`、`markdown_to_docx_bytes()`、文件写入、链接生成 |
409
+| `app/services/export_service.py` | 核心:`DocxRenderer`、`load_style_file()`、`markdown_to_docx_bytes()`、文件写入、链接生成 |
269 410
 | `app/services/document_service.py` | 可选路径:`documentId` 存在时调用 `update_document()` |
270
-| `app/config.py` | `temp_dir`、`base_url`、`export_link_expires` 配置读取 |
411
+| `app/config.py` | `temp_dir`、`base_url`、`export_link_expires`、`default_style_file` 配置读取 |
271 412
 | `app/core/exceptions.py` | `ExportError` 定义及全局 handler(返回 HTTP 500) |
413
+| `tmp/default.json` | 默认样式文件,阶段 0 固定使用 |
272 414
 
273 415
 ---
274 416
 
275
-## 8. 请求示例
417
+## 9. 请求示例
276 418
 
277 419
 ### curl
278 420
 
@@ -282,10 +424,13 @@ curl -X POST http://192.168.0.200:8000/api/v1/export/doc \
282 424
   -d '{
283 425
     "fileName": "2026年Q2季度报告",
284 426
     "format": "doc",
285
-    "content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n## 二、核心数据\n\n### 2.1 收入\n\n| 月份 | 收入(万元) |\n|------|------|\n| 4月 | 120 |\n| 5月 | 135 |\n\n### 2.2 用户增长\n\n- 新增用户:12,000\n- 月活用户:85,000\n\n## 三、下季度计划\n\n1. 推进产品 A 上线\n2. 扩充销售团队\n"
427
+    "content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n## 二、核心数据\n\n### 2.1 收入\n\n| 月份 | 收入(万元) |\n|------|------|\n| 4月 | 120 |\n| 5月 | 135 |\n\n### 2.2 用户增长\n\n- 新增用户:12,000\n- 月活用户:85,000\n\n## 三、下季度计划\n\n1. 推进产品 A 上线\n2. 扩充销售团队\n",
428
+    "styleId": null
286 429
   }'
287 430
 ```
288 431
 
432
+> 阶段 0 传 `null` 或不传 `styleId`,后端均使用 `default.json`。
433
+
289 434
 ### 预期响应
290 435
 
291 436
 ```json
@@ -295,7 +440,8 @@ curl -X POST http://192.168.0.200:8000/api/v1/export/doc \
295 440
   "data": {
296 441
     "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc",
297 442
     "fileName": "2026年Q2季度报告.doc",
298
-    "expiresAt": 1749999999000
443
+    "expiresAt": 1749999999000,
444
+    "styleId": "default"
299 445
   }
300 446
 }
301 447
 ```
@@ -309,6 +455,6 @@ curl -O "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1k
309 455
 
310 456
 ---
311 457
 
312
-**文档版本**:v2.1
313
-**更新日期**:2026-06-12
458
+**文档版本**:v2.2
459
+**更新日期**:2026-06-15
314 460
 **关联文档**:[text-editor-backend-api.md](./text-editor-backend-api.md) · [text-editor-architecture.md](./text-editor-architecture.md) · [environment-setup.md](./environment-setup.md)

+ 398 - 95
docs/text-editor-backend-api.md

@@ -14,12 +14,23 @@
14 14
 
15 15
 | 阶段 | 新增 API |
16 16
 |------|---------|
17
-| 阶段 0 | 文档 CRUD |
18
-| 阶段 1 | 模板管理、DOCX 下载导出 |
17
+| 阶段 0 | 文档 CRUD、导出 .doc(默认样式) |
18
+| 阶段 1 | 样式管理、模板管理、导出 DOCX |
19 19
 | 阶段 2 | 编辑会话(Token)、Webhook |
20 20
 | 阶段 3 | PDF 导出、版本管理 |
21 21
 
22
-### 1.3 通用响应格式
22
+### 1.3 样式与模板的区别
23
+
24
+这是两个独立功能,用户可以按需使用其中一个或两个都不用:
25
+
26
+| | 样式(Style) | 模板(Template) |
27
+|---|---|---|
28
+| 用途 | 控制输出文件的字体、颜色、标题层级等视觉格式 | 定义文档结构,内容填入占位符位置 |
29
+| 上传内容 | 任意 Word 文档,后端提取其中的样式文件 | 带占位符(`{{名称}}`)的 Word 文档 |
30
+| 导出方式 | 导出 .doc 时选择样式,不选用系统默认 | 导出 DOCX 时指定模板,内容按标题匹配占位符 |
31
+| 是否必须 | 否,有默认样式兜底 | 否,不使用模板则走样式导出流程 |
32
+
33
+### 1.4 通用响应格式
23 34
 
24 35
 ```json
25 36
 {
@@ -53,8 +64,7 @@ class CreateDocumentRequest(BaseModel):
53 64
     content: str                                    # 文档内容(Markdown),最大 200KB
54 65
     format: Literal["markdown"] = "markdown"        # 内容格式,默认 markdown
55 66
     session_id: Optional[str] = None                # 关联的聊天会话 ID
56
-    template_id: Optional[str] = None              # 关联 Word 模板 ID(阶段 1,工作流文档必填)
57
-    source: Literal["chat", "workflow"] = "chat"   # 来源,默认 chat
67
+    template_id: Optional[str] = None              # 关联 Word 模板 ID(阶段 1 引入)
58 68
 
59 69
 # 更新文档请求(全量或局部块)
60 70
 class UpdateDocumentRequest(BaseModel):
@@ -70,7 +80,6 @@ class DocumentResponse(BaseModel):
70 80
     format: str
71 81
     session_id: Optional[str] = None
72 82
     template_id: Optional[str] = None
73
-    source: Literal["chat", "workflow"]
74 83
     created_by: str
75 84
     created_at: datetime
76 85
     updated_at: datetime
@@ -79,7 +88,6 @@ class DocumentResponse(BaseModel):
79 88
 class DocumentListItem(BaseModel):
80 89
     id: str
81 90
     title: str
82
-    source: Literal["chat", "workflow"]
83 91
     template_id: Optional[str] = None
84 92
     created_at: datetime
85 93
     updated_at: datetime
@@ -107,8 +115,7 @@ class Pagination(BaseModel):
107 115
   "content": "# 标题\n\n这是文档内容...",
108 116
   "format": "markdown",
109 117
   "sessionId": "chat-session-123",
110
-  "templateId": "tpl-001",
111
-  "source": "chat"
118
+  "templateId": "tpl-001"
112 119
 }
113 120
 ```
114 121
 
@@ -118,8 +125,7 @@ class Pagination(BaseModel):
118 125
 | content | string | 是 | 文档内容,最大 200KB |
119 126
 | format | string | 否 | 默认 `markdown` |
120 127
 | sessionId | string | 否 | 关联聊天会话 ID |
121
-| templateId | string | 否 | 关联 Word 模板 ID(阶段 1 引入,工作流文档必填) |
122
-| source | string | 否 | `chat` / `workflow`,默认 `chat` |
128
+| templateId | string | 否 | 关联 Word 模板 ID(阶段 1 引入) |
123 129
 
124 130
 **响应示例**:
125 131
 ```json
@@ -154,7 +160,6 @@ class Pagination(BaseModel):
154 160
     "format": "markdown",
155 161
     "sessionId": "chat-session-123",
156 162
     "templateId": "tpl-001",
157
-    "source": "workflow",
158 163
     "createdBy": "user-xyz",
159 164
     "createdAt": 1680000000000,
160 165
     "updatedAt": 1680000100000
@@ -243,7 +248,6 @@ class Pagination(BaseModel):
243 248
 | page | integer | 否 | 页码,默认 1 |
244 249
 | pageSize | integer | 否 | 每页数量,默认 20,最大 100 |
245 250
 | sessionId | string | 否 | 按聊天会话过滤 |
246
-| source | string | 否 | `chat` / `workflow` |
247 251
 | sortBy | string | 否 | `createdAt` / `updatedAt`,默认 `updatedAt` |
248 252
 | sortOrder | string | 否 | `asc` / `desc`,默认 `desc` |
249 253
 
@@ -256,7 +260,6 @@ class Pagination(BaseModel):
256 260
       {
257 261
         "id": "doc-abc123",
258 262
         "title": "我的文档",
259
-        "source": "workflow",
260 263
         "templateId": "tpl-001",
261 264
         "createdAt": 1680000000000,
262 265
         "updatedAt": 1680000100000
@@ -276,6 +279,8 @@ class Pagination(BaseModel):
276 279
 
277 280
 ## 3. 导出 API(阶段 0)
278 281
 
282
+**本节说明**:阶段 0 导出不依赖样式库和模板,使用系统内置默认样式将 Markdown 内容转换为 .doc 文件。阶段 1 引入样式管理后,本接口扩展支持用户选择自定义样式(通过 `styleId` 参数),接口结构不变。
283
+
279 284
 ### 数据结构
280 285
 
281 286
 ```python
@@ -284,22 +289,24 @@ from typing import Literal, Optional
284 289
 
285 290
 # 导出 .doc 请求
286 291
 class ExportDocRequest(BaseModel):
287
-    file_name: str              # 文件名(不含扩展名),最大 255 字符
288
-    format: Literal["doc"]      # 阶段 0 固定为 doc
289
-    content: str                # 文档当前内容(Markdown)
292
+    file_name: str                      # 文件名(不含扩展名),最大 255 字符
293
+    format: Literal["doc"]              # 阶段 0 固定为 doc
294
+    content: str                        # 文档当前内容(Markdown)
290 295
     document_id: Optional[str] = None  # 关联文档 ID,传入时后端同步更新草稿记录
296
+    style_id: Optional[str] = None     # 样式 ID;不传时使用系统默认样式;阶段 1 起支持用户自定义样式
291 297
 
292 298
 # 导出响应
293 299
 class ExportDocResponse(BaseModel):
294 300
     download_url: str   # 临时下载链接,有效期 1 小时
295 301
     file_name: str      # 含扩展名的完整文件名,如 季度报告_2026Q2.doc
296 302
     expires_at: int     # 链接过期时间,Unix 毫秒时间戳
303
+    style_id: str       # 实际使用的样式 ID
297 304
 ```
298 305
 
299 306
 ### 3.1 导出 .doc 并下载
300 307
 
301 308
 **触发时机**:用户点击编辑器"保存"按钮,在弹出对话框中填写文件名、选择格式并确认后触发。  
302
-**目的**:根据用户填写的文件名、格式和当前文档内容,生成对应格式的文件,返回临时下载链接,前端自动触发浏览器下载
309
+**目的**:将 Markdown 内容按指定样式渲染生成 .doc 文件,返回临时下载链接。输出字体、标题层级、段落间距等均由样式文件控制
303 310
 **注意**:此接口不操作数据库文档记录,仅负责文件生成与下载链接的返回。如需同时更新草稿,可传入 `documentId`,后端会先更新一次记录再生成文件。
304 311
 
305 312
 **接口地址**: `POST /api/v1/export/doc`
@@ -310,7 +317,8 @@ class ExportDocResponse(BaseModel):
310 317
   "fileName": "季度报告_2026Q2",
311 318
   "format": "doc",
312 319
   "content": "# 标题\n\n这是文档内容...",
313
-  "documentId": "doc-abc123"
320
+  "documentId": "doc-abc123",
321
+  "styleId": "style-001"
314 322
 }
315 323
 ```
316 324
 
@@ -319,7 +327,8 @@ class ExportDocResponse(BaseModel):
319 327
 | fileName | string | 是 | 用户填写的文件名(不含扩展名),最大 255 字符 |
320 328
 | format | string | 是 | 文件格式,阶段 0 固定为 `doc` |
321 329
 | content | string | 是 | 文档当前内容(Markdown) |
322
-| documentId | string | 否 | 关联的文档 ID,用于更新自动保存记录 |
330
+| documentId | string | 否 | 关联的文档 ID,用于同步更新自动保存记录 |
331
+| styleId | string | 否 | 样式 ID;不传时使用系统默认样式;阶段 1 起可传用户上传的自定义样式 ID |
323 332
 
324 333
 **响应示例**:
325 334
 ```json
@@ -328,7 +337,8 @@ class ExportDocResponse(BaseModel):
328 337
   "data": {
329 338
     "downloadUrl": "https://api.axonix.com/files/download/季度报告_2026Q2.doc?token=xxx",
330 339
     "fileName": "季度报告_2026Q2.doc",
331
-    "expiresAt": 1680003600000
340
+    "expiresAt": 1680003600000,
341
+    "styleId": "default"
332 342
   }
333 343
 }
334 344
 ```
@@ -337,7 +347,15 @@ class ExportDocResponse(BaseModel):
337 347
 
338 348
 ---
339 349
 
340
-## 4. Word 模板管理 API(阶段 1)
350
+## 4. 样式管理 API(阶段 1)
351
+
352
+用户上传任意 Word 文档,后端自动提取其中**所有样式的完整 XML 定义**,生成与原文档同名的样式文件并持久化存储,加入用户的样式缓存列表,导出 .doc 时可从列表中选择使用。
353
+
354
+### 功能说明
355
+
356
+- 上传文档后,后端遍历文档中的所有样式(段落样式、字符样式、表格样式、列表样式等),完整保留每个样式的原始 XML 结构(含所有属性、子元素和命名空间),不做任何简化裁剪。
357
+- 样式文件以上传文档的原始文件名命名(去掉扩展名),存入用户的样式库缓存。
358
+- 每次导出 .doc 时,用户可从缓存列表中选择一个样式应用到输出文件;不选则使用系统默认样式。
341 359
 
342 360
 ### 数据结构
343 361
 
@@ -346,45 +364,308 @@ from pydantic import BaseModel
346 364
 from typing import Optional
347 365
 from datetime import datetime
348 366
 
349
-# 标题样式
350
-class HeadingStyle(BaseModel):
351
-    level: int              # 标题等级 1-6
352
-    font: str               # 字体名称,如 "宋体"
353
-    size: int               # 字号(半磅单位,22 = 11pt)
354
-    bold: bool
355
-    color: Optional[str] = None  # 十六进制颜色,如 "#000000"
356
-
357
-# 页边距
358
-class PageMargins(BaseModel):
359
-    top: float      # 单位:磅
360
-    bottom: float
361
-    left: float
362
-    right: float
363
-
364
-# 模板样式元数据
365
-class TemplateStyles(BaseModel):
366
-    headings: list[HeadingStyle]
367
-    default_font: str
368
-    default_size: int
369
-    page_margins: Optional[PageMargins] = None
370
-    has_header: bool
371
-    has_footer: bool
367
+# 单个样式的完整定义
368
+class StyleDefinition(BaseModel):
369
+    name: str               # 样式名称,如 "标题 1"
370
+    style_id: str           # 样式 ID,如 "1"
371
+    type: str               # 样式类型:paragraph / character / table / numbering
372
+    builtin: bool           # 是否为内置样式
373
+    hidden: bool            # 是否隐藏
374
+    quick_style: bool       # 是否显示在快速样式库
375
+    priority: Optional[int] # 显示优先级
376
+    base_style: Optional[str]           # 基础样式名称
377
+    next_paragraph_style: Optional[str] # 下一段落样式名称
378
+    font_summary: Optional[dict]        # 字体摘要(name, size_pt, bold, italic 等)
379
+    paragraph_format_summary: Optional[dict]  # 段落格式摘要(alignment, indent, spacing 等)
380
+    full_xml_definition: dict           # 完整原始 XML(转换为嵌套字典,保留所有属性和子元素)
381
+
382
+# 样式文件摘要(列表展示用)
383
+class StyleFileSummary(BaseModel):
384
+    default_font: str           # 正文默认字体,如 "宋体"
385
+    default_size_pt: float      # 正文默认字号(磅),如 12.0
386
+    heading_fonts: list[str]    # 各级标题字体列表(H1 到 H6)
387
+    total_styles: int           # 提取到的样式总数
388
+    style_types: dict[str, int] # 各类型样式数量,如 {"paragraph": 120, "character": 40}
389
+
390
+# 样式文件完整信息(响应)
391
+class StyleFileResponse(BaseModel):
392
+    style_id: str               # 样式文件唯一 ID
393
+    name: str                   # 样式文件名(源文档文件名去扩展名)
394
+    source_file: str            # 原始上传文件名(含扩展名)
395
+    summary: StyleFileSummary   # 摘要信息,用于列表展示
396
+    is_default: bool            # 是否为系统默认样式
397
+    created_at: datetime
398
+
399
+# 样式文件列表项(不含完整样式定义,减少传输量)
400
+class StyleFileListItem(BaseModel):
401
+    style_id: str
402
+    name: str
403
+    source_file: str
404
+    summary: StyleFileSummary
405
+    is_default: bool
406
+    created_at: datetime
407
+```
408
+
409
+### 后端提取流程
410
+
411
+上传文档后,后端执行以下步骤:
412
+
413
+1. 使用 `python-docx` 打开上传的 Word 文档(.doc / .docx)
414
+2. 遍历文档 `doc.styles` 中的所有样式对象
415
+3. 对每个样式提取:
416
+   - 基本属性(`name`、`style_id`、`type`、`builtin`、`hidden` 等)
417
+   - 字体摘要(通过 `style.font` API 读取 `name`、`size_pt`、`bold`、`italic`、`color_rgb` 等)
418
+   - 段落格式摘要(通过 `style.paragraph_format` API 读取 `alignment`、`indent`、`spacing` 等)
419
+   - **完整 XML 定义**(对 `style.element` 递归转换为嵌套字典,保留所有属性、子元素和文本节点)
420
+4. 以上传文件名(去扩展名)命名,序列化为 JSON 保存到存储层
421
+5. 写入数据库样式缓存记录,返回摘要信息
422
+
423
+### 4.1 上传样式(从 Word 文档提取全量样式 XML)
424
+
425
+**目的**:用户上传任意 Word 文档,后端提取其中所有样式的完整 XML 定义,生成同名样式文件存入缓存列表,供后续导出时选用。
426
+
427
+**接口地址**: `POST /api/v1/styles`
428
+
429
+**请求格式**: `multipart/form-data`
430
+
431
+| 参数 | 类型 | 必填 | 说明 |
432
+|------|------|------|------|
433
+| file | File | 是 | Word 文档(.doc / .docx),最大 20MB |
434
+| name | string | 否 | 自定义样式名称;不传时默认使用上传文件名(去扩展名) |
435
+
436
+**后端处理说明**:
437
+
438
+- 样式文件名默认取自上传文件名(去扩展名),例如上传 `季报模板_2026Q2.docx`,生成样式文件名为 `季报模板_2026Q2`
439
+- 提取结果以 JSON 格式持久化,字段 `full_xml_definition` 包含每个样式的完整原始 XML,转换为嵌套字典结构,保留所有命名空间属性(`w:`、`w14:` 等)和子元素
440
+
441
+**响应示例**:
442
+```json
443
+{
444
+  "code": 0,
445
+  "data": {
446
+    "styleId": "style-001",
447
+    "name": "季报模板_2026Q2",
448
+    "sourceFile": "季报模板_2026Q2.docx",
449
+    "summary": {
450
+      "defaultFont": "宋体",
451
+      "defaultSizePt": 12.0,
452
+      "headingFonts": ["黑体", "黑体", "宋体", "宋体", "宋体", "宋体"],
453
+      "totalStyles": 164,
454
+      "styleTypes": {
455
+        "paragraph": 120,
456
+        "character": 40,
457
+        "table": 3,
458
+        "numbering": 1
459
+      }
460
+    },
461
+    "isDefault": false,
462
+    "createdAt": 1680000000000
463
+  }
464
+}
465
+```
466
+
467
+**错误情况**:
468
+
469
+| HTTP 状态码 | 错误码 | 说明 |
470
+|------------|--------|------|
471
+| 400 | 4001 | 文件格式不支持(非 .doc / .docx) |
472
+| 413 | 4002 | 文件超过 20MB 限制 |
473
+| 422 | 4003 | 文档无法解析(损坏或加密) |
474
+| 409 | 4004 | 同名样式文件已存在(可用 `?overwrite=true` 强制覆盖) |
475
+
476
+---
477
+
478
+### 4.2 获取样式列表(样式缓存)
479
+
480
+**目的**:返回当前用户的样式缓存列表,包含系统默认样式,供前端展示样式选择器。列表按创建时间倒序排列,每次导出时用户从此列表中选择应用哪个样式。
481
+
482
+**接口地址**: `GET /api/v1/styles`
483
+
484
+| 参数 | 类型 | 必填 | 说明 |
485
+|------|------|------|------|
486
+| page | integer | 否 | 页码,默认 1 |
487
+| pageSize | integer | 否 | 每页数量,默认 20,最大 100 |
488
+
489
+**响应示例**:
490
+```json
491
+{
492
+  "code": 0,
493
+  "data": {
494
+    "styles": [
495
+      {
496
+        "styleId": "default",
497
+        "name": "系统默认样式",
498
+        "sourceFile": null,
499
+        "summary": {
500
+          "defaultFont": "宋体",
501
+          "defaultSizePt": 12.0,
502
+          "headingFonts": ["黑体", "黑体", "宋体", "宋体", "宋体", "宋体"],
503
+          "totalStyles": 20,
504
+          "styleTypes": { "paragraph": 15, "character": 5, "table": 0, "numbering": 0 }
505
+        },
506
+        "isDefault": true,
507
+        "createdAt": 1680000000000
508
+      },
509
+      {
510
+        "styleId": "style-001",
511
+        "name": "季报模板_2026Q2",
512
+        "sourceFile": "季报模板_2026Q2.docx",
513
+        "summary": {
514
+          "defaultFont": "宋体",
515
+          "defaultSizePt": 12.0,
516
+          "headingFonts": ["黑体", "黑体", "宋体", "宋体", "宋体", "宋体"],
517
+          "totalStyles": 164,
518
+          "styleTypes": { "paragraph": 120, "character": 40, "table": 3, "numbering": 1 }
519
+        },
520
+        "isDefault": false,
521
+        "createdAt": 1680000000000
522
+      }
523
+    ],
524
+    "pagination": {
525
+      "page": 1,
526
+      "pageSize": 20,
527
+      "total": 2,
528
+      "totalPages": 1
529
+    }
530
+  }
531
+}
532
+```
533
+
534
+---
535
+
536
+### 4.3 获取样式详情(含完整 XML 定义)
537
+
538
+**目的**:返回指定样式文件的完整信息,包含所有样式的完整 XML 定义,供调试或样式预览使用。
539
+
540
+**接口地址**: `GET /api/v1/styles/{styleId}`
541
+
542
+**响应示例**(`styles` 数组内每条记录结构):
543
+```json
544
+{
545
+  "code": 0,
546
+  "data": {
547
+    "styleId": "style-001",
548
+    "name": "季报模板_2026Q2",
549
+    "sourceFile": "季报模板_2026Q2.docx",
550
+    "summary": {
551
+      "defaultFont": "宋体",
552
+      "defaultSizePt": 12.0,
553
+      "headingFonts": ["黑体", "黑体", "宋体", "宋体", "宋体", "宋体"],
554
+      "totalStyles": 164,
555
+      "styleTypes": { "paragraph": 120, "character": 40, "table": 3, "numbering": 1 }
556
+    },
557
+    "isDefault": false,
558
+    "createdAt": 1680000000000,
559
+    "styles": [
560
+      {
561
+        "name": "标题 1",
562
+        "styleId": "1",
563
+        "type": "paragraph",
564
+        "builtin": true,
565
+        "hidden": false,
566
+        "quickStyle": true,
567
+        "priority": 9,
568
+        "baseStyle": "正文",
569
+        "nextParagraphStyle": "正文",
570
+        "fontSummary": {
571
+          "name": "黑体",
572
+          "sizePt": 16.0,
573
+          "bold": true,
574
+          "italic": false,
575
+          "underline": false,
576
+          "colorRgb": "000000",
577
+          "strike": false,
578
+          "allCaps": false,
579
+          "smallCaps": false
580
+        },
581
+        "paragraphFormatSummary": {
582
+          "alignment": "LEFT",
583
+          "leftIndentPt": null,
584
+          "rightIndentPt": null,
585
+          "firstLineIndentPt": null,
586
+          "spaceBeforePt": 12.0,
587
+          "spaceAfterPt": 6.0,
588
+          "lineSpacing": 1.5,
589
+          "keepTogether": false,
590
+          "keepWithNext": true,
591
+          "pageBreakBefore": false
592
+        },
593
+        "fullXmlDefinition": {
594
+          "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}style",
595
+          "@attrib": {
596
+            "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}type": "paragraph",
597
+            "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}styleId": "1"
598
+          },
599
+          "@children": {
600
+            "{...}name": { "@tag": "...", "@attrib": { "{...}val": "heading 1" } },
601
+            "{...}rPr": { "...": "完整字符属性 XML" },
602
+            "{...}pPr": { "...": "完整段落属性 XML" }
603
+          }
604
+        }
605
+      }
606
+    ]
607
+  }
608
+}
609
+```
610
+
611
+> `fullXmlDefinition` 字段为嵌套字典格式,`@tag` 为完整 Clark 表示法标签名(含命名空间),`@attrib` 为属性字典,`@children` 为子元素字典(同名子元素自动合并为数组)。
612
+
613
+---
614
+
615
+### 4.4 删除样式
616
+
617
+**接口地址**: `DELETE /api/v1/styles/{styleId}`
618
+
619
+> 系统默认样式(`styleId: "default"`)不可删除,返回 403。  
620
+> 若该样式正在被某个文档引用,返回 409,需先解除引用再删除。
621
+
622
+**响应示例**:
623
+```json
624
+{
625
+  "code": 0,
626
+  "message": "Style deleted successfully"
627
+}
628
+```
629
+
630
+---
631
+
632
+## 5. 模板管理 API(阶段 1)
633
+
634
+用户上传带占位符的 Word 文档作为模板,占位符格式为 `{{名称}}`。导出时后端根据文档内容按标题名称匹配占位符,替换后生成 DOCX。
635
+
636
+### 占位符匹配规则
637
+
638
+- 占位符名称与 Markdown 文档中的**标题名称完全一致**时匹配,替换为该标题下的完整内容块(含子标题)
639
+- 匹配不上的占位符**清空**(替换为空字符串)
640
+- 示例:模板含 `{{一季度回顾}}`,文档中有 `## 一季度回顾`,则该标题及其下文内容替换占位符
641
+
642
+### 数据结构
643
+
644
+```python
645
+from pydantic import BaseModel
646
+from typing import Optional
647
+from datetime import datetime
372 648
 
373 649
 # 模板完整信息(响应)
374
-class DocumentTemplateResponse(BaseModel):
650
+class TemplateResponse(BaseModel):
375 651
     template_id: str
376 652
     name: str
377
-    styles: TemplateStyles
653
+    placeholders: list[str]     # 模板中所有占位符名称列表,如 ["一季度回顾", "二季度展望"]
654
+    has_header: bool            # 是否包含页眉
655
+    has_footer: bool            # 是否包含页脚
378 656
     created_at: datetime
379 657
 
380 658
 # 模板列表项
381 659
 class TemplateListItem(BaseModel):
382 660
     template_id: str
383 661
     name: str
662
+    placeholders: list[str]
384 663
     created_at: datetime
385 664
 ```
386 665
 
387
-### 4.1 上传 Word 模板
666
+### 5.1 上传模板
667
+
668
+**目的**:用户上传带占位符的 Word 文档,后端解析出所有 `{{名称}}` 占位符列表并返回,供用户确认模板结构。
388 669
 
389 670
 **接口地址**: `POST /api/v1/templates`
390 671
 
@@ -392,7 +673,7 @@ class TemplateListItem(BaseModel):
392 673
 
393 674
 | 参数 | 类型 | 必填 | 说明 |
394 675
 |------|------|------|------|
395
-| file | File | 是 | Word 模板文件(.docx),最大 20MB |
676
+| file | File | 是 | Word 文档(.docx),最大 20MB,需包含 `{{名称}}` 格式占位符 |
396 677
 | name | string | 是 | 模板名称 |
397 678
 
398 679
 **响应示例**:
@@ -401,17 +682,10 @@ class TemplateListItem(BaseModel):
401 682
   "code": 0,
402 683
   "data": {
403 684
     "templateId": "tpl-001",
404
-    "name": "标准报告模板",
405
-    "styles": {
406
-      "headings": [
407
-        { "level": 1, "font": "宋体", "size": 22, "bold": true },
408
-        { "level": 2, "font": "宋体", "size": 18, "bold": true }
409
-      ],
410
-      "defaultFont": "宋体",
411
-      "defaultSize": 12,
412
-      "hasHeader": true,
413
-      "hasFooter": true
414
-    },
685
+    "name": "季度报告模板",
686
+    "placeholders": ["报告摘要", "一季度回顾", "二季度展望", "风险提示"],
687
+    "hasHeader": true,
688
+    "hasFooter": true,
415 689
     "createdAt": 1680000000000
416 690
   }
417 691
 }
@@ -419,7 +693,7 @@ class TemplateListItem(BaseModel):
419 693
 
420 694
 ---
421 695
 
422
-### 4.2 获取模板信息
696
+### 5.2 获取模板信息
423 697
 
424 698
 **接口地址**: `GET /api/v1/templates/{templateId}`
425 699
 
@@ -429,8 +703,10 @@ class TemplateListItem(BaseModel):
429 703
   "code": 0,
430 704
   "data": {
431 705
     "templateId": "tpl-001",
432
-    "name": "标准报告模板",
433
-    "styles": { ... },
706
+    "name": "季度报告模板",
707
+    "placeholders": ["报告摘要", "一季度回顾", "二季度展望", "风险提示"],
708
+    "hasHeader": true,
709
+    "hasFooter": true,
434 710
     "createdAt": 1680000000000
435 711
   }
436 712
 }
@@ -438,7 +714,7 @@ class TemplateListItem(BaseModel):
438 714
 
439 715
 ---
440 716
 
441
-### 4.3 获取模板列表
717
+### 5.3 获取模板列表
442 718
 
443 719
 **接口地址**: `GET /api/v1/templates`
444 720
 
@@ -450,7 +726,8 @@ class TemplateListItem(BaseModel):
450 726
     "templates": [
451 727
       {
452 728
         "templateId": "tpl-001",
453
-        "name": "标准报告模板",
729
+        "name": "季度报告模板",
730
+        "placeholders": ["报告摘要", "一季度回顾", "二季度展望", "风险提示"],
454 731
         "createdAt": 1680000000000
455 732
       }
456 733
     ]
@@ -460,55 +737,81 @@ class TemplateListItem(BaseModel):
460 737
 
461 738
 ---
462 739
 
463
-## 5. 导出 API(阶段 1)
740
+### 5.4 删除模板
741
+
742
+**接口地址**: `DELETE /api/v1/templates/{templateId}`
743
+
744
+**响应示例**:
745
+```json
746
+{
747
+  "code": 0,
748
+  "message": "Template deleted successfully"
749
+}
750
+```
751
+
752
+---
753
+
754
+## 6. 导出 DOCX API(阶段 1)
464 755
 
465 756
 ### 数据结构
466 757
 
467 758
 ```python
468 759
 from pydantic import BaseModel
469 760
 
470
-# 导出 DOCX(保持模板格式)请求
761
+# 导出 DOCX(模板填充)请求
471 762
 class ExportDocxRequest(BaseModel):
472
-    document_id: str  # 文档 ID,需有关联 template_id
763
+    document_id: str    # 文档 ID,后端查文档内容并按标题匹配占位符
764
+    template_id: str    # 模板 ID,必须指定
473 765
 
474 766
 # 响应为文件流,HTTP Header 如下:
475 767
 # Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
476 768
 # Content-Disposition: attachment; filename="{title}.docx"
477 769
 ```
478 770
 
479
-### 5.1 下载 DOCX(保持模板格式)
771
+### 6.1 导出 DOCX(模板填充)
772
+
773
+**触发时机**:用户在编辑器中选择了模板并完成编辑后,点击"下载"按钮时触发。  
774
+**目的**:后端以指定模板为基础,解析文档 Markdown 内容按标题拆分为内容块,按标题名称匹配模板中的 `{{占位符}}`,替换后生成 DOCX。输出文件的字体、样式、页眉页脚完全继承模板。  
480 775
 
481
-**触发时机**:工作流文档编辑完成后,用户点击"下载"按钮时触发。  
482
-**目的**:将编辑器中的最新内容与原始 Word 模板合并,生成 DOCX 文件并直接返回文件流,前端触发下载。下载的文件字体、标题样式、页眉页脚与原模板完全一致。  
483
-**与阶段 0 导出的区别**:阶段 0 导出不依赖模板,直接将 Markdown 转为 .doc;本接口需要关联模板,目的是保证格式与模板一致。
776
+**后端处理流程**:
777
+1. 根据 `documentId` 查文档内容(Markdown)
778
+2. 根据 `templateId` 加载模板文件和占位符列表
779
+3. 将 Markdown 按标题拆分为内容块(标题名 → 内容)
780
+4. 遍历占位符,标题名称匹配则替换为对应内容,匹配不上则清空
781
+5. 生成 DOCX 文件流返回
484 782
 
485 783
 **接口地址**: `POST /api/v1/export/docx`
486 784
 
487 785
 **请求体**:
488 786
 ```json
489 787
 {
490
-  "documentId": "doc-abc123"
788
+  "documentId": "doc-abc123",
789
+  "templateId": "tpl-001"
491 790
 }
492 791
 ```
493 792
 
494 793
 | 参数 | 类型 | 必填 | 说明 |
495 794
 |------|------|------|------|
496
-| documentId | string | 是 | 文档 ID,需有关联 templateId |
497
-
498
-**响应**:
795
+| documentId | string | 是 | 文档 ID,后端据此获取内容并拆分内容块 |
796
+| templateId | string | 是 | 模板 ID,决定输出文件的结构和样式 |
499 797
 
500
-直接返回 DOCX 文件流,前端触发浏览器下载。
798
+**响应**:直接返回 DOCX 文件流,前端触发浏览器下载。
501 799
 
502 800
 ```
503 801
 Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
504
-Content-Disposition: attachment; filename="document.docx"
802
+Content-Disposition: attachment; filename="{title}.docx"
505 803
 ```
506 804
 
507
-> 若文档无关联模板,则以默认样式生成 DOCX。
805
+**错误情况**:
806
+
807
+| 错误码 | 说明 |
808
+|--------|------|
809
+| 404 | documentId 或 templateId 不存在 |
810
+| 422 | 文档内容为空,无法生成 |
508 811
 
509 812
 ---
510 813
 
511
-## 6. 编辑会话 API(阶段 2)
814
+## 7. 编辑会话 API(阶段 2)
512 815
 
513 816
 ### 数据结构
514 817
 
@@ -552,7 +855,7 @@ class SessionDocumentResponse(BaseModel):
552 855
     session_info: SessionInfo
553 856
 ```
554 857
 
555
-### 6.1 创建编辑会话
858
+### 7.1 创建编辑会话
556 859
 
557 860
 **触发时机**:oil-agent 需要让用户编辑某份文档时,由 oil-agent 后端调用此接口。  
558 861
 **目的**:生成一个与指定文档绑定的临时 Token 和编辑器 URL,供 oil-agent 将 URL 传给用户打开,实现免登录访问编辑器。Token 与文档 ID 强绑定,无法访问其他文档。
@@ -591,7 +894,7 @@ class SessionDocumentResponse(BaseModel):
591 894
 
592 895
 ---
593 896
 
594
-### 6.2 凭 Token 获取文档
897
+### 7.2 凭 Token 获取文档
595 898
 
596 899
 **触发时机**:编辑器页面加载时,用 URL 中的 Token 换取文档内容。  
597 900
 **目的**:校验 Token 有效性,返回对应文档内容和权限信息,完成免登录鉴权。
@@ -624,7 +927,7 @@ class SessionDocumentResponse(BaseModel):
624 927
 
625 928
 ---
626 929
 
627
-### 6.3 关闭编辑会话
930
+### 7.3 关闭编辑会话
628 931
 
629 932
 **触发时机**:用户关闭编辑器或 oil-agent 主动撤回访问权限时。  
630 933
 **目的**:立即撤销 Token,使该链接失效,防止 Token 被复用。
@@ -643,7 +946,7 @@ class SessionDocumentResponse(BaseModel):
643 946
 
644 947
 ---
645 948
 
646
-## 7. Webhook 通知(阶段 2)
949
+## 8. Webhook 通知(阶段 2)
647 950
 
648 951
 ### 数据结构
649 952
 
@@ -671,7 +974,7 @@ class SessionClosedData(BaseModel):
671 974
     duration: int   # 会话持续时长(秒)
672 975
 ```
673 976
 
674
-### 7.1 配置 Webhook
977
+### 8.1 配置 Webhook
675 978
 
676 979
 **接口地址**: `POST /api/v1/webhooks`
677 980
 
@@ -684,7 +987,7 @@ class SessionClosedData(BaseModel):
684 987
 }
685 988
 ```
686 989
 
687
-### 7.2 Webhook 事件格式
990
+### 8.2 Webhook 事件格式
688 991
 
689 992
 #### document.updated
690 993
 ```json
@@ -713,7 +1016,7 @@ class SessionClosedData(BaseModel):
713 1016
 }
714 1017
 ```
715 1018
 
716
-### 7.3 签名验证
1019
+### 8.3 签名验证
717 1020
 
718 1021
 请求头: `X-Axonix-Signature: sha256=<hmac_hex>`
719 1022
 
@@ -727,7 +1030,7 @@ def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
727 1030
 
728 1031
 ---
729 1032
 
730
-## 8. 导出扩展 API(阶段 3)
1033
+## 9. 导出扩展 API(阶段 3)
731 1034
 
732 1035
 ### 数据结构
733 1036
 
@@ -751,7 +1054,7 @@ class ExportPdfRequest(BaseModel):
751 1054
 # Content-Disposition: attachment; filename="{title}.pdf"
752 1055
 ```
753 1056
 
754
-### 8.1 导出为 PDF
1057
+### 9.1 导出为 PDF
755 1058
 
756 1059
 **接口地址**: `POST /api/v1/export/pdf`
757 1060
 
@@ -777,7 +1080,7 @@ Content-Disposition: attachment; filename="document.pdf"
777 1080
 
778 1081
 ---
779 1082
 
780
-## 9. 版本管理 API(阶段 3)
1083
+## 10. 版本管理 API(阶段 3)
781 1084
 
782 1085
 ### 数据结构
783 1086
 
@@ -809,7 +1112,7 @@ class RestoreVersionResponse(BaseModel):
809 1112
     updated_at: datetime
810 1113
 ```
811 1114
 
812
-### 9.1 获取版本历史
1115
+### 10.1 获取版本历史
813 1116
 
814 1117
 **接口地址**: `GET /api/v1/documents/{documentId}/versions`
815 1118
 
@@ -830,7 +1133,7 @@ class RestoreVersionResponse(BaseModel):
830 1133
 }
831 1134
 ```
832 1135
 
833
-### 9.2 回滚版本
1136
+### 10.2 回滚版本
834 1137
 
835 1138
 **接口地址**: `POST /api/v1/documents/{documentId}/versions/{versionId}/restore`
836 1139
 
@@ -846,7 +1149,7 @@ class RestoreVersionResponse(BaseModel):
846 1149
 }
847 1150
 ```
848 1151
 
849
-### 9.3 版本对比
1152
+### 10.3 版本对比
850 1153
 
851 1154
 **接口地址**: `GET /api/v1/documents/{documentId}/versions/diff?from={versionId}&to={versionId}`
852 1155
 
@@ -866,7 +1169,7 @@ class RestoreVersionResponse(BaseModel):
866 1169
 
867 1170
 ---
868 1171
 
869
-## 10. 限流与配额
1172
+## 11. 限流与配额
870 1173
 
871 1174
 | 接口类型 | 限制 | 窗口期 |
872 1175
 |---------|------|--------|
@@ -879,7 +1182,7 @@ class RestoreVersionResponse(BaseModel):
879 1182
 
880 1183
 ---
881 1184
 
882
-## 11. 测试环境
1185
+## 12. 测试环境
883 1186
 
884 1187
 - **Base URL**: `https://api-dev.axonix.com`
885 1188
 - **测试文档 ID**: `test-doc-001`

BIN
tmp/default.docx


Datei-Diff unterdrückt, da er zu groß ist
+ 4617 - 0
tmp/default.json


+ 195 - 0
tmp/styles.py

@@ -0,0 +1,195 @@
1
+"""
2
+提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
3
+目标文件: 2026年Q2季度报告_E-EdHk4r1Lg.doc
4
+"""
5
+
6
+import json
7
+from pathlib import Path
8
+from docx import Document
9
+from docx.oxml.ns import qn
10
+from lxml import etree
11
+
12
+DOC_PATH = Path(__file__).parent / "default.docx"
13
+OUTPUT_PATH = Path(__file__).parent / "default.json"
14
+
15
+
16
+def emu_to_pt(emu) -> float | None:
17
+    """EMU 转磅(1 pt = 12700 EMU)"""
18
+    if emu is None:
19
+        return None
20
+    return round(int(emu) / 12700, 2)
21
+
22
+
23
+def extract_font(style) -> dict | None:
24
+    """通过 API 提取字体信息(作为便捷摘要)"""
25
+    try:
26
+        f = style.font
27
+        color_rgb = None
28
+        try:
29
+            if f.color and f.color.type:
30
+                color_rgb = str(f.color.rgb)
31
+        except Exception:
32
+            pass
33
+        return {
34
+            "name": f.name,
35
+            "size_pt": emu_to_pt(f.size),
36
+            "bold": f.bold,
37
+            "italic": f.italic,
38
+            "underline": f.underline,
39
+            "color_rgb": color_rgb,
40
+            "strike": f.strike,
41
+            "all_caps": f.all_caps,
42
+            "small_caps": f.small_caps,
43
+        }
44
+    except Exception:
45
+        return None
46
+
47
+
48
+def extract_paragraph_format(style) -> dict | None:
49
+    """通过 API 提取段落格式(作为便捷摘要)"""
50
+    try:
51
+        pf = style.paragraph_format
52
+        return {
53
+            "alignment": str(pf.alignment) if pf.alignment else None,
54
+            "left_indent_pt": emu_to_pt(pf.left_indent),
55
+            "right_indent_pt": emu_to_pt(pf.right_indent),
56
+            "first_line_indent_pt": emu_to_pt(pf.first_line_indent),
57
+            "space_before_pt": emu_to_pt(pf.space_before),
58
+            "space_after_pt": emu_to_pt(pf.space_after),
59
+            "line_spacing": float(pf.line_spacing) if pf.line_spacing else None,
60
+            "keep_together": pf.keep_together,
61
+            "keep_with_next": pf.keep_with_next,
62
+            "page_break_before": pf.page_break_before,
63
+        }
64
+    except Exception:
65
+        return None
66
+
67
+
68
+def element_to_dict(elem: etree._Element) -> dict | list | str | None:
69
+    """
70
+    将 lxml Element 转换为字典,完整保留标签、属性、文本和子元素。
71
+    处理重复子元素(转为列表)。
72
+    使用 '{namespace}localname' 格式作为标签名。
73
+    """
74
+    # 标签名(完整 Clark 表示法)
75
+    tag = elem.tag
76
+
77
+    # 属性字典
78
+    attrib = dict(elem.attrib)
79
+
80
+    # 子元素处理
81
+    children = list(elem)
82
+    if children:
83
+        # 子元素可能重复,使用字典存储列表
84
+        child_dict = {}
85
+        for child in children:
86
+            child_tag = child.tag
87
+            child_val = element_to_dict(child)
88
+            if child_tag in child_dict:
89
+                # 相同标签名出现多次,转为列表
90
+                if not isinstance(child_dict[child_tag], list):
91
+                    child_dict[child_tag] = [child_dict[child_tag]]
92
+                child_dict[child_tag].append(child_val)
93
+            else:
94
+                child_dict[child_tag] = child_val
95
+        # 合并文本:如果存在文本(非空白),作为 '#text' 字段
96
+        text = elem.text.strip() if elem.text else None
97
+        tail = elem.tail.strip() if elem.tail else None
98
+        result = {"@tag": tag, "@attrib": attrib, "@children": child_dict}
99
+        if text:
100
+            result["#text"] = text
101
+        if tail:
102
+            result["#tail"] = tail
103
+        return result
104
+    else:
105
+        # 叶子节点:直接返回文本或属性+文本
106
+        text = elem.text.strip() if elem.text else None
107
+        tail = elem.tail.strip() if elem.tail else None
108
+        if attrib or text or tail:
109
+            result = {"@tag": tag, "@attrib": attrib}
110
+            if text:
111
+                result["#text"] = text
112
+            if tail:
113
+                result["#tail"] = tail
114
+            return result
115
+        else:
116
+            # 完全空的元素,可简化为 None 但保持结构
117
+            return {"@tag": tag, "@attrib": attrib}
118
+
119
+
120
+def extract_style_full_xml(style) -> dict:
121
+    """提取样式的完整 XML 定义(转换为字典)"""
122
+    elem = style.element
123
+    if elem is None:
124
+        return None
125
+    # 整个样式元素转换为字典
126
+    style_dict = element_to_dict(elem)
127
+    return style_dict
128
+
129
+
130
+def extract_styles(doc_path: Path) -> list[dict]:
131
+    doc = Document(str(doc_path))
132
+    styles_data = []
133
+
134
+    for style in doc.styles:
135
+        info = {
136
+            "name": style.name,
137
+            "style_id": style.style_id,
138
+            "type": str(style.type),
139
+            "builtin": style.builtin,
140
+            "hidden": style.hidden,
141
+            "quick_style": style.quick_style,
142
+            "priority": style.priority,
143
+            "base_style": (
144
+                style.base_style.name
145
+                if hasattr(style, "base_style") and style.base_style
146
+                else None
147
+            ),
148
+            "next_paragraph_style": (
149
+                style.next_paragraph_style.name
150
+                if hasattr(style, "next_paragraph_style") and style.next_paragraph_style
151
+                else None
152
+            ),
153
+            "font_summary": extract_font(style),      # 便捷摘要
154
+            "paragraph_format_summary": extract_paragraph_format(style),  # 便捷摘要
155
+            "full_xml_definition": extract_style_full_xml(style)   # 完整原始定义
156
+        }
157
+        styles_data.append(info)
158
+
159
+    return styles_data
160
+
161
+
162
+def main():
163
+    print(f"读取文件: {DOC_PATH}")
164
+    if not DOC_PATH.exists():
165
+        raise FileNotFoundError(f"文件不存在: {DOC_PATH}")
166
+
167
+    styles_data = extract_styles(DOC_PATH)
168
+
169
+    result = {
170
+        "source_file": DOC_PATH.name,
171
+        "total_styles": len(styles_data),
172
+        "styles": styles_data,
173
+    }
174
+
175
+    OUTPUT_PATH.write_text(
176
+        json.dumps(result, ensure_ascii=False, indent=2),
177
+        encoding="utf-8",
178
+    )
179
+
180
+    print(f"共提取 {len(styles_data)} 个样式")
181
+    print(f"完整 XML 定义已保存至: {OUTPUT_PATH}")
182
+
183
+    # 打印摘要
184
+    by_type: dict[str, list[str]] = {}
185
+    for s in styles_data:
186
+        t = s["type"]
187
+        by_type.setdefault(t, []).append(s["name"])
188
+
189
+    print("\n--- 样式类型分布 ---")
190
+    for t, names in by_type.items():
191
+        print(f"  {t}: {len(names)} 个")
192
+
193
+
194
+if __name__ == "__main__":
195
+    main()