ソースを参照

feat(export): 优化文档导出服务代码质量和样式处理逻辑

- 添加日志级别配置,降低 uvicorn.error 日志噪声
- 优化 _resolve_style_id 函数,优先返回样式名称而非 style_id,增强兼容性
- 简化多个函数的文档字符串格式,改进代码可读性
- 重构段落和表格单元格样式应用逻辑,统一使用样式名称而非 ID
- 修正标题块、段落块、表格块和图像块的样式解析流程
- 移除多余空行,优化代码格式一致性
- 增强样式解析错误处理,完善异常捕获机制
chensiyu 1 ヶ月 前
コミット
d90bf1b96a
共有3 個のファイルを変更した334 個の追加425 個の削除を含む
  1. 2 0
      app/main.py
  2. 39 104
      app/services/export_service.py
  3. 293 321
      app/services/word_parser.py

+ 2 - 0
app/main.py

@@ -1,3 +1,4 @@
1
+import logging
1 2
 from contextlib import asynccontextmanager
2 3
 
3 4
 from fastapi import FastAPI
@@ -7,6 +8,7 @@ from app.api.v1 import documents, export, export_records, blocks
7 8
 from app.core.exceptions import register_exception_handlers
8 9
 from app.services.storage_monitor import start_background_monitor
9 10
 
11
+logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
10 12
 
11 13
 @asynccontextmanager
12 14
 async def lifespan(app: FastAPI):

+ 39 - 104
app/services/export_service.py

@@ -21,7 +21,6 @@ from lxml import etree
21 21
 from app.config import settings
22 22
 from app.core.exceptions import ExportError
23 23
 
24
-
25 24
 # ------------------------------------------------------------------ #
26 25
 # TOC 更新服务(使用 WPS/Word COM API)
27 26
 # ------------------------------------------------------------------ #
@@ -192,11 +191,16 @@ def inject_numbering_from_json(doc: Document, style_data: dict) -> None:
192 191
     pass
193 192
 
194 193
 def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
195
-    """解析样式 ID"""
194
+    """解析样式名称(优先返回 name,兼容旧的 style_id)"""
196 195
     for key in keys:
197 196
         entry = style_map.get(key)
198
-        if entry and entry.get("style_id"):
199
-            return entry["style_id"]
197
+        if entry:
198
+            # 优先返回样式名称(推荐方式)
199
+            if entry.get("name"):
200
+                return entry["name"]
201
+            # 兼容:如果没有 name,返回 style_id
202
+            if entry.get("style_id"):
203
+                return entry["style_id"]
200 204
     return None
201 205
 
202 206
 def _apply_paragraph_style(para, style: dict):
@@ -352,7 +356,6 @@ def twips_to_emu(twips: int) -> int:
352 356
         return None
353 357
     return int(twips * 635)
354 358
 
355
-
356 359
 def apply_page_setup(doc: Document, style_data: dict) -> None:
357 360
     """应用页面设置到文档第一个 section (doc: Document, style_data: dict)"""
358 361
     page_setup = style_data.get("page_setup")
@@ -480,16 +483,7 @@ def _apply_document_grid(section, grid_type: str = None, chars_per_line: int = N
480 483
 # ------------------------------------------------------------------ #
481 484
 
482 485
 def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict) -> bytes:
483
-    """将 Blocks 列表转换为 Word 文档字节流
484
-    
485
-    Args:
486
-        blocks: Block 列表
487
-        style_map: 样式映射
488
-        style_data: 样式数据
489
-        
490
-    Returns:
491
-        Word 文档字节流
492
-    """
486
+    """将 Blocks 列表转换为 Word 文档字节流,Args: blocks: Block 列表, style_map: 样式映射, style_data: 样式数据, Returns: Word 文档字节流"""
493 487
     doc = Document()
494 488
     
495 489
     # 注入样式
@@ -534,16 +528,8 @@ def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict)
534 528
     
535 529
     return docx_bytes
536 530
 
537
-
538 531
 def _fix_normal_style_spacing(doc: Document, style_data: dict):
539
-    """验证并修正 Normal 样式的段后间距
540
-    
541
-    从 style_data 中读取 Normal 样式的段后间距定义,
542
-    确保文档中的 Normal 样式与之一致
543
-    
544
-    关键修复:python-docx 默认模板中 Normal 样式的 styleId 可能是 "Normal" 而不是 "1",
545
-    需要同时检查这两种情况
546
-    """
532
+    """验证并修正 Normal 样式的段后间距,从 style_data 中读取 Normal 样式的段后间距定义,确保文档中的 Normal 样式与之一致,关键修复:python-docx 默认模板中 Normal 样式的 styleId 可能是 "Normal" 而不是 "1",需要同时检查这两种情况"""
547 533
     try:
548 534
         # 查找 Normal 样式的定义
549 535
         normal_style_def = None
@@ -594,7 +580,6 @@ def _fix_normal_style_spacing(doc: Document, style_data: dict):
594 580
         print(f"警告: 修正 Normal 样式段后间距失败: {e}")
595 581
         pass
596 582
 
597
-
598 583
 def _apply_spacing_fix_to_style_elem(style_elem):
599 584
     """对单个样式元素应用间距修复"""
600 585
     try:
@@ -622,12 +607,8 @@ def _apply_spacing_fix_to_style_elem(style_elem):
622 607
     except Exception:
623 608
         pass
624 609
 
625
-
626 610
 def _update_normal_style_if_needed(doc: Document, blocks: list[dict]):
