Kaynağa Gözat

feat(export): Word 文档样式和表格渲染功能

- 添加 eastAsia 字体属性支持,确保中文字符在 run 级样式中正确显示
- 实现 _update_normal_style_if_needed() 函数,自动检测 blocks 中最常用的字体和字号,并应用到 Normal 样式,确保空行样式一致性
- 增强表格渲染功能,支持合并单元格(rowspan/colspan)、列宽、行高和自定义表格样式
- 优化段落渲染逻辑,通过添加带继承样式的空 run 来保留空内容的 block 级样式(字体、字号)
- 更新 _render_table_block() 函数文档,反映新增的表格格式化功能
- 添加列索引偏移跟踪机制,正确处理包含 colspan 的表格单元格填充
- 实现 merge_map 跟踪,防止合并单元格中的重复内容
- 优化 _render_paragraph_block() 中的注释,明确空内容的样式继承逻辑
chensiyu 1 ay önce
ebeveyn
işleme
b150bbd27c
4 değiştirilmiş dosya ile 886 ekleme ve 67 silme
  1. 187 17
      app/services/export_service.py
  2. 538 44
      app/services/word_parser.py
  3. BIN
      tmp/default.docx
  4. 161 6
      tmp/default.json

+ 187 - 17
app/services/export_service.py

@@ -171,9 +171,19 @@ def _apply_run_style(run, style: dict):
171 171
         except (ValueError, AttributeError):
172 172
             pass
173 173
     
174
-    # 字体名称
174
+    # 字体名称(支持中文字体 eastAsia)
175 175
     if style.get('font_name'):
176
-        run.font.name = style['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
177 187
     
178 188
     # 字号
179 189
     if style.get('font_size'):
@@ -198,6 +208,10 @@ def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict)
198 208
     doc = Document()
199 209
     inject_styles_from_json(doc, style_data)
200 210
     
211
+    # 检查 blocks 中是否有常用的字体和字号,用于修改 Normal 样式
212
+    # 这样可以确保空行在 Word 中显示正确的字体
213
+    _update_normal_style_if_needed(doc, blocks)
214
+    
201 215
     for block in blocks:
202 216
         block_type = block['type']
203 217
         
@@ -220,6 +234,90 @@ def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict)
220 234
     return docx_bytes
221 235
 
222 236
 
237
+def _update_normal_style_if_needed(doc: Document, blocks: list[dict]):
238
+    """更新 Normal 样式以匹配 blocks 中最常用的字体
239
+    
240
+    这样可以确保空行在 Word 中显示正确的字体和字号
241
+    """
242
+    # 统计段落中最常用的字体和字号
243
+    font_counts = {}
244
+    size_counts = {}
245
+    
246
+    for block in blocks:
247
+        if block['type'] == 'paragraph':
248
+            style = block.get('style', {})
249
+            font_name = style.get('font_name')
250
+            font_size = style.get('font_size')
251
+            
252
+            if font_name:
253
+                font_counts[font_name] = font_counts.get(font_name, 0) + 1
254
+            if font_size:
255
+                size_counts[font_size] = size_counts.get(font_size, 0) + 1
256
+    
257
+    # 找到最常用的字体和字号
258
+    most_common_font = max(font_counts.items(), key=lambda x: x[1])[0] if font_counts else None
259
+    most_common_size = max(size_counts.items(), key=lambda x: x[1])[0] if size_counts else None
260
+    
261
+    # 如果找到了常用字体或字号,更新 Normal 样式
262
+    if most_common_font or most_common_size:
263
+        try:
264
+            normal_style = doc.styles['Normal']
265
+            
266
+            if most_common_font:
267
+                # 修改 Normal 样式的字体
268
+                style_element = normal_style.element
269
+                rPr = style_element.find(qn('w:rPr'))
270
+                if rPr is None:
271
+                    rPr = OxmlElement('w:rPr')
272
+                    # 插入到第一个子元素之前
273
+                    if len(style_element):
274
+                        style_element.insert(0, rPr)
275
+                    else:
276
+                        style_element.append(rPr)
277
+                
278
+                rFonts = rPr.find(qn('w:rFonts'))
279
+                if rFonts is None:
280
+                    rFonts = OxmlElement('w:rFonts')
281
+                    rPr.append(rFonts)
282
+                
283
+                # 设置所有字体属性
284
+                rFonts.set(qn('w:ascii'), most_common_font)
285
+                rFonts.set(qn('w:hAnsi'), most_common_font)
286
+                rFonts.set(qn('w:eastAsia'), most_common_font)
287
+            
288
+            if most_common_size:
289
+                # 修改 Normal 样式的字号
290
+                style_element = normal_style.element
291
+                rPr = style_element.find(qn('w:rPr'))
292
+                if rPr is None:
293
+                    rPr = OxmlElement('w:rPr')
294
+                    if len(style_element):
295
+                        style_element.insert(0, rPr)
296
+                    else:
297
+                        style_element.append(rPr)
298
+                
299
+                # 删除旧的字号元素
300
+                old_sz = rPr.find(qn('w:sz'))
301
+                if old_sz is not None:
302
+                    rPr.remove(old_sz)
303
+                old_szCs = rPr.find(qn('w:szCs'))
304
+                if old_szCs is not None:
305
+                    rPr.remove(old_szCs)
306
+                
307
+                # 添加新的字号元素
308
+                sz = OxmlElement('w:sz')
309
+                sz.set(qn('w:val'), str(int(most_common_size * 2)))  # Word 使用半磅
310
+                rPr.append(sz)
311
+                
312
+                szCs = OxmlElement('w:szCs')
313
+                szCs.set(qn('w:val'), str(int(most_common_size * 2)))
314
+                rPr.append(szCs)
315
+                
316
+        except Exception:
317
+            # 如果修改样式失败,继续(不影响文档生成)
318
+            pass
319
+
320
+
223 321
 def _inject_numbering_via_zip(docx_bytes: bytes, style_data: dict) -> bytes:
224 322
     """通过 ZIP 操作注入编号格式到 Word 文档
225 323
     
@@ -390,8 +488,9 @@ def _render_paragraph_block(doc: Document, block: dict, style_map: dict):
390 488
         # 富文本:应用 run 级样式
391 489
         _render_rich_text(para, content, block_style)
392 490
     else:
393
-        # 纯文本:始终添加 run(即使是空内容)
394
-        # 空行也需要 run 来继承样式,保持正确的上下间距
491
+        # 纯文本或空内容
492
+        # 即使是空内容,如果有 block 级样式(字体、字号等),也需要添加空 run 来保存样式
493
+        # 这样当用户在 Word 中输入文本时,会自动应用这些样式
395 494
         run = para.add_run(str(content) if content else '')
396 495
         _apply_run_style(run, block_style)
397 496
 
@@ -421,7 +520,7 @@ def _render_rich_text(para, segments: list, block_style: dict = None):
421 520
 
422 521
 
423 522
 def _render_table_block(doc: Document, block: dict, style_map: dict):
424
-    """渲染表格块(支持单元格样式)"""
523
+    """渲染表格块(支持合并单元格、列宽、行高等)"""
425 524
     table_data = block['content']
426 525
     if isinstance(table_data, str):
427 526
         try:
@@ -433,31 +532,99 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
433 532
     if not rows:
434 533
         return
435 534
     
436
-    # 计算表格大小
437
-    num_rows = len(rows)
438
-    num_cols = len(rows[0]['cells']) if rows else 0
535
+    # 使用 col_widths 确定真实列数(而不是第一行的单元格数)
536
+    col_widths = table_data.get('col_widths', [])
537
+    if col_widths:
538
+        num_cols = len(col_widths)
539
+    else:
540
+        # 回退:扫描所有行,找到最大的列索引
541
+        num_cols = 0
542
+        for row_data in rows:
543
+            col_index = 0
544
+            for cell_data in row_data.get('cells', []):
545
+                colspan = cell_data.get('colspan', 1)
546
+                col_index += colspan
547
+            num_cols = max(num_cols, col_index)
548
+        
549
+        if num_cols == 0:
550
+            return
439 551
     
440
-    if num_cols == 0:
441
-        return
552
+    num_rows = len(rows)
442 553
     
443 554
     # 创建表格
444 555
     table = doc.add_table(rows=num_rows, cols=num_cols)
445
-    table.style = 'Table Grid'
446 556
     
447
-    # 填充内容
557
+    # 应用表格样式
558
+    table_style = block.get('word_style', 'Table Grid')
559
+    try:
560
+        table.style = table_style
561
+    except KeyError:
562
+        table.style = 'Table Grid'
563
+    
564
+    # 设置列宽
565
+    if col_widths:
566
+        for col_idx, width in enumerate(col_widths):
567
+            if col_idx < len(table.columns):
568
+                table.columns[col_idx].width = Pt(width)
569
+    
570
+    # 填充内容并处理合并单元格
571
+    merge_map = {}  # {(row, col): (end_row, end_col)} 记录合并区域
572
+    
448 573
     for r_idx, row_data in enumerate(rows):
574
+        # 设置行高
575
+        row_height = row_data.get('height')
576
+        if row_height:
577
+            table.rows[r_idx].height = Pt(row_height)
578
+        
449 579
         cells_data = row_data.get('cells', [])
450
-        for c_idx, cell_data in enumerate(cells_data):
451
-            if c_idx >= num_cols:
580
+        col_offset = 0  # 当前列偏移(考虑 colspan)
581
+        
582
+        for cell_data in cells_data:
583
+            # 跳过被合并的单元格(rowspan=0 表示这个单元格被上面的单元格合并了)
584
+            rowspan = cell_data.get('rowspan', 1)
585
+            if rowspan == 0:
586
+                col_offset += 1
587
+                continue
588
+            
589
+            colspan = cell_data.get('colspan', 1)
590
+            
591
+            # 确保不越界
592
+            if col_offset >= num_cols:
452 593
                 break
453 594
             
454
-            cell = table.rows[r_idx].cells[c_idx]
595
+            # 获取起始单元格
596
+            start_cell = table.rows[r_idx].cells[col_offset]
597
+            
598
+            # 处理合并单元格
599
+            if colspan > 1 or rowspan > 1:
600
+                # 计算结束位置
601
+                end_col = min(col_offset + colspan - 1, num_cols - 1)
602
+                end_row = min(r_idx + rowspan - 1, num_rows - 1)
603
+                
604
+                # 合并单元格
605
+                if end_col > col_offset or end_row > r_idx:
606
+                    try:
607
+                        end_cell = table.rows[end_row].cells[end_col]
608
+                        start_cell.merge(end_cell)
609
+                        merge_map[(r_idx, col_offset)] = (end_row, end_col)
610
+                    except Exception:
611
+                        pass  # 合并失败,继续
612
+            
613
+            # 设置单元格宽度(如果有)
614
+            cell_width = cell_data.get('width')
615
+            if cell_width:
616
+                try:
617
+                    start_cell.width = Pt(cell_width)
618
+                except Exception:
619
+                    pass
620
+            
621
+            # 填充单元格内容
455 622
             cell_text = cell_data.get('text', '')
456 623
             cell_style = cell_data.get('style', {})
457 624
             
458 625
             # 清空默认段落
459
-            cell.text = ''
460
-            para = cell.paragraphs[0]
626
+            start_cell.text = ''
627
+            para = start_cell.paragraphs[0]
461 628
             
462 629
             # 应用单元格段落级样式(对齐)
463 630
             _apply_paragraph_style(para, cell_style)
@@ -471,6 +638,9 @@ def _render_table_block(doc: Document, block: dict, style_map: dict):
471 638
                 run = para.add_run(str(cell_text))
472 639
                 # 应用单元格 run 级样式
473 640
                 _apply_run_style(run, cell_style)
641
+            
642
+            # 更新列偏移
643
+            col_offset += colspan
474 644
 
475 645
 
476 646
 def _render_image_block(doc: Document, block: dict):

+ 538 - 44
app/services/word_parser.py

@@ -4,9 +4,273 @@ import base64
4 4
 import io
5 5
 from pathlib import Path
6 6
 from typing import Optional
7
+import zipfile
7 8
 
8 9
 from docx import Document as DocxDocument
9 10
 from docx.oxml.ns import qn
11
+from lxml import etree
12
+
13
+
14
+# 全局缓存:主题字体映射
15
+_theme_fonts_cache = {}
16
+# 当前文档的主题字体(用于在解析过程中传递)
17
+_current_theme_fonts = {}
18
+
19
+
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
+    """
29
+    # 检查缓存
30
+    cache_key = str(docx_path)
31
+    if cache_key in _theme_fonts_cache:
32
+        return _theme_fonts_cache[cache_key]
33
+    
34
+    theme_fonts = {}
35
+    
36
+    try:
37
+        with zipfile.ZipFile(docx_path, 'r') as docx_zip:
38
+            # 查找主题文件
39
+            theme_files = [name for name in docx_zip.namelist() 
40
+                          if 'theme' in name.lower() and name.endswith('.xml')]
41
+            
42
+            if not theme_files:
43
+                return theme_fonts
44
+            
45
+            # 读取主题 XML
46
+            theme_xml = docx_zip.read(theme_files[0])
47
+            root = etree.fromstring(theme_xml)
48
+            
49
+            # 命名空间
50
+            ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
51
+            
52
+            # 解析 majorFont(标题字体)
53
+            major_font = root.find('.//a:majorFont', ns)
54
+            if major_font is not None:
55
+                ea = major_font.find('.//a:ea', ns)
56
+                if ea is not None and ea.get('typeface'):
57
+                    theme_fonts['majorEastAsia'] = ea.get('typeface')
58
+                # 回退到简体中文
59
+                hans = major_font.find('.//a:font[@script="Hans"]', ns)
60
+                if hans is not None and hans.get('typeface'):
61
+                    if 'majorEastAsia' not in theme_fonts:
62
+                        theme_fonts['majorEastAsia'] = hans.get('typeface')
63
+            
64
+            # 解析 minorFont(正文字体)
65
+            minor_font = root.find('.//a:minorFont', ns)
66
+            if minor_font is not None:
67
+                ea = minor_font.find('.//a:ea', ns)
68
+                if ea is not None and ea.get('typeface'):
69
+                    theme_fonts['minorEastAsia'] = ea.get('typeface')
70
+                # 回退到简体中文
71
+                hans = minor_font.find('.//a:font[@script="Hans"]', ns)
72
+                if hans is not None and hans.get('typeface'):
73
+                    if 'minorEastAsia' not in theme_fonts:
74
+                        theme_fonts['minorEastAsia'] = hans.get('typeface')
75
+    
76
+    except Exception:
77
+        # 如果读取失败,返回空字典
78
+        pass
79
+    
80
+    # 缓存结果
81
+    _theme_fonts_cache[cache_key] = theme_fonts
82
+    return theme_fonts
83
+
84
+
85
+def _get_eastasia_font_from_element(element):
86
+    """从 XML 元素中提取 eastAsia 字体(用于中文字体)
87
+    
88
+    Args:
89
+        element: rPr XML 元素
90
+        
91
+    Returns:
92
+        eastAsia 字体名称或 None
93
+    """
94
+    if element is None:
95
+        return None
96
+    rFonts = element.find(qn('w:rFonts'))
97
+    if rFonts is not None:
98
+        east_asia = rFonts.get(qn('w:eastAsia'))
99
+        if east_asia:
100
+            return east_asia
101
+    return None
102
+
103
+
104
+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
+    """
118
+    if theme_fonts is None:
119
+        theme_fonts = _current_theme_fonts
120
+    
121
+    # 1. 尝试从 XML 读取字体
122
+    if hasattr(run._element, 'rPr'):
123
+        rPr = run._element.rPr
124
+        if rPr is not None:
125
+            rFonts = rPr.find(qn('w:rFonts'))
126
+            if rFonts is not None:
127
+                # 1a. 优先 eastAsia(中文字体)
128
+                east_asia = rFonts.get(qn('w:eastAsia'))
129
+                if east_asia:
130
+                    return east_asia
131
+                
132
+                # 1b. 主题字体引用
133
+                if theme_fonts:
134
+                    east_asia_theme = rFonts.get(qn('w:eastAsiaTheme'))
135
+                    if east_asia_theme and east_asia_theme in theme_fonts:
136
+                        return theme_fonts[east_asia_theme]
137
+                
138
+                # 1c. 如果只定义了 ascii/hAnsi,没有 eastAsia
139
+                # 返回 None 让其从样式继承中文字体
140
+                # 这样可以正确处理 Heading 2 等情况
141
+                ascii_font = rFonts.get(qn('w:ascii'))
142
+                hAnsi_font = rFonts.get(qn('w:hAnsi'))
143
+                if ascii_font or hAnsi_font:
144
+                    # 有西文字体但没有中文字体,返回 None
145
+                    # 让 _extract_paragraph_format 从样式提取
146
+                    return None
147
+    
148
+    # 2. 回退到标准 API(ascii 字体)
149
+    if run.font.name:
150
+        return run.font.name
151
+    
152
+    return None
153
+
154
+
155
+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
+    """
166
+    try:
167
+        style = para.style
168
+        if hasattr(style, 'element'):
169
+            rPr = style.element.find(qn('w:rPr'))
170
+            if rPr is not None:
171
+                rFonts = rPr.find(qn('w:rFonts'))
172
+                if rFonts is not None:
173
+                    # 优先 eastAsia(中文字体)
174
+                    east_asia = rFonts.get(qn('w:eastAsia'))
175
+                    if east_asia:
176
+                        return east_asia
177
+            
178
+            # 如果当前样式没有 eastAsia,查找基础样式的 eastAsia
179
+            # 这样可以正确处理 Heading 2 等只定义 ascii 但基于 Normal 的样式
180
+            if hasattr(style, 'base_style') and style.base_style:
181
+                base_font = _get_paragraph_style_font_recursive(style.base_style)
182
+                if base_font:
183
+                    return base_font
184
+            
185
+            # 如果没有 eastAsia,回退到 ascii/hAnsi
186
+            if rPr is not None:
187
+                rFonts = rPr.find(qn('w:rFonts'))
188
+                if rFonts is not None:
189
+                    # 其次 ascii
190
+                    ascii_font = rFonts.get(qn('w:ascii'))
191
+                    if ascii_font:
192
+                        return ascii_font
193
+                    # 最后 hAnsi
194
+                    hAnsi = rFonts.get(qn('w:hAnsi'))
195
+                    if hAnsi:
196
+                        return hAnsi
197
+    except Exception:
198
+        pass
199
+    
200
+    return None
201
+
202
+
203
+def _get_paragraph_style_font_recursive(style):
204
+    """递归查找样式的 eastAsia 字体(用于基础样式查找)
205
+    
206
+    Args:
207
+        style: python-docx Style 对象
208
+        
209
+    Returns:
210
+        eastAsia 字体名称或 None
211
+    """
212
+    try:
213
+        if hasattr(style, 'element'):
214
+            rPr = style.element.find(qn('w:rPr'))
215
+            if rPr is not None:
216
+                rFonts = rPr.find(qn('w:rFonts'))
217
+                if rFonts is not None:
218
+                    east_asia = rFonts.get(qn('w:eastAsia'))
219
+                    if east_asia:
220
+                        return east_asia
221
+            
222
+            # 继续查找基础样式
223
+            if hasattr(style, 'base_style') and style.base_style:
224
+                return _get_paragraph_style_font_recursive(style.base_style)
225
+    except Exception:
226
+        pass
227
+    
228
+    return None
229
+
230
+
231
+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
+    """
240
+    formatting = {}
241
+    
242
+    if not style or not hasattr(style, 'element'):
243
+        return formatting
244
+    
245
+    try:
246
+        rPr = style.element.find(qn('w:rPr'))
247
+        if rPr is not None:
248
+            # 加粗
249
+            bold_elem = rPr.find(qn('w:b'))
250
+            if bold_elem is not None:
251
+                bold_val = bold_elem.get(qn('w:val'))
252
+                # w:val 为 None、'1' 或 'true' 表示加粗
253
+                if bold_val is None or bold_val in ('1', 'true'):
254
+                    formatting['bold'] = True
255
+            
256
+            # 斜体
257
+            italic_elem = rPr.find(qn('w:i'))
258
+            if italic_elem is not None:
259
+                italic_val = italic_elem.get(qn('w:val'))
260
+                if italic_val is None or italic_val in ('1', 'true'):
261
+                    formatting['italic'] = True
262
+            
263
+            # 下划线
264
+            underline_elem = rPr.find(qn('w:u'))
265
+            if underline_elem is not None:
266
+                underline_val = underline_elem.get(qn('w:val'))
267
+                # 下划线有多种类型,只要存在就算有下划线
268
+                if underline_val and underline_val != 'none':
269
+                    formatting['underline'] = True
270
+    except Exception:
271
+        pass
272
+    
273
+    return formatting
10 274
 
11 275
 
12 276
 def parse_word_to_blocks(docx_path: Path) -> list[dict]:
@@ -18,10 +282,15 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
18 282
     Returns:
19 283
         Block 列表,每个 Block 包含 id, block_order, type, level, index, content 等字段
20 284
     """
