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