Explorar el Código

feat(export): Word 文档样式和表格渲染优化
全面的段落格式支持:新增对行距、段前间距、段后间距等段落属性的完整支持
段落样式优先级应用:实现段落级样式的正确继承和覆盖机制
基于优先级的字体和字号应用:改进 run 级别的样式应用,确保字体名称和字号按正确优先级生效
健壮的 XML 元素管理:优化粗体、斜体、下划线格式的 XML 元素处理,确保格式正确应用
中文字符渲染支持:显式添加 eastAsia 字体属性,确保中文字符使用正确的字体显示
标题和段落块渲染优化:改进标题和段落块的样式应用逻辑,确保所有样式属性正确生效
空段落样式保留:修复空段落的样式保留问题,确保在 Word 中维持默认格式
对齐方式支持:为表格单元格添加水平对齐(左对齐、居中、右对齐)和垂直对齐(顶部、居中、底部)支持
单元格样式完整性:确保单元格内文本样式与对齐方式正确配合
全面的错误处理:为所有样式应用添加错误处理机制,防止单个样式错误影响整体导出
降级回退机制:当特定样式应用失败时,自动回退到默认样式,确保文档导出成功
SQLite 设计文档完善:更新 content-sqlite-design.md 文档,添加合并单元格的提取与导出的详细说明
设计模式和最佳实践:补充数据结构设计、XML 处理、样式优先级等最佳实践指南

chensiyu hace 1 mes
padre
commit
cc50d1acce
Se han modificado 3 ficheros con 856 adiciones y 326 borrados
  1. 240 55
      app/services/export_service.py
  2. 290 265
      app/services/word_parser.py
  3. 326 6
      docs/content-sqlite-design.md

+ 240 - 55
app/services/export_service.py

@@ -9,6 +9,7 @@ from pathlib import Path
9 9
 from typing import Optional
10 10
 
11 11
 from docx import Document
12
+from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
12 13
 from docx.enum.text import WD_ALIGN_PARAGRAPH
13 14
 from docx.oxml import OxmlElement
14 15
 from docx.oxml.ns import qn
@@ -116,12 +117,7 @@ def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
116 117
 
117 118
 
118 119
 def _apply_paragraph_style(para, style: dict):
119
-    """应用段落级样式(对齐方式)
120
-    
121
-    Args:
122
-        para: python-docx 段落对象
123
-        style: 样式字典
124
-    """
120
+    """应用段落级样式(对齐方式、行距、缩进等)"""
125 121
     # 对齐方式
126 122
     align = style.get('align')
127 123
     if align == 'center':
@@ -132,22 +128,115 @@ def _apply_paragraph_style(para, style: dict):
132 128
         para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
133 129
     elif align == 'left':
134 130
         para.alignment = WD_ALIGN_PARAGRAPH.LEFT
131
+    
132
+    # 段落格式(行距、缩进等)- 如果需要的话
133
+    pf = para.paragraph_format
134
+    
135
+    # 行距
136
+    if style.get('line_spacing'):
137
+        try:
138
+            pf.line_spacing = style['line_spacing']
139
+        except Exception:
140
+            pass
141
+    
142
+    # 段前间距
143
+    if style.get('space_before'):
144
+        try:
145
+            pf.space_before = Pt(style['space_before'])
146
+        except Exception:
147
+            pass
148
+    
149
+    # 段后间距
150
+    if style.get('space_after'):
151
+        try:
152
+            pf.space_after = Pt(style['space_after'])
153
+        except Exception:
154
+            pass
135 155
 
136 156
 
137 157
 def _apply_run_style(run, style: dict):
138
-    """应用 run 级样式(字符级格式)
158
+    """应用 run 级样式(字符级格式)- 增强版"""
159
+    # 字体名称(支持中文字体 eastAsia)- 优先处理字体
160
+    if style.get('font_name'):
161
+        font_name = style['font_name']
162
+        run.font.name = font_name
163
+        
164
+        # 对于中文字体,需要设置 eastAsia 属性(关键!)
165
+        try:
166
+            r = run._element
167
+            rPr = r.get_or_add_rPr()
168
+            rFonts = rPr.get_or_add_rFonts()
169
+            # 设置所有字体属性,确保中文字体正确应用
170
+            rFonts.set(qn('w:ascii'), font_name)
171
+            rFonts.set(qn('w:hAnsi'), font_name)
172
+            rFonts.set(qn('w:eastAsia'), font_name)
173
+            rFonts.set(qn('w:cs'), font_name)  # 复杂文字
174
+        except Exception:
175
+            pass
176
+    
177
+    # 字号(优先处理)
178
+    if style.get('font_size'):
179
+        try:
180
+            size_pt = float(style['font_size'])
181
+            run.font.size = Pt(size_pt)
182
+            
183
+            # 确保字号正确应用到 XML
184
+            r = run._element
185
+            rPr = r.get_or_add_rPr()
186
+            
187
+            # 移除旧的字号元素
188
+            for sz in rPr.findall(qn('w:sz')):
189
+                rPr.remove(sz)
190
+            for szCs in rPr.findall(qn('w:szCs')):
191
+                rPr.remove(szCs)
192
+            
193
+            # 添加新的字号元素(Word 使用半磅单位)
194
+            sz = OxmlElement('w:sz')
195
+            sz.set(qn('w:val'), str(int(size_pt * 2)))
196
+            rPr.append(sz)
197
+            
198
+            szCs = OxmlElement('w:szCs')
199
+            szCs.set(qn('w:val'), str(int(size_pt * 2)))
200
+            rPr.append(szCs)
201
+        except Exception:
202
+            pass
139 203
     
140
-    Args:
141
-        run: python-docx run 对象
142
-        style: 样式字典
143
-    """
144 204
     # 粗体
145 205
     if style.get('bold'):
146 206
         run.bold = True
207
+        # 确保粗体正确应用
208
+        try:
209
+            r = run._element
210
+            rPr = r.get_or_add_rPr()
211
+            # 移除旧的粗体元素
212
+            for b in rPr.findall(qn('w:b')):
213
+                rPr.remove(b)
214
+            for bCs in rPr.findall(qn('w:bCs')):
215
+                rPr.remove(bCs)
216
+            # 添加新的粗体元素
217
+            b = OxmlElement('w:b')
218
+            rPr.append(b)
219
+            bCs = OxmlElement('w:bCs')
220
+            rPr.append(bCs)
221
+        except Exception:
222
+            pass
147 223
     
148 224
     # 斜体
149 225
     if style.get('italic'):
150 226
         run.italic = True
227
+        try:
228
+            r = run._element
229
+            rPr = r.get_or_add_rPr()
230
+            for i in rPr.findall(qn('w:i')):
231
+                rPr.remove(i)
232
+            for iCs in rPr.findall(qn('w:iCs')):
233
+                rPr.remove(iCs)
234
+            i = OxmlElement('w:i')
235
+            rPr.append(i)
236
+            iCs = OxmlElement('w:iCs')
237
+            rPr.append(iCs)
238
+        except Exception:
239
+            pass
151 240
     
152 241
     # 下划线
153 242
     if style.get('underline'):
@@ -170,24 +259,6 @@ def _apply_run_style(run, style: dict):
170 259
                 )
171 260
         except (ValueError, AttributeError):
172 261
             pass
173
-    
174
-    # 字体名称(支持中文字体 eastAsia)
175
-    if style.get('font_name'):
176
-        font_name = style['font_name']
177
-        run.font.name = font_name
178
-        
179
-        # 对于中文字体,需要设置 eastAsia 属性
180
-        try:
181
-            r = run._element
182
-            rPr = r.get_or_add_rPr()
183
-            rFonts = rPr.get_or_add_rFonts()
184
-            rFonts.set(qn('w:eastAsia'), font_name)
185
-        except Exception:
186
-            pass
187
-    
188
-    # 字号
189
-    if style.get('font_size'):
190
-        run.font.size = Pt(style['font_size'])
191 262
 
192 263
 
193 264
 # ------------------------------------------------------------------ #