285
+    global _current_theme_fonts
286
+    
21 287
     doc = DocxDocument(str(docx_path))
22 288
     blocks = []
23 289
     block_order = 0
24 290
     
291
+    # 加载主题字体并设置为当前主题
292
+    _current_theme_fonts = _load_theme_fonts(docx_path)
293
+    
25 294
     # 标题计数器(按 level 分别计数)
26 295
     heading_counters = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}
27 296
     # 其他类型的全局计数器
@@ -77,6 +346,14 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
77 346
             level = _identify_heading_level(para, style_name)
78 347
             
79 348
             if level:
349
+                # 提取内容(支持富文本)
350
+                content = _extract_rich_text(para)
351
+                
352
+                # 跳过空标题(没有内容的标题)
353
+                if not content:
354
+                    # 空标题不添加到 blocks,继续下一个段落
355
+                    continue
356
+                
80 357
                 # 标题块
81 358
                 index = heading_counters[level] * 100  # 稀疏排序:0, 100, 200...
82 359
                 heading_counters[level] += 1
@@ -90,9 +367,6 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
90 367
                 
91 368
                 parent_id = parent_stack[-1]['id'] if parent_stack else None
92 369
                 
93
-                # 提取内容(支持富文本)
94
-                content = _extract_rich_text(para)
95
-                
96 370
                 # 提取段落级样式