627
-    """更新 Normal 样式以匹配 blocks 中最常用的字体
628
-    
629
-    这样可以确保空行在 Word 中显示正确的字体和字号
630
-    """
611
+    """更新 Normal 样式以匹配 blocks 中最常用的字体,这样可以确保空行在 Word 中显示正确的字体和字号"""
631 612
     # 统计段落中最常用的字体和字号
632 613
     font_counts = {}
633 614
     size_counts = {}
@@ -706,17 +687,8 @@ def _update_normal_style_if_needed(doc: Document, blocks: list[dict]):
706 687
             # 如果修改样式失败,继续(不影响文档生成)
707 688
             pass
708 689
 
709
-
710 690
 def _inject_numbering_via_zip(docx_bytes: bytes, style_data: dict) -> bytes:
711
-    """通过 ZIP 操作注入编号格式到 Word 文档
712
-    
713
-    Args:
714
-        docx_bytes: 原始 Word 文档字节流
715
-        style_data: 样式数据(包含 numbering 定义)
716
-        
717
-    Returns:
718
-        注入编号格式后的 Word 文档字节流
719
-    """
691
+    """通过 ZIP 操作注入编号格式到 Word 文档,Args: docx_bytes: 原始 Word 文档字节流, style_data: 样式数据(包含 numbering 定义), Returns: 注入编号格式后的 Word 文档字节流"""
720 692
     numbering_def = style_data.get("numbering")
721 693
     if not numbering_def:
722 694
         # 没有编号定义,直接返回原文档
@@ -804,11 +776,11 @@ def _render_heading_block(doc: Document, block: dict, style_map: dict):
804 776
     # 创建段落
805 777
     para = doc.add_paragraph()
806 778
     
807
-    # 应用 Word 样式
808
-    style_id = _resolve_style_id(style_map, style_name, f'Heading {level}')
809
-    if style_id:
779
+    # 应用 Word 样式(使用样式名称,python-docx 推荐方式)
780
+    style_name_or_id = _resolve_style_id(style_map, style_name, f'Heading {level}')
781
+    if style_name_or_id:
810 782
         try:
811
-            para.style = doc.styles[style_id]
783
+            para.style = style_name_or_id  # 直接赋值名称,python-docx 会自动查找
812 784
         except KeyError:
813 785
             para.style = f'Heading {level}'
814 786
     else:
@@ -861,11 +833,11 @@ def _render_paragraph_block(doc: Document, block: dict, style_map: dict):
861 833
     
862 834
     para = doc.add_paragraph()
863 835
     
864
-    # 应用 Word 样式
865
-    style_id = _resolve_style_id(style_map, style_name, 'Normal')
866
-    if style_id:
836
+    # 应用 Word 样式(使用样式名称,python-docx 推荐方式)
837
+    style_name_or_id = _resolve_style_id(style_map, style_name, 'Normal')
838
+    if style_name_or_id:
867 839
         try:
868
-            para.style = doc.styles[style_id]
840
+            para.style = style_name_or_id  # 直接赋值名称,python-docx 会自动查找
869 841
         except KeyError:
870 842
             para.style = 'Normal'
871 843
     else:
@@ -1141,22 +1113,22 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
1141 1113
             
1142 1114
             # 应用单元格的 Word 样式(如果有)
1143 1115
             if cell_word_style:
1144
-                # 尝试从 style_map 解析样式ID
1145
-                style_id = _resolve_style_id(style_map, cell_word_style)
1116
+                # 尝试从 style_map 解析样式名称
1117
+                style_name_or_id = _resolve_style_id(style_map, cell_word_style)
1146 1118
                 
1147
-                if style_id:
1148
-                    # 通过 style_id 应用样式
1119
+                if style_name_or_id:
1120
+                    # 直接使用样式名称(python-docx 推荐方式)
1149 1121
                     try:
1150
-                        para.style = doc.styles[style_id]
1122
+                        para.style = style_name_or_id
1151 1123
                     except KeyError:
1152
-                        # 如果 style_id 不存在,尝试直接使用名称
1124
+                        # 如果解析的名称不存在,尝试直接使用原始名称
1153 1125
                         try:
1154 1126
                             para.style = cell_word_style
1155 1127
                         except KeyError:
1156 1128
                             # 都失败了,使用 Normal
1157 1129
                             para.style = 'Normal'
1158 1130
                 else:
1159
-                    # 没有找到 style_id,尝试直接使用名称
1131
+                    # 没有找到映射,尝试直接使用名称
1160 1132
                     try:
1161 1133
                         para.style = cell_word_style
1162 1134
                     except KeyError:
@@ -1200,14 +1172,14 @@ def _render_image_block(doc: Document, block: dict, style_map: dict = None):
1200 1172
         # 创建段落并应用 Word 样式
1201 1173
         paragraph = doc.add_paragraph()
1202 1174
         
1203
-        # 应用 Word 样式(如"图表标题")
1175
+        # 应用 Word 样式(如"图表标题")(使用样式名称,python-docx 推荐方式)
1204 1176
         if style_map:
1205
-            style_id = _resolve_style_id(style_map, word_style, 'Normal')
1206
-            if style_id:
1177
+            style_name_or_id = _resolve_style_id(style_map, word_style, 'Normal')
1178
+            if style_name_or_id:
1207 1179
                 try:
1208
-                    paragraph.style = doc.styles[style_id]
1180
+                    paragraph.style = style_name_or_id  # 直接赋值名称
1209 1181
                 except KeyError:
1210
-                    # 如果找不到样式ID,尝试使用样式名称
1182
+                    # 如果解析的名称不存在,尝试使用原始样式名称
1211 1183
                     try:
1212 1184
                         paragraph.style = word_style
1213 1185
                     except KeyError:
@@ -1261,10 +1233,7 @@ def _render_image_block(doc: Document, block: dict, style_map: dict = None):
1261 1233
 
1262 1234
 
1263 1235
 def _set_update_fields_on_open(doc: Document):
1264
-    """在文档设置中写入 updateFields,打开时自动触发域更新
1265
-    
1266
-    这样用户在 Word/WPS 中打开文档时,会自动更新所有域(包括目录和页码)
1267
-    """
1236
+    """设置文档在 Word/WPS 中打开时自动更新所有域(包括目录和页码)"""
1268 1237
     try:
1269 1238
         settings = doc.settings.element
1270 1239
         update_fields = OxmlElement('w:updateFields')
@@ -1273,14 +1242,8 @@ def _set_update_fields_on_open(doc: Document):
1273 1242
     except Exception as e:
1274 1243
         print(f"警告: 设置自动更新域失败: {e}")
1275 1244
 
1276
-
1277 1245
 def _render_toc_block(doc: Document, block: dict):
1278
-    """渲染目录块
1279
-    
1280
-    Args:
1281
-        doc: python-docx Document 对象
1282
-        block: TOC block 数据
1283
-    """
1246
+    """渲染目录块,创建 TOC 域并添加到文档"""
1284 1247
     # 获取目录标题和配置
1285 1248
     content = block.get('content', {})
1286 1249
     if isinstance(content, dict):
@@ -1328,16 +1291,8 @@ def _render_toc_block(doc: Document, block: dict):
1328 1291
         # 为新节(正文部分)添加页码,从1开始
1329 1292
         _add_page_number_footer_with_restart(doc, new_section)
1330 1293
 
1331
-
1332 1294
 def _create_toc_field(paragraph, levels: str = '1-3', use_hyperlinks: bool = True, use_outline_levels: bool = True):
1333
-    """在段落中创建 TOC 域代码
1334
-    
1335
-    Args:
1336
-        paragraph: 段落对象
1337
-        levels: 包含的标题层级,如 "1-3" 表示 1-3 级标题
1338
-        use_hyperlinks: 是否使用超链接
1339
-        use_outline_levels: 是否使用大纲级别
1340
-    """
1295
+    """在段落中创建 TOC 域代码,支持指定标题层级、超链接和大纲级别"""
1341 1296
     run = paragraph.add_run()
1342 1297
 
1343 1298
     # 开始域字符
