Преглед изворни кода

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 пре 4 дана
родитељ
комит
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
     result = await export_doc(
41
     result = await export_doc(
42
         file_name=body.file_name,
42
         file_name=body.file_name,
43
         content=body.content,
43
         content=body.content,
44
+        style_id=body.style_id,
44
     )
45
     )
45
 
46
 
46
     resp = ExportDocResponse(
47
     resp = ExportDocResponse(
47
         download_url=result["download_url"],
48
         download_url=result["download_url"],
48
         file_name=result["file_name"],
49
         file_name=result["file_name"],
49
         expires_at=result["expires_at"],
50
         expires_at=result["expires_at"],
51
+        style_id=result["style_id"],
50
     )
52
     )
51
     return _ok(resp.model_dump(by_alias=True))
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
     base_url: str = "http://192.168.0.200:8000"
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
     class Config:
20
     class Config:
18
         env_file = ".env"
21
         env_file = ".env"
19
         env_file_encoding = "utf-8"
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
     format: Literal["doc"] = Field(..., description="阶段 0 固定为 doc")
8
     format: Literal["doc"] = Field(..., description="阶段 0 固定为 doc")
9
     content: str = Field(..., description="文档当前内容(Markdown)")
9
     content: str = Field(..., description="文档当前内容(Markdown)")
10
     document_id: Optional[str] = Field(None, alias="documentId", description="关联文档 ID,传入时同步更新草稿")
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
     model_config = {"populate_by_name": True}
14
     model_config = {"populate_by_name": True}
13
 
15
 
@@ -16,5 +18,6 @@ class ExportDocResponse(BaseModel):
16
     download_url: str = Field(..., serialization_alias="downloadUrl")
18
     download_url: str = Field(..., serialization_alias="downloadUrl")
17
     file_name: str = Field(..., serialization_alias="fileName")
19
     file_name: str = Field(..., serialization_alias="fileName")
18
     expires_at: int = Field(..., serialization_alias="expiresAt", description="Unix 毫秒时间戳")
20
     expires_at: int = Field(..., serialization_alias="expiresAt", description="Unix 毫秒时间戳")
21
+    style_id: str = Field(..., serialization_alias="styleId", description="实际使用的样式 ID")
19
 
22
 
20
     model_config = {"populate_by_name": True}
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
 import io
3
 import io
4
+import json
10
 import secrets
5
 import secrets
11
 import time
6
 import time
12
 import unicodedata
7
 import unicodedata
13
 import urllib.parse
8
 import urllib.parse
14
 from pathlib import Path
9
 from pathlib import Path
10
+from typing import Optional
15
 
11
 
16
 import mistune
12
 import mistune
17
 from docx import Document
13
 from docx import Document
18
 from docx.oxml import OxmlElement
14
 from docx.oxml import OxmlElement
19
 from docx.oxml.ns import qn
15
 from docx.oxml.ns import qn
20
 from docx.shared import Pt, RGBColor
16
 from docx.shared import Pt, RGBColor
17
+from lxml import etree
21
 
18
 
22
 from app.config import settings
19
 from app.config import settings
23
 from app.core.exceptions import ExportError
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
 # Markdown → python-docx 渲染器
131
 # Markdown → python-docx 渲染器
28
 # ------------------------------------------------------------------ #
132
 # ------------------------------------------------------------------ #
29
 
133
 
30
 class DocxRenderer(mistune.BaseRenderer):
134
 class DocxRenderer(mistune.BaseRenderer):
31
     """将 mistune AST token 流渲染到 python-docx Document 对象。"""
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
         super().__init__()
149
         super().__init__()
150
+        self.style_map = style_map
36
         self.doc = Document()
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
     def heading(self, token: dict, state: mistune.core.BlockState) -> str:
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
         level = token["attrs"]["level"]
165
         level = token["attrs"]["level"]
52
         children = token.get("children", [])
166
         children = token.get("children", [])
53
         text = self._extract_text(children)
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
         return ""
182
         return ""
58
 
183
 
59
     def paragraph(self, token: dict, state: mistune.core.BlockState) -> str:
184
     def paragraph(self, token: dict, state: mistune.core.BlockState) -> str:
60
-        # 普通段落,内部可能含粗体/斜体等内联样式,交给 _render_inline_children 处理
185
+        # 普通段落,内联样式由 _render_inline_children 处理
61
         children = token.get("children", [])
186
         children = token.get("children", [])
62
         p = self.doc.add_paragraph()
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
         self._render_inline_children(p, children)
193
         self._render_inline_children(p, children)
64
         return ""
194
         return ""
65
 
195
 
@@ -68,7 +198,7 @@ class DocxRenderer(mistune.BaseRenderer):
68
         return ""
198
         return ""
69
 
199
 
70
     def thematic_break(self, token: dict, state: mistune.core.BlockState) -> str:
200
     def thematic_break(self, token: dict, state: mistune.core.BlockState) -> str:
71
-        # --- 分隔线:python-docx 无直接 API,手动注入 XML 段落底部边框实现
201
+        # --- 分隔线:通过 XML 段落底部边框实现
72
         p = self.doc.add_paragraph()
202
         p = self.doc.add_paragraph()
73
         pPr = p._p.get_or_add_pPr()
203
         pPr = p._p.get_or_add_pPr()
74
         pBdr = OxmlElement("w:pBdr")
204
         pBdr = OxmlElement("w:pBdr")
@@ -82,16 +212,24 @@ class DocxRenderer(mistune.BaseRenderer):
82
         return ""
212
         return ""
83
 
213
 
84
     def block_quote(self, token: dict, state: mistune.core.BlockState) -> str:
214
     def block_quote(self, token: dict, state: mistune.core.BlockState) -> str:
85
-        # > 引用块 → Word Quote 样式(缩进+斜体)
215
+        # > 引用块 → Quote 样式(缩进+斜体)
86
         children = token.get("children", [])
216
         children = token.get("children", [])
87
         for child in children:
217
         for child in children:
88
             text = self._extract_text(child.get("children", []))
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
         return ""
229
         return ""
92
 
230
 
93
     def block_code(self, token: dict, state: mistune.core.BlockState) -> str:
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
         code = token.get("raw", "")
233
         code = token.get("raw", "")
96
         p = self.doc.add_paragraph(style="No Spacing")
234
         p = self.doc.add_paragraph(style="No Spacing")
97
         run = p.add_run(code)
235
         run = p.add_run(code)
@@ -101,16 +239,14 @@ class DocxRenderer(mistune.BaseRenderer):
101
         return ""
239
         return ""
102
 
240
 
103
     def list(self, token: dict, state: mistune.core.BlockState) -> str:
241
     def list(self, token: dict, state: mistune.core.BlockState) -> str:
104
-        # 列表入口,判断有序/无序和当前嵌套深度,具体渲染交给 _render_list_items
242
+        # 列表入口,判断有序/无序和嵌套深度,委托 _render_list_items 处理
105
         ordered = token["attrs"].get("ordered", False)
243
         ordered = token["attrs"].get("ordered", False)
106
         depth = token["attrs"].get("depth", 1)
244
         depth = token["attrs"].get("depth", 1)
107
         self._render_list_items(token.get("children", []), ordered, depth)
245
         self._render_list_items(token.get("children", []), ordered, depth)
108
         return ""
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
         for item in items:
250
         for item in items:
115
             children = item.get("children", [])
251
             children = item.get("children", [])
116
             for child in children:
252
             for child in children:
@@ -128,8 +264,7 @@ class DocxRenderer(mistune.BaseRenderer):
128
                     self.doc.add_paragraph(text, style=style)
264
                     self.doc.add_paragraph(text, style=style)
129
 
265
 
130
     def table(self, token: dict, state: mistune.core.BlockState) -> str:
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
         children = token.get("children", [])
268
         children = token.get("children", [])
134
         if not children:
269
         if not children:
135
             return ""
270
             return ""
@@ -173,7 +308,7 @@ class DocxRenderer(mistune.BaseRenderer):
173
     # ------ 内联样式 ------ #
308
     # ------ 内联样式 ------ #
174
 
309
 
175
     def _render_inline_children(self, paragraph, children: list) -> None:
310
     def _render_inline_children(self, paragraph, children: list) -> None:
176
-        # 遍历段落内子节点,按类型设置 run 样式:粗体/斜体/删除线/行内代码/换行
311
+        # 遍历子节点,按类型设置 run 样式:粗体/斜体/删除线/行内代码/换行
177
         for child in children:
312
         for child in children:
178
             ctype = child.get("type", "")
313
             ctype = child.get("type", "")
179
             raw = child.get("raw", "")
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
     @staticmethod
352
     @staticmethod
211
     def _extract_text(children: list) -> str:
353
     def _extract_text(children: list) -> str:
212
         # 递归提取纯文本,用于标题/列表/表格等不需要内联样式的场景
354
         # 递归提取纯文本,用于标题/列表/表格等不需要内联样式的场景
@@ -222,7 +364,7 @@ class DocxRenderer(mistune.BaseRenderer):
222
         return "".join(parts)
364
         return "".join(parts)
223
 
365
 
224
     def render_token(self, token: dict, state: mistune.core.BlockState) -> str:
366
     def render_token(self, token: dict, state: mistune.core.BlockState) -> str:
225
-        # mistune 分发入口:按 token type 找对应方法,找不到则递归渲染子节点兜底
367
+        # mistune 分发入口:按 token type 找对应方法,找不到则递归子节点兜底
226
         ttype = token["type"]
368
         ttype = token["type"]
227
         func = getattr(self, ttype, None)
369
         func = getattr(self, ttype, None)