97 371
                 para_style = _extract_paragraph_format(para)
98 372
                 
@@ -147,6 +421,9 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
147 421
                     index = type_counters['paragraph'] * 100
148 422
                     type_counters['paragraph'] += 1
149 423
                     
424
+                    # 如果是富文本数组,Block 样式为空;如果是纯文本,Block 有样式
425
+                    block_style = {} if isinstance(content, list) else para_style
426
+                    
150 427
                     block = {
151 428
                         'id': f'block-p-{index}',
152 429
                         'block_order': block_order * 100,
@@ -155,7 +432,7 @@ def parse_word_to_blocks(docx_path: Path) -> list[dict]:
155 432
                         'index': index,
156 433
                         'content': content,
157 434
                         'word_style': style_name,
158
-                        'style': para_style,  # 颗粒度样式
435
+                        'style': block_style,
159 436
                         'metadata': {
160 437
                             'parent_heading_id': parent_id
161 438
                         }
@@ -301,20 +578,36 @@ def _extract_paragraph_format(para) -> dict:
301 578
     if para.runs:
302 579
         first_run = para.runs[0]
303 580
         
304
-        # 检查是否整段使用相同字体
305
-        if first_run.font.name:
306
-            all_same_font = all(
307
-                run.font.name == first_run.font.name 
581
+        # 检查是否整段使用相同字体(支持 eastAsia,忽略 None 值)
582
+        first_font = _get_font_name(first_run)
583
+        
584
+        # 如果所有 runs 都没有字体设置(都是 None),从段落样式提取
585
+        if first_font is None:
586
+            # 检查是否所有 runs 都没有字体
587
+            all_none = all(
588
+                _get_font_name(run) is None 
308 589
                 for run in para.runs if run.text
309 590
             )
591
+            if all_none:
592
+                # 从段落样式提取字体
593
+                style_font = _get_paragraph_style_font(para)
594
+                if style_font:
595
+                    style['font_name'] = style_font
596
+        elif first_font:
597
+            # 如果第一个 run 有字体,检查是否整段统一
598
+            all_same_font = all(
599
+                _get_font_name(run) == first_font 
600
+                for run in para.runs if run.text and _get_font_name(run) is not None
601
+            )
310 602
             if all_same_font:
311
-                style['font_name'] = first_run.font.name
603
+                style['font_name'] = first_font
312 604
         
313
-        # 检查是否整段使用相同字号
605
+        # 检查是否整段使用相同字号(忽略 None 值)
314 606
         if first_run.font.size:
607
+            # 只比较有字号的 runs
315 608
             all_same_size = all(
316 609
                 run.font.size == first_run.font.size 
317
-                for run in para.runs if run.text
610
+                for run in para.runs if run.text and run.font.size is not None
318 611
             )
319 612
             if all_same_size:
320 613
                 style['font_size'] = first_run.font.size.pt
@@ -346,6 +639,18 @@ def _extract_paragraph_format(para) -> dict:
346 639
             )
347 640
             if all_same_color:
348 641
                 style['color'] = first_color
642
+    else:
643
+        # 空段落(没有 runs):从段落样式中提取默认字体和字号
644
+        style_font = _get_paragraph_style_font(para)
645
+        if style_font:
646
+            style['font_name'] = style_font
647
+        
648
+        # 尝试从段落样式中提取字号
649
+        try:
650
+            if hasattr(para.style, 'font') and para.style.font.size:
651
+                style['font_size'] = para.style.font.size.pt
652
+        except Exception:
653
+            pass
349 654
     
350 655
     return style
351 656
 
@@ -358,49 +663,98 @@ def _extract_rich_text(para) -> str | list:
358 663
         
359 664
     Returns:
360 665
         纯文本字符串 或 富文本片段列表
666
+        - 纯文本:所有 runs 样式相同,返回字符串
667
+        - 富文本:runs 样式不同,返回数组,每个元素包含完整样式
361 668
     """
362 669
     text = para.text.strip()
363 670
     if not text:
364 671
         return ""
365 672
     
366
-    # 检查是否包含多种格式
367
-    has_format = False
368
-    for run in para.runs:
369
-        if run.text and (run.bold or run.italic or run.font.strike or run.underline or
370
-                        (run.font.name) or (run.font.color and run.font.color.rgb)):
371
-            has_format = True
372
-            break
673
+    # 没有 runs 或只有一个 run,返回纯文本
674
+    if not para.runs or len(para.runs) == 0:
675
+        return text
373 676
     
374
-    if not has_format:
375
-        # 简单文本
677
+    # 提取所有 runs 的样式(用于判断是否统一)
678
+    valid_runs = [run for run in para.runs if run.text]
679
+    if len(valid_runs) <= 1:
376 680
         return text
377 681
     
378
-    # 富文本格式(JSON 数组)
682
+    # 检查所有 runs 的样式是否完全相同
683
+    def get_run_style_signature(run):
684
+        """获取 run 的样式签名,用于比较"""
685
+        return (
686
+            _get_font_name(run),
687
+            run.font.size.pt if run.font.size else None,
688
+            run.bold,
689
+            run.italic,
690
+            run.underline,
691
+            run.font.strike,
692
+            str(run.font.color.rgb) if run.font.color and run.font.color.rgb else None
693
+        )
694
+    
695
+    first_sig = get_run_style_signature(valid_runs[0])
696
+    all_same = all(get_run_style_signature(run) == first_sig for run in valid_runs)
697
+    
698
+    if all_same:
699
+        # 所有 runs 样式相同,返回纯文本
700
+        return text
701
+    
702
+    # 样式不同,返回富文本数组
703
+    # 每个 run 包含完整样式和 word_style
379 704
     segments = []
380 705
     for run in para.runs:
381 706
         if not run.text:
382 707
             continue
383 708
         
384 709
         style = {}
710
+        
711
+        # 字体
712
+        font_name = _get_font_name(run)
713
+        if font_name:
714
+            style['font_name'] = font_name
715
+        
716
+        # 字号
717
+        if run.font.size:
718
+            style['font_size'] = run.font.size.pt
719
+        
720
+        # 加粗
385 721
         if run.bold:
386 722
             style['bold'] = True
723
+        
724
+        # 斜体
387 725
         if run.italic:
388 726
             style['italic'] = True
727
+        
728
+        # 删除线
389 729
         if run.font.strike:
390 730
             style['strike'] = True
731
+        
732
+        # 下划线
391 733
         if run.underline:
392 734
             style['underline'] = True
393
-        if run.font.name:
394
-            style['font_name'] = run.font.name
395
-        if run.font.size:
396
-            style['font_size'] = run.font.size.pt
735
+        
736
+        # 颜色
397 737
         if run.font.color and run.font.color.rgb:
398 738
             style['color'] = str(run.font.color.rgb)
399 739
         
400
-        segments.append({
740
+        # 提取 word_style(字符样式或段落样式)
741
+        word_style = None
742
+        if run.style:
743
+            word_style = run.style.name
744
+        else:
745
+            # run 没有独立样式,使用段落样式
746
+            word_style = para.style.name if para.style else None
747
+        
748
+        segment = {
401 749
             'text': run.text,
402 750
             'style': style
403
-        })
751
+        }
752
+        
753
+        # 添加 word_style(方案 A:总是添加)
754
+        if word_style:
755
+            segment['word_style'] = word_style
756
+        
757
+        segments.append(segment)
404 758
     
405 759
     return segments if segments else text
406 760
 
@@ -412,13 +766,42 @@ def _extract_table(table) -> dict:
412 766
         table: python-docx 表格对象
413 767
         
414 768
     Returns:
415
-        表格数据字典
769
+        表格数据字典,包含合并单元格和尺寸信息
416 770
     """
417 771
     rows_data = []
418 772
     
419
-    for row in table.rows:
773
+    # 提取表格列宽(从 tblGrid)
774
+    col_widths = []
775
+    tbl_elem = table._element
776
+    tbl_grid = tbl_elem.find(qn('w:tblGrid'))
777
+    if tbl_grid is not None:
778
+        for grid_col in tbl_grid.findall(qn('w:gridCol')):
779
+            width = grid_col.get(qn('w:w'))
780
+            if width:
781
+                # twips 转 pt (1 pt = 20 twips)
782
+                col_widths.append(int(width) / 20)
783
+    
784
+    # 用于跟踪行合并(vMerge)
785
+    # col_index -> {start_row, rowspan_count}
786
+    vmerge_tracking = {}
787
+    
788
+    for row_idx, row in enumerate(table.rows):
420 789
         cells_data = []
421
-        for cell in row.cells:
790
+        
791
+        # 提取行高
792
+        row_height = None
793
+        if row.height:
794
+            row_height = row.height.pt
795
+        
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)
422 805
             # 提取单元格文本