@@ -1380,13 +1335,8 @@ def _create_toc_field(paragraph, levels: str = '1-3', use_hyperlinks: bool = Tru
1380 1335
     # 将所有元素添加到 run
1381 1336
     run._r.extend([fldChar_begin, instrText, fldChar_sep, placeholder_r, fldChar_end])
1382 1337
 
1383
-
1384 1338
 def _add_page_number_footer(doc: Document):
1385
-    """在页脚居中插入「第 X 页 / 共 Y 页」
1386
-    
1387
-    Args:
1388
-        doc: python-docx Document 对象
1389
-    """
1339
+    """在页脚居中插入「第 X 页 / 共 Y 页」格式的页码"""
1390 1340
     try:
1391 1341
         section = doc.sections[0]
1392 1342
         section.footer_distance = Pt(20)  # 页脚距底边设置
@@ -1425,14 +1375,8 @@ def _add_page_number_footer(doc: Document):
1425 1375
     except Exception as e:
1426 1376
         print(f"警告: 添加页码页脚失败: {e}")
1427 1377
 
1428
-
1429 1378
 def _add_page_number_footer_with_restart(doc: Document, section):
1430
-    """在指定节的页脚居中插入页码,并设置从1开始编号
1431
-    
1432
-    Args:
1433
-        doc: python-docx Document 对象
1434
-        section: 要添加页码的节
1435
-    """
1379
+    """在指定节的页脚居中插入页码,并设置从 1 开始编号"""
1436 1380
     try:
1437 1381
         # 设置页脚距底边
1438 1382
         section.footer_distance = Pt(20)
@@ -1481,7 +1425,6 @@ def _add_page_number_footer_with_restart(doc: Document, section):
1481 1425
     except Exception as e:
1482 1426
         print(f"警告: 添加页码页脚(带重启)失败: {e}")
1483 1427
 
1484
-
1485 1428
 # ------------------------------------------------------------------ #
1486 1429
 # 公共工具
1487 1430
 # ------------------------------------------------------------------ #
@@ -1493,16 +1436,8 @@ def _safe_filename(name: str) -> str:
1493 1436
         name = name.replace(ch, "_")
1494 1437
     return name.strip() or "document"
1495 1438
 
1496
-
1497 1439
 def _make_filename(blocks: list[dict]) -> str:
1498
-    """从 blocks 中提取第一个标题或段落作为文件名 + 时间戳
1499
-    
1500
-    Args:
1501
-        blocks: Block 列表
1502
-        
1503
-    Returns:
1504
-        文件名(不含扩展名)
1505
-    """
1440
+    """从 blocks 中提取第一个标题或段落作为文件名并添加时间戳"""
1506 1441
     # 查找第一个标题或段落
1507 1442
     first_text = ""
1508 1443
     for block in blocks:
@@ -1526,4 +1461,4 @@ def _make_filename(blocks: list[dict]) -> str:
1526 1461
         safe = safe[:50]
1527 1462
     
1528 1463
     ts = int(time.time() * 1000)
1529
-    return f"{safe}_{ts}"
1464
+    return f"{safe}_{ts}"

+ 293 - 321
app/services/word_parser.py

@@ -75,111 +75,73 @@ def _load_theme_fonts(docx_path: Path) -> dict:
75 75
     return theme_fonts
76 76
 
77 77
 
78
-def _get_eastasia_font_from_element(element):
79
-    """从 XML 元素中提取 eastAsia 字体(用于中文字体)"""
80
-    if element is None:
78
+def _extract_font_from_rfonts(rFonts, theme_fonts: dict = None):
79
+    """从 w:rFonts 元素提取字体(优先级: eastAsia → 主题引用 → ascii/hAnsi)"""
80
+    if rFonts is None:
81 81
         return None
82
-    rFonts = element.find(qn('w:rFonts'))
83
-    if rFonts is not None:
84
-        east_asia = rFonts.get(qn('w:eastAsia'))
85
-        if east_asia:
86
-            return east_asia
87
-    return None
82
+    
83
+    # 优先 eastAsia(中文)
84
+    if font := rFonts.get(qn('w:eastAsia')):
85
+        return font
86
+    
87
+    # 主题字体引用
88
+    if theme_fonts:
89
+        if theme_key := rFonts.get(qn('w:eastAsiaTheme')):
90
+            if theme_font := theme_fonts.get(theme_key):
91
+                return theme_font
92
+    
93
+    # 回退到 ascii/hAnsi(西文)
94
+    return rFonts.get(qn('w:ascii')) or rFonts.get(qn('w:hAnsi'))
88 95
 
89 96
 
90 97
 def _get_font_name(run, theme_fonts: dict = None):
91
-    """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体;特殊处理混合语言字体继承)"""
98
+    """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体)"""
92 99
     if theme_fonts is None:
93 100
         theme_fonts = _current_theme_fonts
94 101
     
95
-    # 1. 尝试从 XML 读取字体
96
-    if hasattr(run._element, 'rPr'):
97
-        rPr = run._element.rPr
98
-        if rPr is not None:
99
-            rFonts = rPr.find(qn('w:rFonts'))
100
-            if rFonts is not None:
101
-                # 1a. 优先 eastAsia(中文字体)
102
-                east_asia = rFonts.get(qn('w:eastAsia'))
103
-                if east_asia:
104
-                    return east_asia
105
-                
106
-                # 1b. 主题字体引用
107
-                if theme_fonts:
108
-                    east_asia_theme = rFonts.get(qn('w:eastAsiaTheme'))
109
-                    if east_asia_theme and east_asia_theme in theme_fonts:
110
-                        return theme_fonts[east_asia_theme]
111
-                
112
-                # 1c. 如果只定义了 ascii/hAnsi,没有 eastAsia
113
-                # 返回 None 让其从样式继承中文字体
114
-                # 这样可以正确处理 Heading 2 等情况
115
-                ascii_font = rFonts.get(qn('w:ascii'))
116
-                hAnsi_font = rFonts.get(qn('w:hAnsi'))
117
-                if ascii_font or hAnsi_font:
118
-                    # 有西文字体但没有中文字体,返回 None
119
-                    # 让 _extract_paragraph_format 从样式提取
120
-                    return None
121
-    
122
-    # 2. 回退到标准 API(ascii 字体)
123
-    if run.font.name:
124
-        return run.font.name
102
+    # 从 run 的 XML 提取字体
103
+    if hasattr(run._element, 'rPr') and run._element.rPr is not None:
104
+        rFonts = run._element.rPr.find(qn('w:rFonts'))
105
+        if font := _extract_font_from_rfonts(rFonts, theme_fonts):
106
+            return font
107
+        
108
+        # 特殊情况:只定义了 ascii/hAnsi 但没有 eastAsia,返回 None 让调用者从段落样式提取
109
+        if rFonts is not None and (rFonts.get(qn('w:ascii')) or rFonts.get(qn('w:hAnsi'))):
110
+            return None
125 111
     
126
-    return None
112
+    # 回退到标准 API
113
+    return run.font.name
127 114
 
128 115
 
129
-def _get_paragraph_style_font(para):
130
-    """从段落样式中提取字体(当 run 级别没有字体设置时使用,优先 eastAsia)"""
116
+def _get_paragraph_style_font(para, theme_fonts: dict = None):
117
+    """从段落样式中提取字体(递归查找基础样式)"""
118
+    if theme_fonts is None:
119
+        theme_fonts = _current_theme_fonts
120
+    
131 121
     try:
132
-        style = para.style
133
-        if hasattr(style, 'element'):
134
-            rPr = style.element.find(qn('w:rPr'))
135
-            if rPr is not None:
136
-                rFonts = rPr.find(qn('w:rFonts'))
137
-                if rFonts is not None:
138
-                    # 优先 eastAsia(中文字体)
139
-                    east_asia = rFonts.get(qn('w:eastAsia'))
140
-                    if east_asia:
141
-                        return east_asia
142
-            
143
-            # 如果当前样式没有 eastAsia,查找基础样式的 eastAsia
144
-            # 这样可以正确处理 Heading 2 等只定义 ascii 但基于 Normal 的样式
145
-            if hasattr(style, 'base_style') and style.base_style:
146
-                base_font = _get_paragraph_style_font_recursive(style.base_style)
147
-                if base_font:
148
-                    return base_font
149
-            
150
-            # 如果没有 eastAsia,回退到 ascii/hAnsi
151
-            if rPr is not None:
152
-                rFonts = rPr.find(qn('w:rFonts'))
153
-                if rFonts is not None:
154
-                    # 其次 ascii
155
-                    ascii_font = rFonts.get(qn('w:ascii'))
156
-                    if ascii_font:
157
-                        return ascii_font
158
-                    # 最后 hAnsi
159
-                    hAnsi = rFonts.get(qn('w:hAnsi'))
160
-                    if hAnsi:
161
-                        return hAnsi
122
+        if not para.style or not hasattr(para.style, 'element'):
123
+            return None
124
+        
125
+        return _get_style_font_recursive(para.style, theme_fonts)
162 126
     except Exception:
163
-        pass
164
-    
165
-    return None
127
+        return None
166 128
 
167 129
 
168
-def _get_paragraph_style_font_recursive(style):
169
-    """递归查找样式的 eastAsia 字体(用于基础样式查找)"""
130
+def _get_style_font_recursive(style, theme_fonts: dict = None, depth: int = 0):
131
+    """递归查找样式字体(限制深度防止死循环)"""
132
+    if depth > 10 or not style or not hasattr(style, 'element'):
133
+        return None
134
+    
170 135
     try:
171
-        if hasattr(style, 'element'):
172
-            rPr = style.element.find(qn('w:rPr'))
173
-            if rPr is not None:
174
-                rFonts = rPr.find(qn('w:rFonts'))
175
-                if rFonts is not None:
176
-                    east_asia = rFonts.get(qn('w:eastAsia'))
177
-                    if east_asia:
178
-                        return east_asia
179
-            
180
-            # 继续查找基础样式
181
-            if hasattr(style, 'base_style') and style.base_style:
182
-                return _get_paragraph_style_font_recursive(style.base_style)
136
+        rPr = style.element.find(qn('w:rPr'))
137
+        if rPr is not None:
138
+            rFonts = rPr.find(qn('w:rFonts'))
139
+            if font := _extract_font_from_rfonts(rFonts, theme_fonts):
140
+                return font
141
+        
142
+        # 递归查找基础样式
143
+        if hasattr(style, 'base_style') and style.base_style:
144
+            return _get_style_font_recursive(style.base_style, theme_fonts, depth + 1)
183 145
     except Exception:
184 146
         pass
185 147
     
@@ -195,29 +157,21 @@ def _get_style_formatting(style):
195 157
     
196 158
     try:
197 159
         rPr = style.element.find(qn('w:rPr'))
198
-        if rPr is not None:
199
-            # 加粗
200
-            bold_elem = rPr.find(qn('w:b'))
201
-            if bold_elem is not None:
202
-                bold_val = bold_elem.get(qn('w:val'))
203
-                # w:val 为 None、'1' 或 'true' 表示加粗
204
-                if bold_val is None or bold_val in ('1', 'true'):
205
-                    formatting['bold'] = True
206
-            
207
-            # 斜体
208
-            italic_elem = rPr.find(qn('w:i'))
209
-            if italic_elem is not None:
210
-                italic_val = italic_elem.get(qn('w:val'))
211
-                if italic_val is None or italic_val in ('1', 'true'):
212
-                    formatting['italic'] = True
213
-            
214
-            # 下划线
215
-            underline_elem = rPr.find(qn('w:u'))
216
-            if underline_elem is not None:
217
-                underline_val = underline_elem.get(qn('w:val'))
218
-                # 下划线有多种类型,只要存在就算有下划线
219
-                if underline_val and underline_val != 'none':
220
-                    formatting['underline'] = True
160
+        if rPr is None:
161
+            return formatting
162
+        
163
+        # 检查加粗、斜体(w:val 为 None/'1'/'true' 表示启用)
164
+        for prop_name in ['b', 'i']:
165
+            if elem := rPr.find(qn(f'w:{prop_name}')):
166
+                val = elem.get(qn('w:val'))
167
+                if val is None or val in ('1', 'true'):
168
+                    formatting[{'b': 'bold', 'i': 'italic'}[prop_name]] = True
169
+        
170
+        # 检查下划线(有多种类型)
171
+        if underline_elem := rPr.find(qn('w:u')):
172
+            underline_val = underline_elem.get(qn('w:val'))
173
+            if underline_val and underline_val != 'none':
174
+                formatting['underline'] = True
221 175
     except Exception:
222 176
         pass
223 177
     
@@ -229,23 +183,39 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
229 183
     global _current_theme_fonts
230 184
     
231 185
     doc = DocxDocument(str(docx_path))
232
-    blocks = []
233
-    block_order = 0
234
-    
235
-    # 加载主题字体并设置为当前主题
236 186
     _current_theme_fonts = _load_theme_fonts(docx_path)
237 187
     
238
-    # 标题计数器(按 level 分别计数)
239
-    heading_counters = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}
240
-    # 其他类型的全局计数器
241
-    type_counters = {
242
-        'paragraph': 0,
243
-        'image': 0,
244
-        'table': 0
245
-    }
246
-    parent_stack = []  # 维护父标题栈
188
+    # 初始化解析上下文
189
+    context = _init_parse_context()
247 190
     