@@ -406,7 +477,7 @@ def _inject_numbering_via_zip(docx_bytes: bytes, style_data: dict) -> bytes:
406 477
 
407 478
 
408 479
 def _render_heading_block(doc: Document, block: dict, style_map: dict):
409
-    """渲染标题块(支持编号格式和自定义样式)"""
480
+    """渲染标题块(支持编号格式和自定义样式)- 增强版"""
410 481
     level = block['level']
411 482
     content = block['content']
412 483
     style_name = block.get('word_style', f'Heading {level}')
@@ -435,7 +506,9 @@ def _render_heading_block(doc: Document, block: dict, style_map: dict):
435 506
     else:
436 507
         # 纯文本:应用 block 级样式到 run
437 508
         run = para.add_run(str(content))
438
-        _apply_run_style(run, block_style)
509
+        # 标题也需要应用自定义样式(如果有的话)
510
+        if block_style:
511
+            _apply_run_style(run, block_style)
439 512
     
440 513
     # 尝试应用编号格式(如果样式中包含编号定义)
441 514
     try:
@@ -463,7 +536,7 @@ def _render_heading_block(doc: Document, block: dict, style_map: dict):
463 536
 
464 537
 
465 538
 def _render_paragraph_block(doc: Document, block: dict, style_map: dict):
466
-    """渲染段落块(支持富文本和自定义样式)"""
539
+    """渲染段落块(支持富文本和自定义样式)- 增强版"""
467 540
     content = block['content']
468 541
     style_name = block.get('word_style', 'Normal')
469 542
     block_style = block.get('style', {})
@@ -487,22 +560,62 @@ def _render_paragraph_block(doc: Document, block: dict, style_map: dict):
487 560
     if isinstance(content, list):
488 561
         # 富文本:应用 run 级样式
489 562
         _render_rich_text(para, content, block_style)
563
+    elif content:
564
+        # 有内容的纯文本
565
+        run = para.add_run(str(content))
566
+        _apply_run_style(run, block_style)
490 567
     else:
491
-        # 纯文本或空内容
492
-        # 即使是空内容,如果有 block 级样式(字体、字号等),也需要添加空 run 来保存样式
493
-        # 这样当用户在 Word 中输入文本时,会自动应用这些样式
494
-        run = para.add_run(str(content) if content else '')
568
+        # 空内容 - 关键修复:确保空段落也能保留样式
569
+        # 创建空 run 并应用样式,这样用户在 Word 中输入文本时会自动应用这些样式
570
+        run = para.add_run('')
495 571
         _apply_run_style(run, block_style)
572
+        
573
+        # 对于空段落,还需要确保段落格式正确
574
+        # 特别是字体和字号,即使 run 是空的也要设置
575
+        if block_style.get('font_name') or block_style.get('font_size'):
576
+            # 再添加一个空格符 run 来"激活"样式(Word 的特殊处理)
577
+            # 然后立即删除,但样式会保留
578
+            try:
579
+                # 方法:在段落属性中设置默认 run 属性
580
+                pPr = para._element.get_or_add_pPr()
581
+                rPr = pPr.find(qn('w:rPr'))
582
+                if rPr is None:
583
+                    rPr = OxmlElement('w:rPr')
584
+                    pPr.insert(0, rPr)
585
+                
586
+                # 设置字体
587
+                if block_style.get('font_name'):
588
+                    font_name = block_style['font_name']
589
+                    rFonts = rPr.find(qn('w:rFonts'))
590
+                    if rFonts is None:
591
+                        rFonts = OxmlElement('w:rFonts')
592
+                        rPr.append(rFonts)
593
+                    rFonts.set(qn('w:ascii'), font_name)
594
+                    rFonts.set(qn('w:hAnsi'), font_name)
595
+                    rFonts.set(qn('w:eastAsia'), font_name)
596
+                    rFonts.set(qn('w:cs'), font_name)
597
+                
598
+                # 设置字号
599
+                if block_style.get('font_size'):
600
+                    size_pt = float(block_style['font_size'])
601
+                    # 移除旧的字号
602
+                    for sz in rPr.findall(qn('w:sz')):
603
+                        rPr.remove(sz)
604
+                    for szCs in rPr.findall(qn('w:szCs')):
605
+                        rPr.remove(szCs)
606
+                    # 添加新的字号
607
+                    sz = OxmlElement('w:sz')
608
+                    sz.set(qn('w:val'), str(int(size_pt * 2)))
609
+                    rPr.append(sz)
610
+                    szCs = OxmlElement('w:szCs')
611
+                    szCs.set(qn('w:val'), str(int(size_pt * 2)))
612
+                    rPr.append(szCs)
613
+            except Exception:
614
+                pass
496 615
 
497 616
 
498 617
 def _render_rich_text(para, segments: list, block_style: dict = None):
499
-    """渲染富文本格式
500
-    
501
-    Args:
502
-        para: python-docx 段落对象
503
-        segments: 富文本片段列表,每个片段包含 text 和 style
504
-        block_style: Block 级样式,作为默认样式(可选)
505
-    """
618
+    """渲染富文本格式 - 增强版"""
506 619
     for seg in segments:
507 620
         text = seg.get('text', '')
508 621
         seg_style = seg.get('style', {})
@@ -516,7 +629,15 @@ def _render_rich_text(para, segments: list, block_style: dict = None):
516 629
         merged_style.update(seg_style)
517 630
         
518 631
         # 应用合并后的样式
519
-        _apply_run_style(run, merged_style)
632
+        if merged_style:
633
+            _apply_run_style(run, merged_style)
634
+        
635
+        # 处理 word_style(如果片段有独立的 word_style)
636
+        word_style = seg.get('word_style')
637
+        if word_style:
638
+            # 注意:run 不能直接应用样式,只能应用字符样式
639
+            # 这里我们只应用格式属性
640
+            pass
520 641
 
521 642
 
522 643
 def _render_table_block(doc: Document, block: dict, style_map: dict):
@@ -561,6 +682,16 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
561 682
     except KeyError:
562 683
         table.style = 'Table Grid'
563 684
     
685
+    # 设置表格对齐方式(默认居中)
686
+    block_style = block.get('style', {})
687
+    table_align = block_style.get('table_align', 'center')  # 默认居中
688
+    if table_align == 'center':
689
+        table.alignment = WD_TABLE_ALIGNMENT.CENTER
690
+    elif table_align == 'left':
691
+        table.alignment = WD_TABLE_ALIGNMENT.LEFT
692
+    elif table_align == 'right':
693
+        table.alignment = WD_TABLE_ALIGNMENT.RIGHT
694
+    
564 695
     # 设置列宽
565 696
     if col_widths:
566 697
         for col_idx, width in enumerate(col_widths):
@@ -569,6 +700,7 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
569 700
     
570 701
     # 填充内容并处理合并单元格
571 702
     merge_map = {}  # {(row, col): (end_row, end_col)} 记录合并区域
703
+    occupied = {}  # {(row, col): True} 记录哪些位置已被占用(被合并的单元格)
572 704
     
573 705
     for r_idx, row_data in enumerate(rows):
574 706
         # 设置行高
@@ -577,36 +709,58 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
577 709
             table.rows[r_idx].height = Pt(row_height)
578 710
         
579 711
         cells_data = row_data.get('cells', [])
580
-        col_offset = 0  # 当前列偏移(考虑 colspan)
581 712
         
713
+        # 构建一个映射:col_index -> cell_data
714
+        cells_by_col = {}
582 715
         for cell_data in cells_data:
716
+            col_idx = cell_data.get('col_index', None)
717
+            if col_idx is not None:
718
+                cells_by_col[col_idx] = cell_data
719
+        
720
+        # 遍历所有列
721
+        for col_idx in range(num_cols):
722
+            # 检查这个位置是否被占用(被上方的合并单元格占用)
723
+            if occupied.get((r_idx, col_idx), False):
724
+                continue  # 跳过被占用的位置
725
+            
726
+            # 检查是否有数据要填充到这个位置
727
+            if col_idx not in cells_by_col:
728
+                continue  # 这个位置没有数据
729
+            
730
+            cell_data = cells_by_col[col_idx]
731
+            
583 732
             # 跳过被合并的单元格(rowspan=0 表示这个单元格被上面的单元格合并了)