423 806
             cell_text = []
424 807
             for para in cell.paragraphs:
@@ -428,26 +811,45 @@ def _extract_table(table) -> dict:
428 811
             
429 812
             # 检测单元格样式(从第一个段落的第一个 run)
430 813
             cell_style = {}
814
+            cell_word_style = None  # 单元格的 word_style
815
+            
431 816
             if cell.paragraphs:
432 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
+                
433 828
                 if first_para.runs:
434 829
                     first_run = first_para.runs[0]
435 830
                     
436
-                    # 加粗
437
-                    if first_run.bold:
831
+                    # 加粗(run 明确设置会覆盖样式)
832
+                    if first_run.bold is True:
438 833
                         cell_style['bold'] = True
834
+                    elif first_run.bold is False:
835
+                        # 明确设置为不加粗,移除样式的加粗
836
+                        cell_style.pop('bold', None)
837
+                    # 如果 run.bold 为 None,保持样式中的设置
439 838
                     
440
-                    # 斜体
441
-                    if first_run.italic:
839
+                    # 斜体(run 明确设置会覆盖样式)
840
+                    if first_run.italic is True:
442 841
                         cell_style['italic'] = True
842
+                    elif first_run.italic is False:
843
+                        cell_style.pop('italic', None)
443 844
                     
