Bläddra i källkod

feat(export): 优化 Word 文档样式一致性和空段落格式处理

- 在注入样式后清除 python-docx 样式缓存,确保新样式生效
- 新增 Normal 样式间距校验,修复段落间距不一致问题
- 实现 _fix_normal_style_spacing() 函数,根据样式数据修正 Normal 样式的段后间距
- 处理多种 Normal 样式 ID 变体(styleId "1"、"Normal"、"normal")
- 优化空段落字体提取逻辑,优先从 XML 直接格式化属性读取而非样式默认值
- 从段落 pPr/rPr XML 元素提取字体属性后再回退到样式定义
- 优先使用 eastAsia 字体属性以改善中文字符渲染效果
- 改进 word_parser 以处理空段落格式的边界情况
chensiyu 1 månad sedan
förälder
incheckning
6024f6eca2
2 ändrade filer med 141 tillägg och 10 borttagningar
  1. 102 0
      app/services/export_service.py
  2. 39 10
      app/services/word_parser.py

+ 102 - 0
app/services/export_service.py

@@ -94,6 +94,16 @@ def inject_styles_from_json(doc: Document, style_data: dict) -> None:
94 94
     
95 95
     # 注入编号格式定义
96 96
     inject_numbering_from_json(doc, style_data)
97
+    
98
+    # 强制清除 python-docx 的样式缓存,确保后续使用的是新注入的样式
99
+    # 这一步很重要,因为 python-docx 会缓存样式对象
100
+    try:
101
+        # 清除样式字典缓存,强制重新从 XML 读取
102
+        if hasattr(doc.styles, '_element'):
103
+            # 触发样式重新加载
104
+            doc.styles._element = styles_element
105
+    except Exception:
106
+        pass
97 107
 
98 108
 
99 109
 def inject_numbering_from_json(doc: Document, style_data: dict) -> None:
@@ -279,6 +289,10 @@ def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict)
279 289
     doc = Document()
280 290
     inject_styles_from_json(doc, style_data)
281 291
     
292
+    # 验证并修正 Normal 样式的段后间距
293
+    # 这是为了确保样式正确应用,避免 python-docx 的默认值覆盖
294
+    _fix_normal_style_spacing(doc, style_data)
295
+    
282 296
     # 检查 blocks 中是否有常用的字体和字号,用于修改 Normal 样式
283 297
     # 这样可以确保空行在 Word 中显示正确的字体
284 298
     _update_normal_style_if_needed(doc, blocks)
@@ -305,6 +319,94 @@ def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict)
305 319
     return docx_bytes
306 320
 
307 321
 
322
+def _fix_normal_style_spacing(doc: Document, style_data: dict):
323
+    """验证并修正 Normal 样式的段后间距
324
+    
325
+    从 style_data 中读取 Normal 样式的段后间距定义,
326
+    确保文档中的 Normal 样式与之一致
327
+    
328
+    关键修复:python-docx 默认模板中 Normal 样式的 styleId 可能是 "Normal" 而不是 "1",
329
+    需要同时检查这两种情况
330
+    """
331
+    try:
332
+        # 查找 Normal 样式的定义
333
+        normal_style_def = None
334
+        for style_entry in style_data.get("styles", []):
335
+            if style_entry.get("name") == "Normal" or style_entry.get("style_id") == "1":
336
+                normal_style_def = style_entry
337
+                break
338
+        
339
+        if not normal_style_def:
340
+            return
341
+        
342
+        # 从 full_xml_definition 中提取段后间距
343
+        xml_def = normal_style_def.get("full_xml_definition", {})
344
+        pPr = xml_def.get("@children", {}).get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr", {})
345
+        spacing = pPr.get("@children", {}).get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing", {})
346
+        spacing_attrib = spacing.get("@attrib", {})
347
+        
348
+        # 检查是否定义了段后间距
349
+        after_key = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}after"
350
+        has_after = after_key in spacing_attrib
351
+        after_value = spacing_attrib.get(after_key, "0")
352
+        
353
+        # 如果样式定义中没有 w:after 或者 w:after="0",确保文档中所有 Normal 样式也是 0
354
+        if not has_after or after_value == "0":
355
+            styles_element = doc.styles.element
356
+            
357
+            # 需要修正的所有 Normal 样式 ID(python-docx 可能使用不同的 ID)
358
+            normal_style_ids = ["1", "Normal", "normal"]
359
+            
360
+            for style_id in normal_style_ids:
361
+                normal_elem = styles_element.find(
362
+                    f'.//{qn("w:style")}[@{qn("w:styleId")}="{style_id}"]'
363
+                )
364
+                
365
+                if normal_elem is not None:
366
+                    _apply_spacing_fix_to_style_elem(normal_elem)
367
+            
368
+            # 也尝试通过名称查找(可能有其他命名的 Normal 样式)
369
+            for style_elem in styles_element.findall(qn("w:style")):
370
+                name_elem = style_elem.find(qn("w:name"))
371
+                if name_elem is not None:
372
+                    name_val = name_elem.get(qn("w:val"))
373
+                    if name_val and name_val.lower() == "normal":
374
+                        _apply_spacing_fix_to_style_elem(style_elem)
375
+                
376
+    except Exception as e:
377
+        # 如果修正失败,不影响文档生成,只是可能保留默认的 10 磅间距
378
+        print(f"警告: 修正 Normal 样式段后间距失败: {e}")
379
+        pass
380
+
381
+
382
+def _apply_spacing_fix_to_style_elem(style_elem):
383
+    """对单个样式元素应用间距修复"""
384
+    try:
385
+        # 找到或创建 pPr 节点
386
+        pPr_elem = style_elem.find(qn("w:pPr"))
387
+        if pPr_elem is None:
388
+            pPr_elem = OxmlElement("w:pPr")
389
+            # 插入到第一个位置(在 name 之后)
390
+            name_elem = style_elem.find(qn("w:name"))
391
+            if name_elem is not None:
392
+                idx = list(style_elem).index(name_elem) + 1
393
+                style_elem.insert(idx, pPr_elem)
394
+            else:
395
+                style_elem.insert(0, pPr_elem)
396
+        
397
+        # 找到或创建 spacing 节点
398
+        spacing_elem = pPr_elem.find(qn("w:spacing"))
399
+        if spacing_elem is None:
400
+            spacing_elem = OxmlElement("w:spacing")
401
+            pPr_elem.append(spacing_elem)
402
+        
403
+        # 确保 w:after="0"(明确设置为 0,而不是依赖默认值)
404
+        spacing_elem.set(qn("w:after"), "0")
405
+        
406
+    except Exception:
407
+        pass
408
+
409
+
308 410
 def _update_normal_style_if_needed(doc: Document, blocks: list[dict]):