584 733
             rowspan = cell_data.get('rowspan', 1)
585 734
             if rowspan == 0:
586
-                col_offset += 1
587 735
                 continue
588 736
             
589 737
             colspan = cell_data.get('colspan', 1)
590 738
             
591 739
             # 确保不越界
592
-            if col_offset >= num_cols:
593
-                break
740
+            if col_idx >= num_cols:
741
+                continue
594 742
             
595 743
             # 获取起始单元格
596
-            start_cell = table.rows[r_idx].cells[col_offset]
744
+            start_cell = table.rows[r_idx].cells[col_idx]
597 745
             
598 746
             # 处理合并单元格
599 747
             if colspan > 1 or rowspan > 1:
600 748
                 # 计算结束位置
601
-                end_col = min(col_offset + colspan - 1, num_cols - 1)
749
+                end_col = min(col_idx + colspan - 1, num_cols - 1)
602 750
                 end_row = min(r_idx + rowspan - 1, num_rows - 1)
603 751
                 
604 752
                 # 合并单元格
605
-                if end_col > col_offset or end_row > r_idx:
753
+                if end_col > col_idx or end_row > r_idx:
606 754
                     try:
607 755
                         end_cell = table.rows[end_row].cells[end_col]
608 756
                         start_cell.merge(end_cell)
609
-                        merge_map[(r_idx, col_offset)] = (end_row, end_col)
757
+                        merge_map[(r_idx, col_idx)] = (end_row, end_col)
758
+                        
759
+                        # 标记被合并的单元格位置为已占用
760
+                        for merge_r in range(r_idx, end_row + 1):
761
+                            for merge_c in range(col_idx, end_col + 1):
762
+                                if merge_r != r_idx or merge_c != col_idx:  # 不标记起始单元格
763
+                                    occupied[(merge_r, merge_c)] = True
610 764
                     except Exception:
611 765
                         pass  # 合并失败,继续
612 766
             
@@ -621,11 +775,45 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
621 775
             # 填充单元格内容
622 776
             cell_text = cell_data.get('text', '')
623 777
             cell_style = cell_data.get('style', {})
778
+            cell_word_style = cell_data.get('word_style')  # 获取单元格的 word_style
779
+            
780
+            # 设置单元格垂直对齐(默认居中)
781
+            valign = cell_style.get('valign', 'center')  # 默认垂直居中
782
+            if valign == 'center' or valign == 'middle':
783
+                start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
784
+            elif valign == 'top':
785
+                start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP
786
+            elif valign == 'bottom':
787
+                start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.BOTTOM
624 788
             
625 789
             # 清空默认段落
626 790
             start_cell.text = ''
627 791
             para = start_cell.paragraphs[0]
628 792
             
793
+            # 应用单元格的 Word 样式(如果有)
794
+            if cell_word_style:
795
+                # 尝试从 style_map 解析样式ID
796
+                style_id = _resolve_style_id(style_map, cell_word_style)
797
+                
798
+                if style_id:
799
+                    # 通过 style_id 应用样式
800
+                    try:
801
+                        para.style = doc.styles[style_id]
802
+                    except KeyError:
803
+                        # 如果 style_id 不存在,尝试直接使用名称
804
+                        try:
805
+                            para.style = cell_word_style
806
+                        except KeyError:
807
+                            # 都失败了,使用 Normal
808
+                            para.style = 'Normal'
809
+                else:
810
+                    # 没有找到 style_id,尝试直接使用名称
811
+                    try:
812
+                        para.style = cell_word_style
813
+                    except KeyError:
814
+                        # 失败了,使用 Normal
815
+                        para.style = 'Normal'
816
+            
629 817
             # 应用单元格段落级样式(对齐)
630 818
             _apply_paragraph_style(para, cell_style)
631 819
             
@@ -638,9 +826,6 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
638 826
                 run = para.add_run(str(cell_text))
639 827
                 # 应用单元格 run 级样式
640 828
                 _apply_run_style(run, cell_style)
641
-            
642
-            # 更新列偏移
643
-            col_offset += colspan
644 829
 
645 830
 
646 831
 def _render_image_block(doc: Document, block: dict):

+ 290 - 265
app/services/word_parser.py

@@ -18,14 +18,7 @@ _current_theme_fonts = {}
18 18
 
19 19
 
20 20
 def _load_theme_fonts(docx_path: Path) -> dict:
21
-    """从 Word 文档中加载主题字体定义
22
-    
23
-    Args:
24
-        docx_path: Word 文档路径
25
-        
26
-    Returns:
27
-        主题字体映射字典,例如: {'minorEastAsia': '宋体', 'majorEastAsia': '黑体'}
28
-    """
21
+    """从 Word 文档中加载主题字体定义"""
29 22
     # 检查缓存
30 23
     cache_key = str(docx_path)
31 24
     if cache_key in _theme_fonts_cache:
@@ -83,14 +76,7 @@ def _load_theme_fonts(docx_path: Path) -> dict:
83 76
 
84 77
 
85 78
 def _get_eastasia_font_from_element(element):
86
-    """从 XML 元素中提取 eastAsia 字体(用于中文字体)
87
-    
88
-    Args:
89
-        element: rPr XML 元素
90
-        
91
-    Returns:
92
-        eastAsia 字体名称或 None
93
-    """
79
+    """从 XML 元素中提取 eastAsia 字体(用于中文字体)"""
94 80
     if element is None:
95 81
         return None
96 82
     rFonts = element.find(qn('w:rFonts'))
@@ -102,19 +88,7 @@ def _get_eastasia_font_from_element(element):
102 88
 
103 89
 
104 90
 def _get_font_name(run, theme_fonts: dict = None):
105
-    """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体)
106
-    
107
-    特殊处理:如果 run 只定义了 ascii 字体(如 Times New Roman),
108
-    但没有定义 eastAsia,则忽略 run 的字体,返回 None 让其从样式继承中文字体。
109
-    这样可以正确处理混合语言的字体继承。
110
-    
111
-    Args:
112
-        run: python-docx Run 对象
113
-        theme_fonts: 主题字体映射字典(可选,默认使用全局的 _current_theme_fonts)
114
-        
115
-    Returns:
116
-        字体名称或 None
117
-    """
91
+    """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体;特殊处理混合语言字体继承)"""
118 92
     if theme_fonts is None:
119 93
         theme_fonts = _current_theme_fonts
120 94
     
@@ -153,16 +127,7 @@ def _get_font_name(run, theme_fonts: dict = None):
153 127
 
154 128
 
155 129
 def _get_paragraph_style_font(para):
156
-    """从段落样式中提取字体(当 run 级别没有字体设置时使用)
157
-    
158
-    优先提取 eastAsia(中文字体),如果没有则查找基础样式的 eastAsia
159
-    
160
-    Args:
161
-        para: python-docx 段落对象
162
-        
163
-    Returns:
164
-        字体名称或 None
165
-    """
130
+    """从段落样式中提取字体(当 run 级别没有字体设置时使用,优先 eastAsia)"""
166 131
     try:
167 132
         style = para.style
168 133
         if hasattr(style, 'element'):
@@ -201,14 +166,7 @@ def _get_paragraph_style_font(para):
201 166
 
202 167
 
203 168
 def _get_paragraph_style_font_recursive(style):
204
-    """递归查找样式的 eastAsia 字体(用于基础样式查找)
205
-    
206
-    Args:
207
-        style: python-docx Style 对象
208
-        
209
-    Returns:
210
-        eastAsia 字体名称或 None
211
-    """
169
+    """递归查找样式的 eastAsia 字体(用于基础样式查找)"""
212 170
     try:
213 171
         if hasattr(style, 'element'):
214 172
             rPr = style.element.find(qn('w:rPr'))