444
-                    # 下划线
845
+                    # 下划线(run 明确设置会覆盖样式)
445 846
                     if first_run.underline:
446 847
                         cell_style['underline'] = True
447 848
                     
448 849
                     # 字体
449
-                    if first_run.font.name:
450
-                        cell_style['font_name'] = first_run.font.name
850
+                    font_name = _get_font_name(first_run)
851
+                    if font_name:
852
+                        cell_style['font_name'] = font_name
451 853
                     
452 854
                     # 字号
453 855
                     if first_run.font.size:
@@ -471,17 +873,109 @@ def _extract_table(table) -> dict:
471 873
             else:
472 874
                 text_content = ""
473 875
             
474
-            cells_data.append({
876
+            # 提取合并信息
877
+            tc_elem = cell._tc
878
+            tcPr = tc_elem.find(qn('w:tcPr'))
879
+            
880
+            colspan = 1
881
+            rowspan = 1
882
+            is_vmerge_continue = False
883
+            
884
+            if tcPr is not None:
885
+                # 列合并 (gridSpan)
886
+                grid_span = tcPr.find(qn('w:gridSpan'))
887
+                if grid_span is not None:
888
+                    colspan = int(grid_span.get(qn('w:val')))
889
+                
890
+                # 行合并 (vMerge)
891
+                v_merge = tcPr.find(qn('w:vMerge'))
892
+                if v_merge is not None:
893
+                    v_merge_val = v_merge.get(qn('w:val'))
894
+                    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
917
+            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 = {
475 936
                 'text': text_content,
476
-                'rowspan': 1,
477
-                'colspan': 1,
937
+                'rowspan': rowspan,
938
+                'colspan': colspan,
478 939
                 'style': cell_style
479
-            })
940
+            }
941
+            
942
+            # 添加 word_style
943
+            if cell_word_style:
944
+                cell_data['word_style'] = cell_word_style
945
+            
946
+            # 添加尺寸信息
947
+            if cell_width is not None:
948
+                cell_data['width'] = round(cell_width, 2)
949
+            
950
+            cells_data.append(cell_data)
951
+            
952
+            # 更新列偏移
953
+            col_offset += colspan
480 954
         