248
-    # 提取所有图片及其位置信息
191
+    # 提取图片映射
192
+    image_map = _build_image_map(doc)
193
+    
194
+    # 收集文档元素
195
+    elements = _collect_document_elements(doc)
196
+    
197
+    # 建立段落索引映射
198
+    para_idx_map = _build_paragraph_index_map(elements)
199
+    
200
+    # 转换元素为 blocks
201
+    blocks = _convert_elements_to_blocks(elements, context, image_map, para_idx_map)
202
+    
203
+    return blocks
204
+
205
+
206
+def _init_parse_context():
207
+    """初始化解析上下文"""
208
+    return {
209
+        'blocks': [],
210
+        'block_order': 0,
211
+        'heading_counters': {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0},
212
+        'type_counters': {'paragraph': 0, 'image': 0, 'table': 0},
213
+        'parent_stack': []
214
+    }
215
+
216
+
217
+def _build_image_map(doc):
218
+    """构建图片位置映射"""
249 219
     from app.services.image_service import extract_images_from_word
250 220
     images = extract_images_from_word(doc)
251 221
     image_map = {}
@@ -254,8 +224,11 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
254 224
         if para_idx not in image_map:
255 225
             image_map[para_idx] = []
256 226
         image_map[para_idx].append(img)
257
-    
258
-    # 收集所有元素(段落、表格、SDT)并按文档顺序排列
227
+    return image_map
228
+
229
+
230
+def _collect_document_elements(doc):
231
+    """收集文档中的所有元素(段落、表格、SDT)"""
259 232
     elements = []