@@ -229,14 +187,7 @@ def _get_paragraph_style_font_recursive(style):
229 187
 
230 188
 
231 189
 def _get_style_formatting(style):
232
-    """从样式中提取格式属性(加粗、斜体、下划线等)
233
-    
234
-    Args:
235
-        style: python-docx Style 对象
236
-        
237
-    Returns:
238
-        格式属性字典 {'bold': True/False, 'italic': True/False, ...}
239
-    """
190
+    """从样式中提取格式属性(加粗、斜体、下划线等)"""
240 191
     formatting = {}
241 192
     
242 193
     if not style or not hasattr(style, 'element'):
@@ -274,14 +225,7 @@ def _get_style_formatting(style):
274 225
 
275 226
 
276 227
 def parse_word_to_blocks(docx_path: Path) -> list[dict]:
277
-    """将 Word 文档解析为 Block 列表
278
-    
279
-    Args:
280
-        docx_path: Word 文档路径
281
-        
282
-    Returns:
283
-        Block 列表,每个 Block 包含 id, block_order, type, level, index, content 等字段
284
-    """
228
+    """将 Word 文档解析为 Block 列表"""
285 229
     global _current_theme_fonts
286 230
     
287 231
     doc = DocxDocument(str(docx_path))
@@ -513,15 +457,7 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
513 457
 
514 458
 
515 459
 def _identify_heading_level(para, style_name: str) -> Optional[int]:
516
-    """识别段落的标题级别
517
-    
518
-    Args:
519
-        para: python-docx 段落对象
520
-        style_name: 样式名称
521
-        
522
-    Returns:
523
-        标题级别(1-6)或 None(不是标题)
524
-    """
460
+    """识别段落的标题级别(1-6 或 None)"""
525 461
     # 方法1:检查样式名称(内置样式)
526 462
     if style_name.startswith('Heading'):
527 463
         try:
@@ -559,14 +495,7 @@ def _identify_heading_level(para, style_name: str) -> Optional[int]:
559 495
 
560 496
 
561 497
 def _extract_paragraph_format(para) -> dict:
562
-    """提取段落级样式(Block 级别的 style)
563
-    
564
-    Args:
565
-        para: python-docx 段落对象
566
-        
567
-    Returns:
568
-        段落样式字典(只包含设计文档 5.3 中可支持的属性)
569
-    """
498
+    """提取段落级样式(Block 级别的 style)"""
570 499
     style = {}
571 500
     
572 501
     # 对齐方式
@@ -656,16 +585,7 @@ def _extract_paragraph_format(para) -> dict:
656 585
 
657 586
 
658 587
 def _extract_rich_text(para) -> str | list:
659
-    """提取段落的富文本内容
660
-    
661
-    Args:
662
-        para: python-docx 段落对象
663
-        
664
-    Returns:
665
-        纯文本字符串 或 富文本片段列表
666
-        - 纯文本:所有 runs 样式相同,返回字符串
667
-        - 富文本:runs 样式不同,返回数组,每个元素包含完整样式
668
-    """
588
+    """提取段落的富文本内容(纯文本字符串或富文本片段列表)"""
669 589
     text = para.text.strip()
670 590
     if not text:
671 591
         return ""
@@ -760,14 +680,7 @@ def _extract_rich_text(para) -> str | list:
760 680
 
761 681
 
762 682
 def _extract_table(table) -> dict:
763
-    """提取表格内容
764
-    
765
-    Args:
766
-        table: python-docx 表格对象
767
-        
768
-    Returns:
769
-        表格数据字典,包含合并单元格和尺寸信息
770
-    """
683
+    """提取表格内容(包含合并单元格和尺寸信息)- 完整修复版"""
771 684
     rows_data = []
772 685
     
773 686
     # 提取表格列宽(从 tblGrid)
@@ -781,200 +694,312 @@ def _extract_table(table) -> dict:
781 694
                 # twips 转 pt (1 pt = 20 twips)
782 695
                 col_widths.append(int(width) / 20)
783 696
     
784
-    # 用于跟踪行合并(vMerge)
785
-    # col_index -> {start_row, rowspan_count}
786
-    vmerge_tracking = {}
697
+    # 第一遍:从 XML 直接读取,建立列索引到行合并信息的映射
698
+    # col_index -> [{start_row, end_row}, ...]  # 可能有多个合并区间
699
+    vmerge_map = {}
787 700
     
788
-    for row_idx, row in enumerate(table.rows):
789
-        cells_data = []
790
-        
791
-        # 提取行高
792
-        row_height = None
793
-        if row.height:
794
-            row_height = row.height.pt
701
+    trs = tbl_elem.findall(qn('w:tr'))
702
+    for row_idx, tr in enumerate(trs):
703
+        tcs = tr.findall(qn('w:tc'))
704
+        col_offset = 0
795 705
         
796
-        col_offset = 0  # 当前列偏移(考虑 colspan)
797
-        seen_cells = set()  # 用于去重(基于对象 ID)
798
-        
799
-        for cell_idx, cell in enumerate(row.cells):
800
-            # 去重:跳过重复的单元格对象(合并单元格会返回同一个对象)
801
-            cell_id = id(cell)
802
-            if cell_id in seen_cells:
803
-                continue
804
-            seen_cells.add(cell_id)
805
-            # 提取单元格文本
806
-            cell_text = []
807
-            for para in cell.paragraphs:
808
-                para_text = _extract_rich_text(para)
809
-                if para_text:
810
-                    cell_text.append(para_text if isinstance(para_text, str) else para_text)
811
-            
812
-            # 检测单元格样式(从第一个段落的第一个 run)
813
-            cell_style = {}
814
-            cell_word_style = None  # 单元格的 word_style
815
-            
816
-            if cell.paragraphs:
817
-                first_para = cell.paragraphs[0]
818
-                
819
-                # 提取 word_style(段落样式)
820
-                if first_para.style:
821
-                    cell_word_style = first_para.style.name
822
-                    
823
-                    # 从样式中提取格式(加粗、斜体等)
824
-                    style_formatting = _get_style_formatting(first_para.style)
825
-                    # 将样式中定义的格式作为基础
826
-                    cell_style.update(style_formatting)
827
-                
828
-                if first_para.runs:
829
-                    first_run = first_para.runs[0]
830
-                    
831
-                    # 加粗(run 明确设置会覆盖样式)
832
-                    if first_run.bold is True:
833
-                        cell_style['bold'] = True
834
-                    elif first_run.bold is False:
835
-                        # 明确设置为不加粗,移除样式的加粗
836
-                        cell_style.pop('bold', None)
837
-                    # 如果 run.bold 为 None,保持样式中的设置
838
-                    
839
-                    # 斜体(run 明确设置会覆盖样式)
840
-                    if first_run.italic is True:
841
-                        cell_style['italic'] = True
842
-                    elif first_run.italic is False:
843
-                        cell_style.pop('italic', None)
844
-                    
845
-                    # 下划线(run 明确设置会覆盖样式)
846
-                    if first_run.underline:
847
-                        cell_style['underline'] = True
848
-                    
849
-                    # 字体
850
-                    font_name = _get_font_name(first_run)
851
-                    if font_name:
852
-                        cell_style['font_name'] = font_name
853
-                    
854
-                    # 字号
855
-                    if first_run.font.size:
856
-                        cell_style['font_size'] = first_run.font.size.pt
857
-                    
858
-                    # 颜色
859
-                    if first_run.font.color and first_run.font.color.rgb:
860
-                        cell_style['color'] = str(first_run.font.color.rgb)
861
-                
862
-                # 对齐方式
863
-                if first_para.alignment is not None:
864
-                    align_map = {0: 'left', 1: 'center', 2: 'right', 3: 'justify'}
865
-                    cell_style['align'] = align_map.get(first_para.alignment, 'left')
866
-            
867
-            # 合并多个段落的文本
868
-            if len(cell_text) == 1:
869
-                text_content = cell_text[0]
870
-            elif len(cell_text) > 1:
871
-                # 多个段落,用换行符连接
872
-                text_content = ' '.join(str(t) for t in cell_text)
873
-            else:
874
-                text_content = ""
875
-            
876
-            # 提取合并信息
877
-            tc_elem = cell._tc
878
-            tcPr = tc_elem.find(qn('w:tcPr'))
706
+        for tc in tcs:
707
+            tcPr = tc.find(qn('w:tcPr'))
879 708
             