481
-        rows_data.append({
955
+        # 构建行数据
956
+        row_data = {
482 957
             'cells': cells_data
483
-        })
958
+        }
959
+        
960
+        # 添加行高
961
+        if row_height is not None:
962
+            row_data['height'] = round(row_height, 2)
963
+        
964
+        rows_data.append(row_data)
965
+    
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
484 977
     
485 978
     return {
486
-        'rows': rows_data
979
+        'rows': rows_data,
980
+        'col_widths': [round(w, 2) for w in col_widths] if col_widths else None
487 981
     }

BIN
tmp/default.docx


+ 161 - 6
tmp/default.json

@@ -1,6 +1,6 @@
1 1
 {
2 2
   "source_file": "default.docx",
3
-  "total_styles": 27,
3
+  "total_styles": 28,
4 4
   "styles": [
5 5
     {
6 6
       "name": "Normal",
@@ -2840,7 +2840,7 @@
2840 2840
       "type": "CHARACTER (2)",
2841 2841
       "builtin": false,
2842 2842
       "hidden": false,
2843
-      "quick_style": false,
2843
+      "quick_style": true,
2844 2844
       "priority": 0,
2845 2845
       "base_style": null,
2846 2846
       "next_paragraph_style": null,
@@ -2876,6 +2876,10 @@
2876 2876
               "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "2"
2877 2877
             }
2878 2878
           },
2879
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat": {
2880
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat",
2881
+            "@attrib": {}
2882
+          },
2879 2883
           "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority": {
2880 2884
             "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority",
2881 2885
             "@attrib": {
@@ -3015,7 +3019,7 @@
3015 3019
       "type": "PARAGRAPH (1)",
3016 3020
       "builtin": false,
3017 3021
       "hidden": false,
3018
-      "quick_style": false,
3022
+      "quick_style": true,
3019 3023
       "priority": 0,
3020 3024
       "base_style": "Normal",
3021 3025
       "next_paragraph_style": "表格正文",
@@ -3068,6 +3072,10 @@
3068 3072
               "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "25"
3069 3073
             }
3070 3074
           },
3075
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat": {
3076
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat",
3077
+            "@attrib": {}
3078
+          },
3071 3079
           "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority": {
3072 3080
             "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority",
3073 3081
             "@attrib": {
@@ -3148,7 +3156,7 @@
3148 3156
       "type": "CHARACTER (2)",
3149 3157
       "builtin": false,
3150 3158
       "hidden": false,
3151
-      "quick_style": false,
3159
+      "quick_style": true,
3152 3160
       "priority": 0,
3153 3161
       "base_style": null,
3154 3162
       "next_paragraph_style": null,
@@ -3184,6 +3192,10 @@
3184 3192
               "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "24"
3185 3193
             }
3186 3194
           },