309 411
     """更新 Normal 样式以匹配 blocks 中最常用的字体
310 412
     

+ 39 - 10
app/services/word_parser.py

@@ -569,17 +569,46 @@ def _extract_paragraph_format(para) -> dict:
569 569
             if all_same_color:
570 570
                 style['color'] = first_color
571 571
     else:
572
-        # 空段落(没有 runs):从段落样式中提取默认字体和字号
573
-        style_font = _get_paragraph_style_font(para)
574
-        if style_font:
575
-            style['font_name'] = style_font
572
+        # 空段落(没有 runs):先尝试从段落属性 XML 中提取直接格式化的字体和字号
573
+        pPr = para._element.find(qn('w:pPr'))
574
+        if pPr is not None:
575
+            rPr = pPr.find(qn('w:rPr'))
576
+            if rPr is not None:
577
+                # 从段落属性中提取字号(w:sz,单位是半磅)
578
+                sz = rPr.find(qn('w:sz'))
579
+                if sz is not None:
580
+                    size_val = sz.get(qn('w:val'))
581
+                    if size_val:
582
+                        try:
583
+                            style['font_size'] = int(size_val) / 2  # 转换为磅值
584
+                        except (ValueError, TypeError):
585
+                            pass
586
+                
587
+                # 从段落属性中提取字体
588
+                rFonts = rPr.find(qn('w:rFonts'))
589
+                if rFonts is not None:
590
+                    # 优先使用 eastAsia 字体(中文)
591
+                    eastAsia = rFonts.get(qn('w:eastAsia'))
592
+                    ascii_font = rFonts.get(qn('w:ascii'))
593
+                    # 如果有 eastAsia 字体就用,否则用 ascii
594
+                    if eastAsia:
595
+                        style['font_name'] = eastAsia
596
+                    elif ascii_font:
597
+                        style['font_name'] = ascii_font
576 598
         
577
-        # 尝试从段落样式中提取字号
578
-        try:
579
-            if hasattr(para.style, 'font') and para.style.font.size:
580
-                style['font_size'] = para.style.font.size.pt
581
-        except Exception:
582
-            pass
599
+        # 如果 XML 中没有找到,再从段落样式中提取默认字体和字号
600
+        if 'font_name' not in style:
601
+            style_font = _get_paragraph_style_font(para)
602
+            if style_font:
603
+                style['font_name'] = style_font
604
+        
605
+        if 'font_size' not in style:
606
+            # 尝试从段落样式中提取字号
607
+            try:
608
+                if hasattr(para.style, 'font') and para.style.font.size:
609
+                    style['font_size'] = para.style.font.size.pt
610
+            except Exception:
611
+                pass
583 612
     
584 613
     return style
585 614