880 709
             colspan = 1
881
-            rowspan = 1
882
-            is_vmerge_continue = False
710
+            has_vmerge_restart = False
711
+            has_vmerge_continue = False
712
+            is_empty = False
883 713
             
884 714
             if tcPr is not None:
885
-                # 列合并 (gridSpan)
715
+                # 列合并
886 716
                 grid_span = tcPr.find(qn('w:gridSpan'))
887 717
                 if grid_span is not None:
888 718
                     colspan = int(grid_span.get(qn('w:val')))
889 719
                 
890
-                # 行合并 (vMerge)
720
+                # 行合并
891 721
                 v_merge = tcPr.find(qn('w:vMerge'))
892 722
                 if v_merge is not None:
893 723
                     v_merge_val = v_merge.get(qn('w:val'))
894 724
                     if v_merge_val == 'restart':
895
-                        # 行合并起始
896
-                        vmerge_tracking[col_offset] = {
897
-                            'start_row': row_idx,
898
-                            'count': 1
899
-                        }
900
-                    elif v_merge_val is None:
901
-                        # 行合并继续(被合并的单元格)
902
-                        is_vmerge_continue = True
903
-                        if col_offset in vmerge_tracking:
904
-                            vmerge_tracking[col_offset]['count'] += 1
905
-            
906
-            # 计算实际的 rowspan
907
-            if col_offset in vmerge_tracking:
908
-                if vmerge_tracking[col_offset]['start_row'] == row_idx:
909
-                    # 这是起始行,后续会更新 rowspan
910
-                    rowspan = vmerge_tracking[col_offset]['count']
911
-                elif is_vmerge_continue:
912
-                    # 这是被合并的单元格,标记为 0(表示被合并)
913
-                    rowspan = 0
914
-            
915
-            # 提取单元格宽度
916
-            cell_width = None
725
+                        has_vmerge_restart = True
726
+                    else:
727
+                        # 'continue' 或 None/空字符串都表示继续合并
728
+                        has_vmerge_continue = True
729
+            
730
+            # 检查是否为空单元格(用于判断行合并)
731
+            paras = tc.findall(qn('w:p'))
732
+            text_parts = []
733
+            for p in paras:
734
+                runs = p.findall(qn('w:r'))
735
+                for r in runs:
736
+                    ts = r.findall(qn('w:t'))
737
+                    for t in ts:
738
+                        if t.text and t.text.strip():
739
+                            text_parts.append(t.text)
740
+            is_empty = len(text_parts) == 0
741
+            
742
+            # 处理行合并逻辑 - 记录所有合并区间
743
+            if col_offset not in vmerge_map:
744
+                vmerge_map[col_offset] = []
745
+            
746
+            merges = vmerge_map[col_offset]
747
+            
748
+            if has_vmerge_restart:
749
+                # 开始新的行合并
750
+                merges.append({
751
+                    'start_row': row_idx,
752
+                    'end_row': row_idx  # 初始结束行等于开始行,后续会扩展
753
+                })
754
+            elif has_vmerge_continue:
755
+                # 明确标记为 continue - 扩展最后一个合并
756
+                if merges:
757
+                    merges[-1]['end_row'] = row_idx
758
+            # 注意:移除了 "is_empty" 的判断,因为空单元格不一定意味着合并
759
+            
760
+            col_offset += colspan
761
+    
762
+    # 第二遍:完全从 XML 提取单元格数据
763
+    for row_idx, tr in enumerate(trs):
764
+        cells_data = []
765
+        
766
+        # 提取行高(从 python-docx,因为 XML 提取行高比较复杂)
767
+        row_height = None
768
+        if row_idx < len(table.rows):
769
+            row = table.rows[row_idx]
770
+            if row.height:
771
+                row_height = row.height.pt
772
+        
773
+        # 遍历 XML 的 tc 元素
774
+        tcs = tr.findall(qn('w:tc'))
775
+        col_offset = 0
776
+        
777
+        for tc in tcs:
778
+            # 首先提取 colspan 和 vMerge 信息
779
+            tcPr = tc.find(qn('w:tcPr'))
780
+            
781
+            # 提取 colspan
782
+            colspan = 1
917 783
             if tcPr is not None:
918
-                tcW = tcPr.find(qn('w:tcW'))
919
-                if tcW is not None:
920
-                    width_val = tcW.get(qn('w:w'))
921
-                    width_type = tcW.get(qn('w:type'))
922
-                    if width_val and width_type != 'pct':
923
-                        # twips 转 pt
924
-                        cell_width = int(width_val) / 20
925
-            
926
-            # 如果没有明确宽度,使用列宽
927
-            if cell_width is None and col_offset < len(col_widths):
928
-                if colspan == 1:
929
-                    cell_width = col_widths[col_offset]
930
-                else:
931
-                    # 多列合并,计算总宽度
932
-                    cell_width = sum(col_widths[col_offset:col_offset + colspan])
933
-            
934
-            # 构建单元格数据(方案 D:包含 word_style)
935
-            cell_data = {
936
-                'text': text_content,
937
-                'rowspan': rowspan,
938
-                'colspan': colspan,
939
-                'style': cell_style
940
-            }
784
+                grid_span = tcPr.find(qn('w:gridSpan'))
785
+                if grid_span is not None:
786
+                    colspan = int(grid_span.get(qn('w:val')))
787
+            
788
+            # 检查 vMerge - 如果是 continue,跳过这个单元格
789
+            is_vmerge_continue = False
790
+            if tcPr is not None:
791
+                v_merge = tcPr.find(qn('w:vMerge'))
792
+                if v_merge is not None:
793
+                    v_merge_val = v_merge.get(qn('w:val'))
794
+                    # 'continue' 或 None/空字符串都表示继续合并
795
+                    if v_merge_val != 'restart':
796
+                        is_vmerge_continue = True
797
+            
798
+            if is_vmerge_continue:
799
+                # 这是被合并的单元格,跳过
800
+                col_offset += colspan
801
+                continue
802
+            
803
+            # 不需要跳过被占用的列,因为 XML 中已经包含了占位符
804
+            # (上面的 is_vmerge_continue 检查已经处理了)
941 805
             
942
-            # 添加 word_style
943
-            if cell_word_style:
944
-                cell_data['word_style'] = cell_word_style
806
+            # 检查是否为空单元格
807
+            is_empty = True
808
+            paras = tc.findall(qn('w:p'))
809
+            text_parts = []
810
+            for p in paras:
811
+                runs = p.findall(qn('w:r'))
812
+                for r in runs:
813
+                    ts = r.findall(qn('w:t'))
814
+                    for t in ts:
815
+                        if t.text and t.text.strip():
816
+                            is_empty = False
817
+                            text_parts.append(t.text)
945 818
             
946
-            # 添加尺寸信息
947
-            if cell_width is not None:
948
-                cell_data['width'] = round(cell_width, 2)
819
+            # 判断是否应该提取此单元格
820
+            should_extract = True
821
+            rowspan = 1
822
+            
823
+            # 查找该列该行所在的合并区间
824
+            if col_offset in vmerge_map:
825
+                merges = vmerge_map[col_offset]
826
+                for merge in merges:
827
+                    if merge['start_row'] == row_idx:
828
+                        # 这是合并的起始行
829
+                        rowspan = merge['end_row'] - merge['start_row'] + 1
830
+                        break
831
+                    elif row_idx > merge['start_row'] and row_idx <= merge['end_row']:
832
+                        # 这是被合并的行
833
+                        should_extract = False  # 跳过被合并的单元格
834
+                        break
949 835
             