3195
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat": {
3196
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat",
3197
+            "@attrib": {}
3198
+          },
3187 3199
           "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority": {
3188 3200
             "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority",
3189 3201
             "@attrib": {
@@ -3231,7 +3243,7 @@
3231 3243
       "type": "CHARACTER (2)",
3232 3244
       "builtin": false,
3233 3245
       "hidden": false,
3234
-      "quick_style": false,
3246
+      "quick_style": true,
3235 3247
       "priority": 0,
3236 3248
       "base_style": null,
3237 3249
       "next_paragraph_style": null,
@@ -3267,6 +3279,10 @@
3267 3279
               "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "14"
3268 3280
             }
3269 3281
           },
3282
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat": {
3283
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat",
3284
+            "@attrib": {}
3285
+          },
3270 3286
           "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority": {
3271 3287
             "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority",
3272 3288
             "@attrib": {
@@ -3294,7 +3310,7 @@
3294 3310
       "type": "CHARACTER (2)",
3295 3311
       "builtin": false,
3296 3312
       "hidden": false,
3297
-      "quick_style": false,
3313
+      "quick_style": true,
3298 3314
       "priority": 0,
3299 3315
       "base_style": null,
3300 3316
       "next_paragraph_style": null,
@@ -3330,6 +3346,10 @@
3330 3346
               "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "23"
3331 3347
             }
3332 3348
           },
3349
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat": {
3350
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat",
3351
+            "@attrib": {}
3352
+          },
3333 3353
           "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority": {
3334 3354
             "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority",
3335 3355
             "@attrib": {
@@ -3350,6 +3370,141 @@
3350 3370
           }
3351 3371
         }
3352 3372
       }
3373
+    },
3374
+    {
3375
+      "name": "表格标题1",
3376
+      "style_id": "28",
3377
+      "type": "PARAGRAPH (1)",
3378
+      "builtin": false,
3379
+      "hidden": false,
3380
+      "quick_style": true,
3381
+      "priority": 0,
3382
+      "base_style": "Normal",
3383
+      "next_paragraph_style": "表格标题1",
3384
+      "font_summary": {
3385
+        "name": "Times New Roman",
3386
+        "size_pt": 9.0,
3387
+        "bold": true,
3388
+        "italic": null,
3389
+        "underline": null,
3390
+        "color_rgb": null,
3391
+        "strike": null,
3392
+        "all_caps": null,
3393
+        "small_caps": null
3394
+      },
3395
+      "paragraph_format_summary": {
3396
+        "alignment": "CENTER (1)",
3397
+        "left_indent_pt": null,
3398
+        "right_indent_pt": null,
3399
+        "first_line_indent_pt": 0.0,
3400
+        "space_before_pt": null,
3401
+        "space_after_pt": 1.2,
3402
+        "line_spacing": 152400.0,
3403
+        "keep_together": null,
3404
+        "keep_with_next": null,
3405
+        "page_break_before": null
3406
+      },
3407
+      "full_xml_definition": {
3408
+        "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}style",
3409
+        "@attrib": {
3410
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}type": "paragraph",
3411
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}customStyle": "1",
3412
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}styleId": "28"
3413
+        },
3414
+        "@children": {
3415
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}name": {
3416
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}name",
3417
+            "@attrib": {
3418
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "表格标题1"
3419
+            }
3420
+          },
3421
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}basedOn": {
3422
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}basedOn",
3423
+            "@attrib": {
3424
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "1"
3425
+            }
3426
+          },
3427
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat": {
3428
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}qFormat",
3429
+            "@attrib": {}
3430
+          },
3431
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority": {
3432
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}uiPriority",
3433
+            "@attrib": {
3434
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "0"
3435
+            }
3436
+          },
3437
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr": {
3438
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr",
3439
+            "@attrib": {},
3440
+            "@children": {
3441
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}snapToGrid": {
3442
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}snapToGrid",
3443
+                "@attrib": {
3444
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "0"
3445
+                }
3446
+              },
3447
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing": {
3448
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing",
3449
+                "@attrib": {
3450
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}after": "24",
3451
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}line": "240",
3452
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}lineRule": "exact"
3453
+                }
3454
+              },
3455
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}ind": {
3456
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}ind",
3457
+                "@attrib": {
3458
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}firstLine": "0",
3459
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}firstLineChars": "0"
3460
+                }
3461
+              },
3462
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}jc": {
3463
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}jc",
3464
+                "@attrib": {
3465
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "center"
3466
+                }
3467
+              }
3468
+            }
3469
+          },
3470
+          "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rPr": {
3471
+            "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rPr",
3472
+            "@attrib": {},
3473
+            "@children": {
3474
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rFonts": {
3475
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rFonts",
3476
+                "@attrib": {
3477
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}hint": "eastAsia",
3478
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}ascii": "Times New Roman",
3479
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}hAnsi": "Times New Roman",
3480
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}eastAsia": "宋体",
3481
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}cs": "宋体"
3482
+                }
3483
+              },
3484
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}b": {
3485
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}b",
3486
+                "@attrib": {}
3487
+              },
3488
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}bCs": {
3489
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}bCs",
3490
+                "@attrib": {}
3491
+              },
3492
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}sz": {
3493
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}sz",
3494
+                "@attrib": {
3495
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "18"
3496
+                }
3497
+              },
3498
+              "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}szCs": {
3499
+                "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}szCs",
3500
+                "@attrib": {
3501
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val": "18"
3502
+                }
3503
+              }
3504
+            }
3505
+          }
3506
+        }
3507
+      }
3353 3508
     }
3354 3509
   ],
3355 3510
   "numbering": {