228
         if func:
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
     # 必须显式启用插件:mistune 默认不解析表格和删除线
398
     # 必须显式启用插件:mistune 默认不解析表格和删除线
257
     md = mistune.create_markdown(
399
     md = mistune.create_markdown(
258
         renderer=renderer,
400
         renderer=renderer,
@@ -267,7 +409,7 @@ def markdown_to_docx_bytes(content: str) -> bytes:
267
 
409
 
268
 def _safe_filename(name: str) -> str:
410
 def _safe_filename(name: str) -> str:
269
     """过滤文件名非法字符,规范化全角字符,返回安全的文件名。"""
411
     """过滤文件名非法字符,规范化全角字符,返回安全的文件名。"""
270
-    name = unicodedata.normalize("NFKC", name)  # 全角转半角
412
+    name = unicodedata.normalize("NFKC", name)
271
     illegal = r'\/:*?"<>|'
413
     illegal = r'\/:*?"<>|'
272
     for ch in illegal:
414
     for ch in illegal:
273
         name = name.replace(ch, "_")
415
         name = name.replace(ch, "_")
@@ -277,20 +419,24 @@ def _safe_filename(name: str) -> str:
277
 async def export_doc(
419
 async def export_doc(
278
     file_name: str,
420
     file_name: str,
279
     content: str,
421
     content: str,
422
+    style_id: Optional[str] = None,
280
 ) -> dict:
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
     try:
431
     try:
287
-        doc_bytes = markdown_to_docx_bytes(content)
432
+        doc_bytes = markdown_to_docx_bytes(content, style_map, style_data)
288
     except Exception as exc:
433
     except Exception as exc:
289
         raise ExportError(f"Markdown 转换失败: {exc}") from exc
434
         raise ExportError(f"Markdown 转换失败: {exc}") from exc
290
 
435
 
436
+    # 3. 写入临时目录
291
     safe_name = _safe_filename(file_name)
437
     safe_name = _safe_filename(file_name)
292
     token = secrets.token_urlsafe(8)
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
     tmp_dir = Path(settings.temp_dir)
441
     tmp_dir = Path(settings.temp_dir)
296
     tmp_dir.mkdir(parents=True, exist_ok=True)
442
     tmp_dir.mkdir(parents=True, exist_ok=True)
@@ -301,8 +447,8 @@ async def export_doc(
301
     except OSError as exc:
447
     except OSError as exc:
302
         raise ExportError(f"文件写入失败: {exc}") from exc
448
         raise ExportError(f"文件写入失败: {exc}") from exc
303
 
449
 
450
+    # 4. 生成下载链接
304
     expires_at_ms = int((time.time() + settings.export_link_expires) * 1000)
451
     expires_at_ms = int((time.time() + settings.export_link_expires) * 1000)
305
-    # 文件名中文部分编码进 URL,下载路由收到后 FastAPI 自动解码还原
306
     encoded_name = urllib.parse.quote(final_name, safe="-._~")
452
     encoded_name = urllib.parse.quote(final_name, safe="-._~")
307
     download_url = f"{settings.base_url.rstrip('/')}/api/v1/files/{encoded_name}"
453
     download_url = f"{settings.base_url.rstrip('/')}/api/v1/files/{encoded_name}"
308
 
454
 
@@ -310,4 +456,5 @@ async def export_doc(
310
         "download_url": download_url,
456
         "download_url": download_url,
311
         "file_name": final_name,
457
         "file_name": final_name,
312
         "expires_at": expires_at_ms,
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
 | 核心依赖 | `python-docx 1.1.2` · `mistune 3.0.2` |
16
 | 核心依赖 | `python-docx 1.1.2` · `mistune 3.0.2` |
17
 | 文件存储 | 服务器本地 `./tmp/` 目录(临时缓存,当前不自动清理) |
17
 | 文件存储 | 服务器本地 `./tmp/` 目录(临时缓存,当前不自动清理) |
18
 | 下载链接有效期 | 默认 3600 秒(1 小时),由 `EXPORT_LINK_EXPIRES` 配置 |
18
 | 下载链接有效期 | 默认 3600 秒(1 小时),由 `EXPORT_LINK_EXPIRES` 配置 |
19
+| 默认样式文件 | `./tmp/default.json`(阶段 0 固定使用;阶段 1 起支持用户选择) |
19
 
20
 
20
 ---
21
 ---
21
 
22
 
@@ -43,7 +44,8 @@
43
   "fileName": "2026年Q2季度报告",
44
   "fileName": "2026年Q2季度报告",
44
   "format": "doc",
45
   "format": "doc",
45
   "content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n...",
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
 | `format` | string | 是 | 阶段 0 固定为 `"doc"` |
55
 | `format` | string | 是 | 阶段 0 固定为 `"doc"` |
54
 | `content` | string | 是 | 文档当前 Markdown 内容,最大 200KB |
56
 | `content` | string | 是 | 文档当前 Markdown 内容,最大 200KB |
55
 | `documentId` | string | 否 | 关联文档 ID,传入时后端同步更新草稿记录 |
57
 | `documentId` | string | 否 | 关联文档 ID,传入时后端同步更新草稿记录 |
58
+| `styleId` | string | 否 | 样式 ID;**阶段 0 暂不生效**,后端固定使用 `default.json`;阶段 1 起传入有效 ID 则使用用户上传的样式 |
56
 
59
 
57
 #### 响应
60
 #### 响应
58
 
61
 
@@ -65,7 +68,8 @@
65
   "data": {
68
   "data": {
66
     "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc",
69
     "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc",
67
     "fileName": "2026年Q2季度报告.doc",
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
 | `downloadUrl` | string | 临时下载链接,有效期由 `expiresAt` 标注 |
79
 | `downloadUrl` | string | 临时下载链接,有效期由 `expiresAt` 标注 |
76
 | `fileName` | string | 含扩展名的完整文件名 |
80
 | `fileName` | string | 含扩展名的完整文件名 |
77
 | `expiresAt` | number | 链接过期时间,Unix 毫秒时间戳 |
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
           v
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
 export_service.export_doc()
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
      - mistune 解析 Markdown -> AST
183
      - mistune 解析 Markdown -> AST
159
        plugins: table, strikethrough, url
184
        plugins: table, strikethrough, url
160
-     - 遍历 token,映射到 python-docx
185
+     - 遍历 token,按映射表应用对应样式
161
 
186
 
162
-  2. _safe_filename(fileName)
187
+  3. _safe_filename(fileName)
163
      └── 过滤非法字符,规范化 Unicode
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
           v
194
           v
170
 返回 ExportDocResponse
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
 | Markdown | Word 样式 | python-docx 调用 |
320
 | Markdown | Word 样式 | python-docx 调用 |
181
 |----------|-----------|-----------------|
321
 |----------|-----------|-----------------|
@@ -187,7 +327,7 @@ export_service.export_doc()
187
 
327
 
188
 > 所有标题颜色强制设为黑色,覆盖 Word 内置 Heading 样式的蓝色主题色。
328
 > 所有标题颜色强制设为黑色,覆盖 Word 内置 Heading 样式的蓝色主题色。
189
 
329
 
190
-### 4.2 内联样式
330
+### 5.2 内联样式
191
 
331
 
192
 | Markdown | 语义 | python-docx 处理 |
332
 | Markdown | 语义 | python-docx 处理 |
193
 |----------|------|-----------------|
333
 |----------|------|-----------------|
@@ -196,7 +336,7 @@ export_service.export_doc()
196
 | `~~文字~~` | 删除线 | `run.font.strike = True` |
336
 | `~~文字~~` | 删除线 | `run.font.strike = True` |
197
 | `` `代码` `` | 行内代码 | `run.font.name = "Courier New"` |
337
 | `` `代码` `` | 行内代码 | `run.font.name = "Courier New"` |
198
 
338
 
199
-### 4.3 列表
339
+### 5.3 列表
200
 
340
 
201
 | Markdown | Word 样式 | python-docx 调用 |
341
 | Markdown | Word 样式 | python-docx 调用 |
202
 |----------|-----------|-----------------|
342
 |----------|-----------|-----------------|
@@ -205,7 +345,7 @@ export_service.export_doc()
205
 | 二级无序缩进 | List Bullet 2 | `doc.add_paragraph(style="List Bullet 2")` |
345
 | 二级无序缩进 | List Bullet 2 | `doc.add_paragraph(style="List Bullet 2")` |
206
 | 二级有序缩进 | List Number 2 | `doc.add_paragraph(style="List Number 2")` |
346
 | 二级有序缩进 | List Number 2 | `doc.add_paragraph(style="List Number 2")` |
207
 
347
 
208
-### 4.4 块级元素
348
+### 5.4 块级元素
209
 
349
 
210
 | Markdown | Word 样式 | python-docx 处理 |
350
 | Markdown | Word 样式 | python-docx 处理 |
211
 |----------|-----------|-----------------|
351
 |----------|-----------|-----------------|
@@ -214,7 +354,7 @@ export_service.export_doc()
214
 | `---` / `***` | 段落底部边框 | OxmlElement 注入 `w:pBdr` |
354
 | `---` / `***` | 段落底部边框 | OxmlElement 注入 `w:pBdr` |
215
 | `\| 列 \| 列 \|` | Table Grid | `doc.add_table()`,表头行自动加粗 |
355
 | `\| 列 \| 列 \|` | Table Grid | `doc.add_table()`,表头行自动加粗 |
216
 
356
 
217
-### 4.5 表格 AST 结构说明
357
+### 5.5 表格 AST 结构说明
218
 
358
 
219
 mistune 3.x 解析表格后的 token 层级(需启用 `table` 插件):
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
 | `TEMP_DIR` | `./tmp` | 临时文件存储目录 |
396
 | `TEMP_DIR` | `./tmp` | 临时文件存储目录 |
257
 | `EXPORT_LINK_EXPIRES` | `3600` | 下载链接有效期(秒) |
397
 | `EXPORT_LINK_EXPIRES` | `3600` | 下载链接有效期(秒) |
258
 | `BASE_URL` | `http://192.168.0.200:8000` | 服务对外地址,用于拼接 `downloadUrl` |
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
 | `app/api/v1/export.py` | 路由层:参数接收、可选草稿同步、响应封装 |
407
 | `app/api/v1/export.py` | 路由层:参数接收、可选草稿同步、响应封装 |
267
 | `app/schemas/export.py` | 请求/响应 Pydantic 模型,字段别名(camelCase) |
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
 | `app/services/document_service.py` | 可选路径:`documentId` 存在时调用 `update_document()` |
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
 | `app/core/exceptions.py` | `ExportError` 定义及全局 handler(返回 HTTP 500) |
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
 ### curl
419
 ### curl
278
 
420
 
@@ -282,10 +424,13 @@ curl -X POST http://192.168.0.200:8000/api/v1/export/doc \
282
   -d '{
424
   -d '{
283
     "fileName": "2026年Q2季度报告",
425
     "fileName": "2026年Q2季度报告",
284
     "format": "doc",
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
 ```json
436
 ```json
@@ -295,7 +440,8 @@ curl -X POST http://192.168.0.200:8000/api/v1/export/doc \
295
   "data": {
440
   "data": {
296
     "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc",
441
     "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc",
297
     "fileName": "2026年Q2季度报告.doc",
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
 **关联文档**:[text-editor-backend-api.md](./text-editor-backend-api.md) · [text-editor-architecture.md](./text-editor-architecture.md) · [environment-setup.md](./environment-setup.md)
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
 | 阶段 | 新增 API |
15
 | 阶段 | 新增 API |
16
 |------|---------|
16
 |------|---------|
17
-| 阶段 0 | 文档 CRUD |
18
-| 阶段 1 | 模板管理、DOCX 下载导出 |
17
+| 阶段 0 | 文档 CRUD、导出 .doc(默认样式) |
18
+| 阶段 1 | 样式管理、模板管理、导出 DOCX |
19
 | 阶段 2 | 编辑会话(Token)、Webhook |
19
 | 阶段 2 | 编辑会话(Token)、Webhook |
20
 | 阶段 3 | PDF 导出、版本管理 |
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
 ```json
35
 ```json
25
 {
36
 {
@@ -53,8 +64,7 @@ class CreateDocumentRequest(BaseModel):
53
     content: str                                    # 文档内容(Markdown),最大 200KB
64
     content: str                                    # 文档内容(Markdown),最大 200KB
54
     format: Literal["markdown"] = "markdown"        # 内容格式,默认 markdown
65
     format: Literal["markdown"] = "markdown"        # 内容格式,默认 markdown
55
     session_id: Optional[str] = None                # 关联的聊天会话 ID
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
 class UpdateDocumentRequest(BaseModel):
70
 class UpdateDocumentRequest(BaseModel):
@@ -70,7 +80,6 @@ class DocumentResponse(BaseModel):
70
     format: str
80
     format: str
71
     session_id: Optional[str] = None
81
     session_id: Optional[str] = None
72
     template_id: Optional[str] = None
82
     template_id: Optional[str] = None
73
-    source: Literal["chat", "workflow"]
74
     created_by: str
83
     created_by: str
75
     created_at: datetime
84
     created_at: datetime
76
     updated_at: datetime
85
     updated_at: datetime
@@ -79,7 +88,6 @@ class DocumentResponse(BaseModel):
79
 class DocumentListItem(BaseModel):
88
 class DocumentListItem(BaseModel):
80
     id: str
89
     id: str
81
     title: str
90
     title: str
82
-    source: Literal["chat", "workflow"]
83
     template_id: Optional[str] = None
91
     template_id: Optional[str] = None
84
     created_at: datetime
92
     created_at: datetime
85
     updated_at: datetime
93
     updated_at: datetime
@@ -107,8 +115,7 @@ class Pagination(BaseModel):
107
   "content": "# 标题\n\n这是文档内容...",
115
   "content": "# 标题\n\n这是文档内容...",
108
   "format": "markdown",
116
   "format": "markdown",
109
   "sessionId": "chat-session-123",
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
 | content | string | 是 | 文档内容,最大 200KB |
125
 | content | string | 是 | 文档内容,最大 200KB |
119
 | format | string | 否 | 默认 `markdown` |
126
 | format | string | 否 | 默认 `markdown` |
120
 | sessionId | string | 否 | 关联聊天会话 ID |
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
 ```json
131
 ```json
@@ -154,7 +160,6 @@ class Pagination(BaseModel):
154
     "format": "markdown",
160
     "format": "markdown",
155
     "sessionId": "chat-session-123",
161
     "sessionId": "chat-session-123",
156
     "templateId": "tpl-001",
162
     "templateId": "tpl-001",
157
-    "source": "workflow",
158
     "createdBy": "user-xyz",
163
     "createdBy": "user-xyz",
159
     "createdAt": 1680000000000,
164
     "createdAt": 1680000000000,
160
     "updatedAt": 1680000100000
165
     "updatedAt": 1680000100000
@@ -243,7 +248,6 @@ class Pagination(BaseModel):
243
 | page | integer | 否 | 页码,默认 1 |
248
 | page | integer | 否 | 页码,默认 1 |
244
 | pageSize | integer | 否 | 每页数量,默认 20,最大 100 |
249
 | pageSize | integer | 否 | 每页数量,默认 20,最大 100 |
245
 | sessionId | string | 否 | 按聊天会话过滤 |
250
 | sessionId | string | 否 | 按聊天会话过滤 |
246
-| source | string | 否 | `chat` / `workflow` |
247
 | sortBy | string | 否 | `createdAt` / `updatedAt`,默认 `updatedAt` |
251
 | sortBy | string | 否 | `createdAt` / `updatedAt`,默认 `updatedAt` |
248
 | sortOrder | string | 否 | `asc` / `desc`,默认 `desc` |
252
 | sortOrder | string | 否 | `asc` / `desc`,默认 `desc` |
249
 
253
 
@@ -256,7 +260,6 @@ class Pagination(BaseModel):
256
       {
260
       {
257
         "id": "doc-abc123",
261
         "id": "doc-abc123",
258
         "title": "我的文档",
262
         "title": "我的文档",
259
-        "source": "workflow",
260
         "templateId": "tpl-001",
263
         "templateId": "tpl-001",
261
         "createdAt": 1680000000000,
264
         "createdAt": 1680000000000,
262
         "updatedAt": 1680000100000
265
         "updatedAt": 1680000100000
@@ -276,6 +279,8 @@ class Pagination(BaseModel):
276
 
279
 
277
 ## 3. 导出 API(阶段 0)
280
 ## 3. 导出 API(阶段 0)
278
 
281
 
282
+**本节说明**:阶段 0 导出不依赖样式库和模板,使用系统内置默认样式将 Markdown 内容转换为 .doc 文件。阶段 1 引入样式管理后,本接口扩展支持用户选择自定义样式(通过 `styleId` 参数),接口结构不变。
283
+
279
 ### 数据结构
284
 ### 数据结构
280
 
285
 
281
 ```python
286
 ```python
@@ -284,22 +289,24 @@ from typing import Literal, Optional
284
 
289
 
285
 # 导出 .doc 请求
290
 # 导出 .doc 请求
286
 class ExportDocRequest(BaseModel):
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
     document_id: Optional[str] = None  # 关联文档 ID,传入时后端同步更新草稿记录
295
     document_id: Optional[str] = None  # 关联文档 ID,传入时后端同步更新草稿记录
296
+    style_id: Optional[str] = None     # 样式 ID;不传时使用系统默认样式;阶段 1 起支持用户自定义样式
291
 
297
 
292
 # 导出响应
298
 # 导出响应
293
 class ExportDocResponse(BaseModel):
299
 class ExportDocResponse(BaseModel):
294
     download_url: str   # 临时下载链接,有效期 1 小时
300
     download_url: str   # 临时下载链接,有效期 1 小时
295
     file_name: str      # 含扩展名的完整文件名,如 季度报告_2026Q2.doc
301
     file_name: str      # 含扩展名的完整文件名,如 季度报告_2026Q2.doc
296
     expires_at: int     # 链接过期时间,Unix 毫秒时间戳
302
     expires_at: int     # 链接过期时间,Unix 毫秒时间戳
303
+    style_id: str       # 实际使用的样式 ID
297
 ```
304
 ```
298
 
305
 
299
 ### 3.1 导出 .doc 并下载
306
 ### 3.1 导出 .doc 并下载
300
 
307
 
301
 **触发时机**:用户点击编辑器"保存"按钮,在弹出对话框中填写文件名、选择格式并确认后触发。  
308
 **触发时机**:用户点击编辑器"保存"按钮,在弹出对话框中填写文件名、选择格式并确认后触发。  
302
-**目的**:根据用户填写的文件名、格式和当前文档内容,生成对应格式的文件,返回临时下载链接,前端自动触发浏览器下载
309
+**目的**:将 Markdown 内容按指定样式渲染生成 .doc 文件,返回临时下载链接。输出字体、标题层级、段落间距等均由样式文件控制
303
 **注意**:此接口不操作数据库文档记录,仅负责文件生成与下载链接的返回。如需同时更新草稿,可传入 `documentId`,后端会先更新一次记录再生成文件。
310
 **注意**:此接口不操作数据库文档记录,仅负责文件生成与下载链接的返回。如需同时更新草稿,可传入 `documentId`,后端会先更新一次记录再生成文件。
304
 
311
 
305
 **接口地址**: `POST /api/v1/export/doc`
312
 **接口地址**: `POST /api/v1/export/doc`
@@ -310,7 +317,8 @@ class ExportDocResponse(BaseModel):
310
   "fileName": "季度报告_2026Q2",
317
   "fileName": "季度报告_2026Q2",
311
   "format": "doc",
318
   "format": "doc",
312
   "content": "# 标题\n\n这是文档内容...",
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
 | fileName | string | 是 | 用户填写的文件名(不含扩展名),最大 255 字符 |
327
 | fileName | string | 是 | 用户填写的文件名(不含扩展名),最大 255 字符 |
320
 | format | string | 是 | 文件格式,阶段 0 固定为 `doc` |
328
 | format | string | 是 | 文件格式,阶段 0 固定为 `doc` |
321
 | content | string | 是 | 文档当前内容(Markdown) |
329
 | content | string | 是 | 文档当前内容(Markdown) |
322
-| documentId | string | 否 | 关联的文档 ID,用于更新自动保存记录 |
330
+| documentId | string | 否 | 关联的文档 ID,用于同步更新自动保存记录 |
331
+| styleId | string | 否 | 样式 ID;不传时使用系统默认样式;阶段 1 起可传用户上传的自定义样式 ID |
323
 
332
 
324
 **响应示例**:
333
 **响应示例**:
325
 ```json
334
 ```json
@@ -328,7 +337,8 @@ class ExportDocResponse(BaseModel):
328
   "data": {
337
   "data": {
329
     "downloadUrl": "https://api.axonix.com/files/download/季度报告_2026Q2.doc?token=xxx",
338
     "downloadUrl": "https://api.axonix.com/files/download/季度报告_2026Q2.doc?token=xxx",
330
     "fileName": "季度报告_2026Q2.doc",
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
 from typing import Optional
364
 from typing import Optional
347
 from datetime import datetime
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
     template_id: str
651
     template_id: str
376
     name: str
652
     name: str
377
-    styles: TemplateStyles
653
+    placeholders: list[str]     # 模板中所有占位符名称列表,如 ["一季度回顾", "二季度展望"]
654
+    has_header: bool            # 是否包含页眉
655
+    has_footer: bool            # 是否包含页脚
378
     created_at: datetime
656
     created_at: datetime
379
 
657
 
380
 # 模板列表项
658
 # 模板列表项
381
 class TemplateListItem(BaseModel):
659
 class TemplateListItem(BaseModel):
382
     template_id: str
660
     template_id: str
383
     name: str
661
     name: str
662
+    placeholders: list[str]
384
     created_at: datetime
663
     created_at: datetime
385
 ```
664
 ```
386
 
665
 
387
-### 4.1 上传 Word 模板
666
+### 5.1 上传模板
667
+
668
+**目的**:用户上传带占位符的 Word 文档,后端解析出所有 `{{名称}}` 占位符列表并返回,供用户确认模板结构。
388
 
669
 
389
 **接口地址**: `POST /api/v1/templates`
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
 | name | string | 是 | 模板名称 |
677
 | name | string | 是 | 模板名称 |
397
 
678
 
398
 **响应示例**:
679
 **响应示例**:
@@ -401,17 +682,10 @@ class TemplateListItem(BaseModel):
401
   "code": 0,
682
   "code": 0,
402
   "data": {
683
   "data": {
403
     "templateId": "tpl-001",
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
     "createdAt": 1680000000000
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
 **接口地址**: `GET /api/v1/templates/{templateId}`
698
 **接口地址**: `GET /api/v1/templates/{templateId}`
425
 
699
 
@@ -429,8 +703,10 @@ class TemplateListItem(BaseModel):
429
   "code": 0,
703
   "code": 0,
430
   "data": {
704
   "data": {
431
     "templateId": "tpl-001",
705
     "templateId": "tpl-001",
432
-    "name": "标准报告模板",
433
-    "styles": { ... },
706
+    "name": "季度报告模板",
707
+    "placeholders": ["报告摘要", "一季度回顾", "二季度展望", "风险提示"],
708
+    "hasHeader": true,
709
+    "hasFooter": true,
434
     "createdAt": 1680000000000
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
 **接口地址**: `GET /api/v1/templates`
719
 **接口地址**: `GET /api/v1/templates`
444
 
720
 
@@ -450,7 +726,8 @@ class TemplateListItem(BaseModel):
450
     "templates": [
726
     "templates": [
451
       {
727
       {
452
         "templateId": "tpl-001",
728
         "templateId": "tpl-001",
453
-        "name": "标准报告模板",
729
+        "name": "季度报告模板",
730
+        "placeholders": ["报告摘要", "一季度回顾", "二季度展望", "风险提示"],
454
         "createdAt": 1680000000000
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
 ```python
758
 ```python
468
 from pydantic import BaseModel
759
 from pydantic import BaseModel
469
 
760
 
470
-# 导出 DOCX(保持模板格式)请求
761
+# 导出 DOCX(模板填充)请求
471
 class ExportDocxRequest(BaseModel):
762
 class ExportDocxRequest(BaseModel):
472
-    document_id: str  # 文档 ID,需有关联 template_id
763
+    document_id: str    # 文档 ID,后端查文档内容并按标题匹配占位符
764
+    template_id: str    # 模板 ID,必须指定
473
 
765
 
474
 # 响应为文件流,HTTP Header 如下:
766
 # 响应为文件流,HTTP Header 如下:
475
 # Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
767
 # Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
476
 # Content-Disposition: attachment; filename="{title}.docx"
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
 **接口地址**: `POST /api/v1/export/docx`
783
 **接口地址**: `POST /api/v1/export/docx`
486
 
784
 
487
 **请求体**:
785
 **请求体**:
488
 ```json
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
 Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
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
     session_info: SessionInfo
855
     session_info: SessionInfo
553
 ```
856
 ```
554
 
857
 
555
-### 6.1 创建编辑会话
858
+### 7.1 创建编辑会话
556
 
859
 
557
 **触发时机**:oil-agent 需要让用户编辑某份文档时,由 oil-agent 后端调用此接口。  
860
 **触发时机**:oil-agent 需要让用户编辑某份文档时,由 oil-agent 后端调用此接口。  
558
 **目的**:生成一个与指定文档绑定的临时 Token 和编辑器 URL,供 oil-agent 将 URL 传给用户打开,实现免登录访问编辑器。Token 与文档 ID 强绑定,无法访问其他文档。
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
 **触发时机**:编辑器页面加载时,用 URL 中的 Token 换取文档内容。  
899
 **触发时机**:编辑器页面加载时,用 URL 中的 Token 换取文档内容。  
597
 **目的**:校验 Token 有效性,返回对应文档内容和权限信息,完成免登录鉴权。
900
 **目的**:校验 Token 有效性,返回对应文档内容和权限信息,完成免登录鉴权。
@@ -624,7 +927,7 @@ class SessionDocumentResponse(BaseModel):
624
 
927
 
625
 ---
928
 ---
626
 
929
 
627
-### 6.3 关闭编辑会话
930
+### 7.3 关闭编辑会话
628
 
931
 
629
 **触发时机**:用户关闭编辑器或 oil-agent 主动撤回访问权限时。  
932
 **触发时机**:用户关闭编辑器或 oil-agent 主动撤回访问权限时。  
630
 **目的**:立即撤销 Token,使该链接失效,防止 Token 被复用。
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
     duration: int   # 会话持续时长(秒)
974
     duration: int   # 会话持续时长(秒)
672
 ```
975
 ```
673
 
976
 
674
-### 7.1 配置 Webhook
977
+### 8.1 配置 Webhook
675
 
978
 
676
 **接口地址**: `POST /api/v1/webhooks`
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
 #### document.updated
992
 #### document.updated
690
 ```json
993
 ```json
@@ -713,7 +1016,7 @@ class SessionClosedData(BaseModel):
713
 }
1016
 }
714
 ```
1017
 ```
715
 
1018
 
716
-### 7.3 签名验证
1019
+### 8.3 签名验证
717
 
1020
 
718
 请求头: `X-Axonix-Signature: sha256=<hmac_hex>`
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
 # Content-Disposition: attachment; filename="{title}.pdf"
1054
 # Content-Disposition: attachment; filename="{title}.pdf"
752
 ```
1055
 ```
753
 
1056
 
754
-### 8.1 导出为 PDF
1057
+### 9.1 导出为 PDF
755
 
1058
 
756
 **接口地址**: `POST /api/v1/export/pdf`
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
     updated_at: datetime
1112
     updated_at: datetime
810
 ```
1113
 ```
811
 
1114
 
812
-### 9.1 获取版本历史
1115
+### 10.1 获取版本历史
813
 
1116
 
814
 **接口地址**: `GET /api/v1/documents/{documentId}/versions`
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
 **接口地址**: `POST /api/v1/documents/{documentId}/versions/{versionId}/restore`
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
 **接口地址**: `GET /api/v1/documents/{documentId}/versions/diff?from={versionId}&to={versionId}`
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
 - **Base URL**: `https://api-dev.axonix.com`
1187
 - **Base URL**: `https://api-dev.axonix.com`
885
 - **测试文档 ID**: `test-doc-001`
1188
 - **测试文档 ID**: `test-doc-001`


Разлика између датотеке није приказан због своје велике величине
+ 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()