950
-            cells_data.append(cell_data)
836
+            if should_extract:
837
+                # 从 XML 提取文本(支持富文本)
838
+                cell_text_segments = []
839
+                for p in paras:
840
+                    para_segments = []
841
+                    runs = p.findall(qn('w:r'))
842
+                    
843
+                    for r in runs:
844
+                        # 提取文本
845
+                        run_text = []
846
+                        for t in r.findall(qn('w:t')):
847
+                            if t.text:
848
+                                run_text.append(t.text)
849
+                        
850
+                        if run_text:
851
+                            # 提取 run 级样式
852
+                            run_style = {}
853
+                            rPr = r.find(qn('w:rPr'))
854
+                            if rPr is not None:
855
+                                # 加粗
856
+                                if rPr.find(qn('w:b')) is not None:
857
+                                    run_style['bold'] = True
858
+                                
859
+                                # 斜体
860
+                                if rPr.find(qn('w:i')) is not None:
861
+                                    run_style['italic'] = True
862
+                                
863
+                                # 下划线
864
+                                if rPr.find(qn('w:u')) is not None:
865
+                                    run_style['underline'] = True
866
+                                
867
+                                # 字号
868
+                                sz = rPr.find(qn('w:sz'))
869
+                                if sz is not None:
870
+                                    size_val = sz.get(qn('w:val'))
871
+                                    if size_val:
872
+                                        run_style['font_size'] = int(size_val) / 2  # 半磅转磅
873
+                                
874
+                                # 颜色
875
+                                color = rPr.find(qn('w:color'))
876
+                                if color is not None:
877
+                                    color_val = color.get(qn('w:val'))
878
+                                    if color_val and color_val != 'auto':
879
+                                        run_style['color'] = color_val
880
+                            
881
+                            para_segments.append({
882
+                                'text': ''.join(run_text),
883
+                                'style': run_style
884
+                            })
885
+                    
886
+                    if para_segments:
887
+                        cell_text_segments.extend(para_segments)
888
+                
889
+                # 合并文本
890
+                if len(cell_text_segments) == 0:
891
+                    text_content = ""
892
+                elif len(cell_text_segments) == 1 and not cell_text_segments[0]['style']:
893
+                    # 纯文本
894
+                    text_content = cell_text_segments[0]['text']
895
+                else:
896
+                    # 富文本或多个片段 - 简化处理:合并为纯文本
897
+                    text_content = ''.join(seg['text'] for seg in cell_text_segments)
898
+                
899
+                # 提取单元格样式(从第一个段落的第一个 run)
900
+                cell_style = {}
901
+                cell_word_style = None
902
+                
903
+                if paras:
904
+                    first_p = paras[0]
905
+                    pPr = first_p.find(qn('w:pPr'))
906
+                    
907
+                    if pPr is not None:
908
+                        # 段落样式名称
909
+                        pStyle = pPr.find(qn('w:pStyle'))
910
+                        if pStyle is not None:
911
+                            cell_word_style = pStyle.get(qn('w:val'))
912
+                        
913
+                        # 对齐方式
914
+                        jc = pPr.find(qn('w:jc'))
915
+                        if jc is not None:
916
+                            align_val = jc.get(qn('w:val'))
917
+                            align_map = {'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify'}
918
+                            cell_style['align'] = align_map.get(align_val, 'left')
919
+                    
920
+                    # 从第一个 run 提取样式
921
+                    runs = first_p.findall(qn('w:r'))
922
+                    if runs:
923
+                        first_r = runs[0]
924
+                        rPr = first_r.find(qn('w:rPr'))
925
+                        if rPr is not None:
926
+                            # 加粗
927
+                            if rPr.find(qn('w:b')) is not None:
928
+                                cell_style['bold'] = True
929
+                            
930
+                            # 斜体
931
+                            if rPr.find(qn('w:i')) is not None:
932
+                                cell_style['italic'] = True
933
+                            
934
+                            # 下划线
935
+                            if rPr.find(qn('w:u')) is not None:
936
+                                cell_style['underline'] = True
937
+                            
938
+                            # 字号
939
+                            sz = rPr.find(qn('w:sz'))
940
+                            if sz is not None:
941
+                                size_val = sz.get(qn('w:val'))
942
+                                if size_val:
943
+                                    cell_style['font_size'] = int(size_val) / 2
944
+                            
945
+                            # 颜色
946
+                            color = rPr.find(qn('w:color'))
947
+                            if color is not None:
948
+                                color_val = color.get(qn('w:val'))
949
+                                if color_val and color_val != 'auto':
950
+                                    cell_style['color'] = color_val
951
+                            
952
+                            # 字体(复杂,需要处理主题字体)
953
+                            rFonts = rPr.find(qn('w:rFonts'))
954
+                            if rFonts is not None:
955
+                                font_name = (rFonts.get(qn('w:eastAsia')) or 
956
+                                           rFonts.get(qn('w:ascii')) or 
957
+                                           rFonts.get(qn('w:hAnsi')))
958
+                                if font_name:
959
+                                    cell_style['font_name'] = font_name
960
+                
961
+                # 提取单元格宽度
962
+                cell_width = None
963
+                if tcPr is not None:
964
+                    tcW = tcPr.find(qn('w:tcW'))
965
+                    if tcW is not None:
966
+                        width_val = tcW.get(qn('w:w'))
967
+                        width_type = tcW.get(qn('w:type'))
968
+                        if width_val and width_type != 'pct':
969
+                            cell_width = int(width_val) / 20
970
+                
971
+                if cell_width is None and col_offset < len(col_widths):
972
+                    if colspan == 1:
973
+                        cell_width = col_widths[col_offset]
974
+                    else:
975
+                        cell_width = sum(col_widths[col_offset:col_offset + colspan])
976
+                
977
+                # 构建单元格数据
978
+                cell_data = {
979
+                    'text': text_content,
980
+                    'rowspan': rowspan,
981
+                    'colspan': colspan,
982
+                    'col_index': col_offset,  # 记录该单元格的绝对列位置
983
+                    'style': cell_style
984
+                }
985
+                
986
+                if cell_word_style:
987
+                    cell_data['word_style'] = cell_word_style
988
+                
989
+                if cell_width is not None:
990
+                    cell_data['width'] = round(cell_width, 2)
991
+                
992
+                cells_data.append(cell_data)
951 993
             
952
-            # 更新列偏移
953 994
             col_offset += colspan
954 995
         
955 996
         # 构建行数据
956
-        row_data = {
957
-            'cells': cells_data
958
-        }
959
-        
960
-        # 添加行高
997
+        row_data = {'cells': cells_data}
961 998
         if row_height is not None:
962 999
             row_data['height'] = round(row_height, 2)
963 1000
         
964 1001
         rows_data.append(row_data)
965 1002
     