260 233
     body = doc.element.body
261 234
     para_map = {p._element: p for p in doc.paragraphs}
@@ -264,211 +237,210 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
264 237
     for child in body:
265 238
         tag = child.tag
266 239
         if tag.endswith('p'):
267
-            para = para_map.get(child)
268
-            if para:
240
+            if para := para_map.get(child):
269 241
                 elements.append(('para', para))
270 242
         elif tag.endswith('tbl'):
271
-            table = table_map.get(child)
272
-            if table:
243
+            if table := table_map.get(child):
273 244
                 elements.append(('table', table))
274
-        elif tag.endswith('sdt'):  # ★ 新增:检测 SDT(可能是目录)
245
+        elif tag.endswith('sdt'):
275 246
             elements.append(('sdt', child))
276 247
     
277
-    # 记录段落索引
278
-    para_idx_in_elements = {}
248
+    return elements
249
+
250
+
251
+def _build_paragraph_index_map(elements):
252
+    """建立段落索引映射"""
253
+    para_idx_map = {}
279 254
     actual_para_idx = 0
280 255
     for elem_idx, (elem_type, elem) in enumerate(elements):
281 256
         if elem_type == "para":
282
-            para_idx_in_elements[actual_para_idx] = elem_idx
257
+            para_idx_map[actual_para_idx] = elem_idx
283 258
             actual_para_idx += 1
284
-    
285
-    # 转换为 Blocks
259
+    return para_idx_map
260
+
261
+
262
+def _convert_elements_to_blocks(elements, context, image_map, para_idx_map):
263
+    """将元素列表转换为 blocks"""
286 264
     for elem_idx, (elem_type, elem) in enumerate(elements):
287 265
         if elem_type == "para":
288
-            para = elem
289
-            style_name = para.style.name if para.style else "Normal"
290
-            
291
-            # 判断是否为标题
292
-            level = _identify_heading_level(para, style_name)
293
-            
294
-            if level:
295
-                # 提取内容(支持富文本)
296
-                content = _extract_rich_text(para)
297
-                
298
-                # 跳过空标题(没有内容的标题)
299
-                if not content:
300
-                    # 空标题不添加到 blocks,继续下一个段落
301
-                    continue
302
-                
303
-                # 标题块
304
-                index = heading_counters[level] * 100  # 稀疏排序:0, 100, 200...
305
-                heading_counters[level] += 1
306
-                
307
-                # 注意:不重置更深层级的计数器
308
-                # index 是全局的(按 level 独立计数),不受父标题影响
309
-                
310
-                # 更新父标题栈
311
-                while parent_stack and parent_stack[-1]['level'] >= level:
312
-                    parent_stack.pop()
313
-                
314
-                parent_id = parent_stack[-1]['id'] if parent_stack else None
315
-                
316
-                # 提取段落级样式
317
-                para_style = _extract_paragraph_format(para)
318
-                
319
-                block = {
320
-                    'id': f'block-h{level}-{index}',  # 使用 index 而不是 block_order
321
-                    'block_order': block_order * 100,  # 稀疏排序
322
-                    'type': 'heading',
323
-                    'level': level,
324
-                    'index': index,  # 稀疏 index
325
-                    'content': content,
326
-                    'word_style': style_name,
327
-                    'style': para_style,  # 颗粒度样式
328
-                    'metadata': {
329
-                        'parent_heading_id': parent_id
330
-                    }
331
-                }
332
-                blocks.append(block)
333
-                parent_stack.append({'id': block['id'], 'level': level})
334
-                block_order += 1
335
-            
336
-            else:
337
-                # 普通段落
338
-                content = _extract_rich_text(para)
339
-                para_style = _extract_paragraph_format(para)  # 提取段落级样式
340
-                
341
-                # 空行处理:与普通 paragraph 一致,只是 content 为空
342
-                if not content and not (actual_para_idx - 1 in image_map):
343
-                    # 空行:作为普通段落,content 为空字符串
344
-                    parent_id = parent_stack[-1]['id'] if parent_stack else None
345
-                    index = type_counters['paragraph'] * 100
346
-                    type_counters['paragraph'] += 1
347
-                    
348
-                    block = {
349
-                        'id': f'block-p-{index}',
350
-                        'block_order': block_order * 100,
351
-                        'type': 'paragraph',
352
-                        'level': 0,
353
-                        'index': index,
354
-                        'content': '',  # 空内容
355
-                        'word_style': style_name,
356
-                        'style': para_style,  # 保留空行的样式(如果有)
357
-                        'metadata': {
358
-                            'parent_heading_id': parent_id
359
-                        }
360
-                    }
361
-                    blocks.append(block)
362
-                    block_order += 1
363
-                
364
-                elif content:
365
-                    # 有内容的段落
366
-                    parent_id = parent_stack[-1]['id'] if parent_stack else None
367
-                    index = type_counters['paragraph'] * 100
368
-                    type_counters['paragraph'] += 1
369
-                    
370
-                    # 如果是富文本数组,Block 样式为空;如果是纯文本,Block 有样式
371
-                    block_style = {} if isinstance(content, list) else para_style
372
-                    
373
-                    block = {
374
-                        'id': f'block-p-{index}',
375
-                        'block_order': block_order * 100,
376
-                        'type': 'paragraph',
377
-                        'level': 0,
378
-                        'index': index,
379
-                        'content': content,
380
-                        'word_style': style_name,
381
-                        'style': block_style,
382
-                        'metadata': {
383
-                            'parent_heading_id': parent_id
384
-                        }
385
-                    }
386
-                    blocks.append(block)
387
-                    block_order += 1
388
-                
389
-                # 检查是否有图片
390
-                current_para_idx = None
391
-                for p_idx, e_idx in para_idx_in_elements.items():
392
-                    if e_idx == elem_idx:
393
-                        current_para_idx = p_idx
394
-                        break
395
-                
396
-                if current_para_idx is not None and current_para_idx in image_map:
397
-                    for img in image_map[current_para_idx]:
398
-                        parent_id = parent_stack[-1]['id'] if parent_stack else None
399
-                        index = type_counters['image'] * 100  # 稀疏 index
400
-                        type_counters['image'] += 1
401
-                        
402
-                        block = {
403
-                            'id': f'block-img-{index}',  # 使用 index
404
-                            'block_order': block_order * 100,
405
-                            'type': 'image',
406
-                            'level': 0,
407
-                            'index': index,  # 稀疏 index
408
-                            'content': img['data_url'],
409
-                            'word_style': img['style'].get('para_style', 'Normal'),
410
-                            'style': {
411
-                                'width': img['style'].get('width', 10.0),
412
-                                'height': img['style'].get('height', 7.0),
413
-                                'unit': img['style'].get('unit', 'cm'),
414
-                                'align': img['style'].get('align', 'left')
415
-                            },
416
-                            'metadata': {
417
-                                'alt': '图片',
418
-                                'parent_heading_id': parent_id
419
-                            }
420
-                        }
421
-                        blocks.append(block)
422
-                        block_order += 1
423
-        
266
+            _process_paragraph_element(elem, elem_idx, context, image_map, para_idx_map)
424 267
         elif elem_type == "table":