966
-    # 第二遍:更新 rowspan 值
967
-    for col_idx, info in vmerge_tracking.items():
968
-        start_row = info['start_row']
969
-        count = info['count']
970
-        # 找到起始行的单元格并更新 rowspan
971
-        if start_row < len(rows_data):
972
-            for cell in rows_data[start_row]['cells']:
973
-                # 简化:假设 col_idx 对应 cells 索引(实际可能需要考虑 colspan)
974
-                if 'rowspan' in cell and cell['rowspan'] > 0:
975
-                    cell['rowspan'] = count
976
-                    break
977
-    
978 1003
     return {
979 1004
         'rows': rows_data,
980 1005
         'col_widths': [round(w, 2) for w in col_widths] if col_widths else None

+ 326 - 6
docs/content-sqlite-design.md

@@ -893,7 +893,324 @@ ORDER BY block_order;
893 893
 ```
894 894
 
895 895
 
896
-#### 4.3.7 表格样式示例
896
+#### 4.3.7 合并单元格的提取与导出
897
+
898
+##### Word XML 中的合并单元格标记
899
+
900
+Word 文档使用 XML 标记表示合并单元格:
901
+
902
+**垂直合并(rowspan)标记:**
903
+```xml
904
+<!-- 合并起始单元格 -->
905
+<w:tc>
906
+    <w:tcPr>
907
+        <w:vMerge w:val="restart"/>  <!-- 标记合并开始 -->
908
+    </w:tcPr>
909
+    <w:p><w:r><w:t>井控风险级别划分</w:t></w:r></w:p>
910
+</w:tc>
911
+
912
+<!-- 被合并的单元格(在后续行中)-->
913
+<w:tc>
914
+    <w:tcPr>
915
+        <w:vMerge/>  <!-- 或 <w:vMerge w:val="continue"/> -->
916
+    </w:tcPr>
917
+    <w:p></w:p>  <!-- 内容通常为空 -->
918
+</w:tc>
919
+```
920
+
921
+**水平合并(colspan)标记:**
922
+```xml
923
+<w:tc>
924
+    <w:tcPr>
925
+        <w:gridSpan w:val="3"/>  <!-- 横跨3列 -->
926
+    </w:tcPr>
927
+    <w:p><w:r><w:t>标题</w:t></w:r></w:p>
928
+</w:tc>
929
+<!-- 注意:被横跨的列在 XML 中不存在 -->
930
+```
931
+
932
+**vMerge 属性值说明:**
933
+
934
+| vMerge 值 | 含义 | Python 解析结果 |
935
+|-----------|------|-----------------|
936
+| `w:val="restart"` | 垂直合并的起始单元格 | `v_merge_val = 'restart'` (字符串) |
937
+| `w:val="continue"` | 垂直合并的继续单元格(新格式) | `v_merge_val = 'continue'` (字符串) |
938
+| 无 val 属性(`<w:vMerge/>`)| 垂直合并的继续单元格(旧格式) | `v_merge_val = None` |
939
+
940
+**关键注意事项:**
941
+- ⚠️ `v_merge_val` 可能是字符串 `'continue'`,而不是 `None`
942
+- ⚠️ 被垂直合并的单元格在 XML 中**仍然存在**(只是标记为 vMerge),必须在提取时跳过
943
+- ⚠️ 被水平合并的列在 XML 中**不存在**,不需要特殊处理
944
+
945
+##### 提取阶段:两遍扫描算法
946
+
947
+提取表格时采用**两遍扫描**策略,确保正确识别合并单元格:
948
+
949
+**第一遍:构建 vmerge_map**
950
+
951
+扫描所有单元格的 XML 元素,记录每列的所有垂直合并区间:
952
+
953
+```python
954
+# vmerge_map 数据结构
955
+vmerge_map = {
956
+    0: [  # 列0的合并区间列表
957
+        {'start_row': 1, 'end_row': 12},   # 行1-12 合并
958
+        {'start_row': 15, 'end_row': 15}   # 行15 单独
959
+    ],
960
+    1: [  # 列1的合并区间列表
961
+        {'start_row': 1, 'end_row': 8},    # 行1-8 合并
962
+        {'start_row': 9, 'end_row': 11},   # 行9-11 合并
963
+        {'start_row': 12, 'end_row': 12}   # 行12 单独
964
+    ]
965
+}
966
+```
967
+
968
+**检测逻辑:**
969
+```python
970
+if has_vmerge_restart:
971
+    # 开始新的行合并区间
972
+    merges.append({
973
+        'start_row': row_idx,
974
+        'end_row': row_idx
975
+    })
976
+elif has_vmerge_continue:
977
+    # 扩展当前合并区间(只有明确标记为 continue 才扩展)
978
+    if merges:
979
+        merges[-1]['end_row'] = row_idx
980
+# ⚠️ 不使用 is_empty 判断,避免将空单元格误认为合并
981
+```
982
+
983
+**第二遍:提取单元格数据**
984
+
985
+从 XML 直接提取单元格,根据 vmerge_map 判断是否跳过:
986
+
987
+```python
988
+for row_idx, tr in enumerate(trs):
989
+    for tc in tr.findall(qn('w:tc')):
990
+        # 1. 检查 vMerge 标记
991
+        v_merge_val = tc.find('.//w:vMerge').get(qn('w:val'))
992
+        
993
+        # 2. 跳过被合并的单元格
994
+        if v_merge_val != 'restart':  # 'continue' 或 None
995
+            col_offset += colspan
996
+            continue  # 不提取被合并的单元格
997
+        
998
+        # 3. 计算 rowspan
999
+        rowspan = 1
1000
+        if col_offset in vmerge_map:
1001
+            for merge in vmerge_map[col_offset]:
1002
+                if merge['start_row'] == row_idx:
1003
+                    rowspan = merge['end_row'] - merge['start_row'] + 1
1004
+                    break
1005
+        
1006
+        # 4. 提取单元格数据(从 XML)
1007
+        cell_data = {
1008
+            'text': extract_text_from_xml(tc),
1009
+            'rowspan': rowspan,
1010
+            'colspan': colspan,
1011
+            'col_index': col_offset,  # ← 记录绝对列位置
1012
+            'style': extract_style_from_xml(tc)
1013
+        }
1014
+        
1015
+        cells_data.append(cell_data)
1016
+        col_offset += colspan
1017
+```
1018
+
1019
+**关键字段:`col_index`**
1020
+- `col_index` 记录单元格在表格中的绝对列位置(0-based)
1021
+- 对于导出阶段至关重要,确保单元格被放置到正确的列
1022
+
1023
+##### 导出阶段:列索引映射
1024
+
1025
+导出时使用 `col_index` 字段确保单元格被放置到正确的列位置:
1026
+
1027
+**导出逻辑:**
1028
+```python
1029
+# 1. 构建列索引映射
1030
+cells_by_col = {}
1031
+for cell_data in cells_data:
1032
+    col_idx = cell_data.get('col_index')
1033
+    if col_idx is not None:
1034
+        cells_by_col[col_idx] = cell_data
1035
+
1036
+# 2. 遍历所有列(而非只遍历 cells_data)
1037
+occupied = {}  # 记录被合并单元格占用的位置
1038
+for col_idx in range(num_cols):
1039
+    # 3. 检查位置是否被占用
1040
+    if occupied.get((r_idx, col_idx), False):
1041
+        continue  # 跳过被占用的位置
1042
+    
1043
+    # 4. 检查是否有数据填充
1044
+    if col_idx not in cells_by_col:
1045
+        continue  # 这个位置没有数据
1046
+    
1047
+    cell_data = cells_by_col[col_idx]
1048
+    
1049
+    # 5. 使用绝对列位置访问单元格
1050
+    start_cell = table.rows[r_idx].cells[col_idx]
1051
+    
1052
+    # 6. 处理合并
1053
+    if colspan > 1 or rowspan > 1:
1054
+        end_row = r_idx + rowspan - 1
1055
+        end_col = col_idx + colspan - 1
1056
+        end_cell = table.rows[end_row].cells[end_col]
1057
+        start_cell.merge(end_cell)
1058
+        
1059
+        # 7. 标记被合并的位置为已占用
1060
+        for merge_r in range(r_idx, end_row + 1):
1061
+            for merge_c in range(col_idx, end_col + 1):
1062
+                if merge_r != r_idx or merge_c != col_idx:
1063
+                    occupied[(merge_r, merge_c)] = True
1064
+```
1065
+
1066
+**关键改进:**
1067
+- ✓ 使用 `cells_by_col` 映射,避免按顺序迭代
1068
+- ✓ 遍历所有列(0 到 num_cols-1),而非只遍历提取的单元格
1069
+- ✓ 使用 `occupied` 字典跟踪被合并占用的位置
1070
+- ✓ 使用 `col_index` 而非 `col_offset`,确保位置正确
1071
+
1072
+##### 合并单元格数据示例
1073
+
1074
+**表格效果:**
1075
+```
1076
+┌──────────────┬─────────┬──────────┬──────┬──────┐
1077
+│井控风险级别  │  一级   │ 预测地层 │      │      │
1078
+│划分          │         │ 压力     │      │      │
1079
+│(12行合并)    │(8行合并)│≥105MPa  │      │      │
1080
+├──────────────┼─────────┼──────────┼──────┼──────┤
1081
+│              │         │ 预测硫化 │      │      │
1082
+│              │         │ 氢含量   │      │      │
1083
+│              ├─────────┼──────────┼──────┼──────┤
1084
+│              │  二级   │ ...      │      │      │
1085
+│              │(3行合并)│          │      │      │
1086
+└──────────────┴─────────┴──────────┴──────┴──────┘
1087
+```
1088
+
1089
+**提取结果(JSON 数据):**
1090
+
1091
+```json
1092
+{
1093
+  "rows": [
1094
+    {
1095
+      "cells": [
1096
+        {
1097
+          "col_index": 0,
1098
+          "rowspan": 12,
1099
+          "colspan": 1,
1100
+          "text": "井控风险级别划分",
1101
+          "style": {}
1102
+        },
1103
+        {
1104
+          "col_index": 1,
1105
+          "rowspan": 8,
1106
+          "colspan": 1,
1107
+          "text": "一级",
1108
+          "style": {}
1109
+        },
1110
+        {
1111
+          "col_index": 2,
1112
+          "rowspan": 1,
1113
+          "colspan": 3,
1114
+          "text": "预测地层压力≥105MPa",
1115
+          "style": {}
1116
+        },
1117
+        {
1118
+          "col_index": 5,
1119
+          "rowspan": 1,
1120
+          "colspan": 1,
1121
+          "text": " ",
1122
+          "style": {}
1123
+        },
1124
+        {
1125
+          "col_index": 6,
1126
+          "rowspan": 1,
1127
+          "colspan": 1,
1128
+          "text": " ",
1129
+          "style": {}
1130
+        }
1131
+      ]
1132
+    },
1133
+    {
1134
+      "cells": [
1135
+        // 注意:列0和列1被上一行占用,不在此行定义
1136
+        {
1137
+          "col_index": 2,
1138
+          "rowspan": 1,
1139
+          "colspan": 3,
1140
+          "text": "预测硫化氢含量...",
1141
+          "style": {}
1142
+        },
1143
+        {
1144
+          "col_index": 5,
1145
+          "rowspan": 1,
1146
+          "colspan": 1,
1147
+          "text": " ",
1148
+          "style": {}
1149
+        },
1150
+        {
1151
+          "col_index": 6,
1152
+          "rowspan": 1,
1153
+          "colspan": 1,
1154
+          "text": " ",
1155
+          "style": {}
1156
+        }
1157
+      ]
1158
+    },
1159
+    {
1160
+      "cells": [
1161
+        // 列0仍被第1行占用,列1在第9行才重新开始
1162
+        {
1163
+          "col_index": 1,
1164
+          "rowspan": 3,
1165
+          "colspan": 1,
1166
+          "text": "二级",
1167
+          "style": {}
1168
+        },
1169
+        // ...
1170
+      ]
1171
+    }
1172
+  ]
1173
+}
1174
+```
1175
+
1176
+**关键特征:**
1177
+- ✓ 被合并的单元格**不出现**在数据中
1178
+- ✓ 每个单元格都有 `col_index` 字段记录绝对列位置
1179
+- ✓ `rowspan` 表示垂直跨越的行数(从 vmerge_map 计算)
1180
+- ✓ `colspan` 表示水平跨越的列数(从 gridSpan 提取)
1181
+
1182
+##### 技术要点总结
1183
+
1184
+**提取阶段关键点:**
1185
+1. ⚠️ **不要依赖 python-docx 的 `row.cells`**:对于合并单元格,它会在多行返回同一个对象
1186
+2. ✓ **完全从 XML 提取**:直接解析 `<w:tc>` 元素
1187
+3. ✓ **两遍扫描**:第一遍构建 vmerge_map,第二遍提取数据
1188
+4. ✓ **跳过 vMerge=continue**:只提取合并起始单元格(restart)
1189
+5. ✓ **记录 col_index**:为每个单元格记录绝对列位置
1190
+
1191
+**导出阶段关键点:**
1192
+1. ✓ **使用 col_index 映射**:不按顺序迭代 cells_data
1193
+2. ✓ **遍历所有列**:从 0 到 num_cols-1,而非只遍历单元格数据
1194
+3. ✓ **跟踪 occupied 位置**:记录哪些位置被合并单元格占用
1195
+4. ✓ **使用绝对列索引**:`table.rows[r_idx].cells[col_idx]`
1196
+5. ✓ **标记合并占用**:合并后标记所有涉及的位置为已占用
1197
+
1198
+**常见错误与修复:**
1199
+
1200
+| 错误 | 现象 | 原因 | 修复 |
1201
+|------|------|------|------|
1202
+| **错误合并** | 空单元格被合并 | 使用 `is_empty` 判断扩展合并 | 只在明确标记 `vMerge=continue` 时扩展 |
1203
+| **重复单元格** | 被合并的单元格也被提取 | 没有跳过 `vMerge=continue` 单元格 | 检测并跳过 |
1204
+| **位置错误** | 单元格在错误的列 | 按顺序迭代而非使用 col_index | 使用 `cells_by_col` 映射 + 遍历所有列 |
1205
+| **应该合并的不合并** | rowspan 应该 >1 但为 1 | 第一遍扫描的判断逻辑不一致 | 统一使用 `v_merge_val != 'restart'` |
1206
+
1207
+##### 相关文档
1208
+
1209
+- `TABLE_MERGE_FIX_SUMMARY.md` - 提取阶段修复详细说明
1210
+- `TABLE_EXPORT_FIX_SUMMARY.md` - 导出阶段修复详细说明
1211
+- `MERGE_CELL_COMPLETE_FIX.md` - 完整修复总结
1212
+
1213
+#### 4.3.8 表格样式示例
897 1214
 
898 1215
 **示例 1:基础表格(无合并)**
899 1216
 ```json
@@ -944,6 +1261,7 @@ ORDER BY block_order;
944 1261
           "text": "标题", 
945 1262
           "rowspan": 1, 
946 1263
           "colspan": 3,       // ← 横跨3列
1264
+          "col_index": 0,
947 1265
           "style": {
948 1266
             "bold": true,
949 1267
             "align": "center",
@@ -957,20 +1275,21 @@ ORDER BY block_order;
957 1275
         {
958 1276
           "text": "项目A", 
959 1277
           "rowspan": 2,       // ← 纵跨2行
960
-          "colspan": 1, 
1278
+          "colspan": 1,
1279
+          "col_index": 0,
961 1280
           "style": {
962 1281
             "valign": "middle"
963 1282
           }
964 1283
         },
965
-        {"text": "子项1", "rowspan": 1, "colspan": 1, "style": {}},
966
-        {"text": "100", "rowspan": 1, "colspan": 1, "style": {}}
1284
+        {"text": "子项1", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {}},
1285
+        {"text": "100", "rowspan": 1, "colspan": 1, "col_index": 2, "style": {}}
967 1286
       ]
968 1287
     },
969 1288
     {
970 1289
       "cells": [
971 1290
         // 注意:第1个单元格被上一行的"项目A"占据,所以这行只有2个单元格
972
-        {"text": "子项2", "rowspan": 1, "colspan": 1, "style": {}},
973
-        {"text": "200", "rowspan": 1, "colspan": 1, "style": {}}
1291
+        {"text": "子项2", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {}},
1292
+        {"text": "200", "rowspan": 1, "colspan": 1, "col_index": 2, "style": {}}
974 1293
       ]
975 1294
     }
976 1295
   ]
@@ -980,6 +1299,7 @@ ORDER BY block_order;
980 1299
 **关键点:**
981 1300
 - `colspan`: 横向合并,值为合并的列数
982 1301
 - `rowspan`: 纵向合并,值为合并的行数
1302
+- `col_index`: 单元格在表格中的绝对列位置(0-based)
983 1303
 - 被合并占据的单元格**不需要**在数据中定义
984 1304
 - 例如:第3行只定义2个单元格,因为第1个位置被"项目A"占据
985 1305