425
-            # 表格块
426
-            table = elem
427
-            table_content = _extract_table(table, doc)
428
-            parent_id = parent_stack[-1]['id'] if parent_stack else None
429
-            
430
-            # 计算表格元数据
431
-            rows = table_content.get('rows', [])
432
-            # 遍历所有行,找出最大的列数(考虑 colspan)
433
-            max_cols = 0
434
-            for row_data in rows:
435
-                row_cols = sum(cell.get('colspan', 1) for cell in row_data.get('cells', []))
436
-                max_cols = max(max_cols, row_cols)
437
-            
438
-            cols = max_cols if max_cols > 0 else 0
439
-            
440
-            index = type_counters['table'] * 100  # 稀疏 index
441
-            type_counters['table'] += 1
442
-            
443
-            block = {
444
-                'id': f'block-table-{index}',  # 使用 index
445
-                'block_order': block_order * 100,
446
-                'type': 'table',
447
-                'level': 0,
448
-                'index': index,  # 稀疏 index
449
-                'content': table_content,
450
-                'word_style': 'Table Grid',
451
-                'style': {},
452
-                'metadata': {
453
-                    'cols': cols,
454
-                    'rows': len(rows),
455
-                    'table_width': 100,
456
-                    'table_width_unit': 'percent',
457
-                    'col_widths': [100 // cols] * cols if cols > 0 else [],
458
-                    'parent_heading_id': parent_id
459
-                }
460
-            }
461
-            blocks.append(block)
462
-            block_order += 1
463
-        
464
-        elif elem_type == "sdt":  # ★ 新增:处理 SDT(目录)
465
-            sdt = elem
466
-            toc_block = _extract_toc_from_sdt(sdt, block_order, parent_stack)
467
-            if toc_block:
468
-                blocks.append(toc_block)
469
-                block_order += 1
268
+            _process_table_element(elem, context)
269
+        elif elem_type == "sdt":
270
+            _process_sdt_element(elem, context)
470 271
     
471
-    return blocks
272
+    return context['blocks']
273
+
274
+
275
+def _process_paragraph_element(para, elem_idx, context, image_map, para_idx_map):
276
+    """处理段落元素"""
277
+    style_name = para.style.name if para.style else "Normal"
278
+    level = _identify_heading_level(para, style_name)
279
+    
280
+    if level:
281
+        _process_heading_paragraph(para, style_name, level, context)
282
+    else:
283
+        _process_normal_paragraph(para, style_name, elem_idx, context, image_map, para_idx_map)
284
+
285
+
286
+def _process_heading_paragraph(para, style_name, level, context):
287
+    """处理标题段落"""
288
+    content = _extract_rich_text(para)
289
+    if not content:
290
+        return  # 跳过空标题
291
+    
292
+    # 计算 index 并更新计数器
293
+    index = context['heading_counters'][level] * 100
294
+    context['heading_counters'][level] += 1
295
+    
296
+    # 更新父标题栈
297
+    parent_stack = context['parent_stack']
298
+    while parent_stack and parent_stack[-1]['level'] >= level:
299
+        parent_stack.pop()
300
+    
301
+    parent_id = parent_stack[-1]['id'] if parent_stack else None
302
+    para_style = _extract_paragraph_format(para)
303
+    
304
+    block = {
305
+        'id': f'block-h{level}-{index}',
306
+        'block_order': context['block_order'] * 100,
307
+        'type': 'heading',
308
+        'level': level,
309
+        'index': index,
310
+        'content': content,
311
+        'word_style': style_name,
312
+        'style': para_style,
313
+        'metadata': {'parent_heading_id': parent_id}
314
+    }
315
+    
316
+    context['blocks'].append(block)
317
+    parent_stack.append({'id': block['id'], 'level': level})
318
+    context['block_order'] += 1
319
+
320
+
321
+def _process_normal_paragraph(para, style_name, elem_idx, context, image_map, para_idx_map):
322
+    """处理普通段落(包括空行)"""
323
+    content = _extract_rich_text(para)
324
+    para_style = _extract_paragraph_format(para)
325
+    
326
+    # 查找当前段落索引
327
+    current_para_idx = None
328
+    for p_idx, e_idx in para_idx_map.items():
329
+        if e_idx == elem_idx:
330
+            current_para_idx = p_idx
331
+            break
332
+    
333
+    # 空行处理
334
+    if not content and (current_para_idx is None or current_para_idx - 1 not in image_map):
335
+        _create_paragraph_block('', style_name, para_style, context)
336
+    elif content:
337
+        # 有内容的段落
338
+        block_style = {} if isinstance(content, list) else para_style
339
+        _create_paragraph_block(content, style_name, block_style, context)
340
+    
341
+    # 处理段落后的图片
342
+    if current_para_idx is not None and current_para_idx in image_map:
343
+        for img in image_map[current_para_idx]:
344
+            _create_image_block(img, context)
345
+
346
+
347
+def _create_paragraph_block(content, style_name, style, context):
348
+    """创建段落 block"""
349
+    parent_id = context['parent_stack'][-1]['id'] if context['parent_stack'] else None
350
+    index = context['type_counters']['paragraph'] * 100
351
+    context['type_counters']['paragraph'] += 1
352
+    
353
+    block = {
354
+        'id': f'block-p-{index}',
355
+        'block_order': context['block_order'] * 100,
356
+        'type': 'paragraph',
357
+        'level': 0,
358
+        'index': index,
359
+        'content': content,
360
+        'word_style': style_name,
361
+        'style': style,
362
+        'metadata': {'parent_heading_id': parent_id}
363
+    }
364
+    
365
+    context['blocks'].append(block)
366
+    context['block_order'] += 1
367
+
368
+
369
+def _create_image_block(img, context):
370
+    """创建图片 block"""
371
+    parent_id = context['parent_stack'][-1]['id'] if context['parent_stack'] else None
372
+    index = context['type_counters']['image'] * 100
373
+    context['type_counters']['image'] += 1
374
+    
375
+    block = {
376
+        'id': f'block-img-{index}',
377
+        'block_order': context['block_order'] * 100,
378
+        'type': 'image',
379
+        'level': 0,
380
+        'index': index,
381
+        'content': img['data_url'],
382
+        'word_style': img['style'].get('para_style', 'Normal'),
383
+        'style': {
384
+            'width': img['style'].get('width', 10.0),
385
+            'height': img['style'].get('height', 7.0),
386
+            'unit': img['style'].get('unit', 'cm'),
387
+            'align': img['style'].get('align', 'left')
388
+        },
389
+        'metadata': {
390
+            'alt': '图片',
391
+            'parent_heading_id': parent_id
392
+        }
393
+    }
394
+    
395
+    context['blocks'].append(block)
396
+    context['block_order'] += 1
397
+
398
+
399
+def _process_table_element(table, context):
400
+    """处理表格元素"""
401
+    table_content = _extract_table(table, None)
402
+    parent_id = context['parent_stack'][-1]['id'] if context['parent_stack'] else None
403
+    
404
+    # 计算表格列数
405
+    rows = table_content.get('rows', [])
406
+    max_cols = max(
407
+        (sum(cell.get('colspan', 1) for cell in row_data.get('cells', []))
408
+         for row_data in rows),
409
+        default=0
410
+    )
411
+    
412
+    index = context['type_counters']['table'] * 100
413
+    context['type_counters']['table'] += 1
414
+    
415
+    block = {
416
+        'id': f'block-table-{index}',
417
+        'block_order': context['block_order'] * 100,
418
+        'type': 'table',
419
+        'level': 0,
420
+        'index': index,
421
+        'content': table_content,
422
+        'word_style': 'Table Grid',
423
+        'style': {},
424
+        'metadata': {
425
+            'cols': max_cols,
426
+            'rows': len(rows),
427
+            'table_width': 100,
428
+            'table_width_unit': 'percent',
429
+            'col_widths': [100 // max_cols] * max_cols if max_cols > 0 else [],
430
+            'parent_heading_id': parent_id
431
+        }
432
+    }
433
+    
434
+    context['blocks'].append(block)
435
+    context['block_order'] += 1
436
+
437
+
438
+def _process_sdt_element(sdt, context):
439
+    """处理 SDT 元素(目录)"""
440
+    toc_block = _extract_toc_from_sdt(sdt, context['block_order'], context['parent_stack'])
441
+    if toc_block:
442
+        context['blocks'].append(toc_block)
443
+        context['block_order'] += 1
472 444
 
473 445
 
474 446
 def _extract_toc_from_sdt(sdt, block_order: int, parent_stack: list) -> Optional[dict]: