word_parser.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. """word_parser.py — 将 Word 文档解析为 Block 列表"""
  2. import base64
  3. import io
  4. from pathlib import Path
  5. from typing import Optional
  6. import zipfile
  7. from docx import Document as DocxDocument
  8. from docx.oxml.ns import qn
  9. from lxml import etree
  10. # 全局缓存:主题字体映射
  11. _theme_fonts_cache = {}
  12. # 当前文档的主题字体(用于在解析过程中传递)
  13. _current_theme_fonts = {}
  14. def _load_theme_fonts(docx_path: Path) -> dict:
  15. """从 Word 文档中加载主题字体定义"""
  16. # 检查缓存
  17. cache_key = str(docx_path)
  18. if cache_key in _theme_fonts_cache:
  19. return _theme_fonts_cache[cache_key]
  20. theme_fonts = {}
  21. try:
  22. with zipfile.ZipFile(docx_path, 'r') as docx_zip:
  23. # 查找主题文件
  24. theme_files = [name for name in docx_zip.namelist()
  25. if 'theme' in name.lower() and name.endswith('.xml')]
  26. if not theme_files:
  27. return theme_fonts
  28. # 读取主题 XML
  29. theme_xml = docx_zip.read(theme_files[0])
  30. root = etree.fromstring(theme_xml)
  31. # 命名空间
  32. ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
  33. # 解析 majorFont(标题字体)
  34. major_font = root.find('.//a:majorFont', ns)
  35. if major_font is not None:
  36. ea = major_font.find('.//a:ea', ns)
  37. if ea is not None and ea.get('typeface'):
  38. theme_fonts['majorEastAsia'] = ea.get('typeface')
  39. # 回退到简体中文
  40. hans = major_font.find('.//a:font[@script="Hans"]', ns)
  41. if hans is not None and hans.get('typeface'):
  42. if 'majorEastAsia' not in theme_fonts:
  43. theme_fonts['majorEastAsia'] = hans.get('typeface')
  44. # 解析 minorFont(正文字体)
  45. minor_font = root.find('.//a:minorFont', ns)
  46. if minor_font is not None:
  47. ea = minor_font.find('.//a:ea', ns)
  48. if ea is not None and ea.get('typeface'):
  49. theme_fonts['minorEastAsia'] = ea.get('typeface')
  50. # 回退到简体中文
  51. hans = minor_font.find('.//a:font[@script="Hans"]', ns)
  52. if hans is not None and hans.get('typeface'):
  53. if 'minorEastAsia' not in theme_fonts:
  54. theme_fonts['minorEastAsia'] = hans.get('typeface')
  55. except Exception:
  56. # 如果读取失败,返回空字典
  57. pass
  58. # 缓存结果
  59. _theme_fonts_cache[cache_key] = theme_fonts
  60. return theme_fonts
  61. def _get_eastasia_font_from_element(element):
  62. """从 XML 元素中提取 eastAsia 字体(用于中文字体)"""
  63. if element is None:
  64. return None
  65. rFonts = element.find(qn('w:rFonts'))
  66. if rFonts is not None:
  67. east_asia = rFonts.get(qn('w:eastAsia'))
  68. if east_asia:
  69. return east_asia
  70. return None
  71. def _get_font_name(run, theme_fonts: dict = None):
  72. """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体;特殊处理混合语言字体继承)"""
  73. if theme_fonts is None:
  74. theme_fonts = _current_theme_fonts
  75. # 1. 尝试从 XML 读取字体
  76. if hasattr(run._element, 'rPr'):
  77. rPr = run._element.rPr
  78. if rPr is not None:
  79. rFonts = rPr.find(qn('w:rFonts'))
  80. if rFonts is not None:
  81. # 1a. 优先 eastAsia(中文字体)
  82. east_asia = rFonts.get(qn('w:eastAsia'))
  83. if east_asia:
  84. return east_asia
  85. # 1b. 主题字体引用
  86. if theme_fonts:
  87. east_asia_theme = rFonts.get(qn('w:eastAsiaTheme'))
  88. if east_asia_theme and east_asia_theme in theme_fonts:
  89. return theme_fonts[east_asia_theme]
  90. # 1c. 如果只定义了 ascii/hAnsi,没有 eastAsia
  91. # 返回 None 让其从样式继承中文字体
  92. # 这样可以正确处理 Heading 2 等情况
  93. ascii_font = rFonts.get(qn('w:ascii'))
  94. hAnsi_font = rFonts.get(qn('w:hAnsi'))
  95. if ascii_font or hAnsi_font:
  96. # 有西文字体但没有中文字体,返回 None
  97. # 让 _extract_paragraph_format 从样式提取
  98. return None
  99. # 2. 回退到标准 API(ascii 字体)
  100. if run.font.name:
  101. return run.font.name
  102. return None
  103. def _get_paragraph_style_font(para):
  104. """从段落样式中提取字体(当 run 级别没有字体设置时使用,优先 eastAsia)"""
  105. try:
  106. style = para.style
  107. if hasattr(style, 'element'):
  108. rPr = style.element.find(qn('w:rPr'))
  109. if rPr is not None:
  110. rFonts = rPr.find(qn('w:rFonts'))
  111. if rFonts is not None:
  112. # 优先 eastAsia(中文字体)
  113. east_asia = rFonts.get(qn('w:eastAsia'))
  114. if east_asia:
  115. return east_asia
  116. # 如果当前样式没有 eastAsia,查找基础样式的 eastAsia
  117. # 这样可以正确处理 Heading 2 等只定义 ascii 但基于 Normal 的样式
  118. if hasattr(style, 'base_style') and style.base_style:
  119. base_font = _get_paragraph_style_font_recursive(style.base_style)
  120. if base_font:
  121. return base_font
  122. # 如果没有 eastAsia,回退到 ascii/hAnsi
  123. if rPr is not None:
  124. rFonts = rPr.find(qn('w:rFonts'))
  125. if rFonts is not None:
  126. # 其次 ascii
  127. ascii_font = rFonts.get(qn('w:ascii'))
  128. if ascii_font:
  129. return ascii_font
  130. # 最后 hAnsi
  131. hAnsi = rFonts.get(qn('w:hAnsi'))
  132. if hAnsi:
  133. return hAnsi
  134. except Exception:
  135. pass
  136. return None
  137. def _get_paragraph_style_font_recursive(style):
  138. """递归查找样式的 eastAsia 字体(用于基础样式查找)"""
  139. try:
  140. if hasattr(style, 'element'):
  141. rPr = style.element.find(qn('w:rPr'))
  142. if rPr is not None:
  143. rFonts = rPr.find(qn('w:rFonts'))
  144. if rFonts is not None:
  145. east_asia = rFonts.get(qn('w:eastAsia'))
  146. if east_asia:
  147. return east_asia
  148. # 继续查找基础样式
  149. if hasattr(style, 'base_style') and style.base_style:
  150. return _get_paragraph_style_font_recursive(style.base_style)
  151. except Exception:
  152. pass
  153. return None
  154. def _get_style_formatting(style):
  155. """从样式中提取格式属性(加粗、斜体、下划线等)"""
  156. formatting = {}
  157. if not style or not hasattr(style, 'element'):
  158. return formatting
  159. try:
  160. rPr = style.element.find(qn('w:rPr'))
  161. if rPr is not None:
  162. # 加粗
  163. bold_elem = rPr.find(qn('w:b'))
  164. if bold_elem is not None:
  165. bold_val = bold_elem.get(qn('w:val'))
  166. # w:val 为 None、'1' 或 'true' 表示加粗
  167. if bold_val is None or bold_val in ('1', 'true'):
  168. formatting['bold'] = True
  169. # 斜体
  170. italic_elem = rPr.find(qn('w:i'))
  171. if italic_elem is not None:
  172. italic_val = italic_elem.get(qn('w:val'))
  173. if italic_val is None or italic_val in ('1', 'true'):
  174. formatting['italic'] = True
  175. # 下划线
  176. underline_elem = rPr.find(qn('w:u'))
  177. if underline_elem is not None:
  178. underline_val = underline_elem.get(qn('w:val'))
  179. # 下划线有多种类型,只要存在就算有下划线
  180. if underline_val and underline_val != 'none':
  181. formatting['underline'] = True
  182. except Exception:
  183. pass
  184. return formatting
  185. def parse_word_to_blocks(docx_path: Path) -> list[dict]:
  186. """将 Word 文档解析为 Block 列表"""
  187. global _current_theme_fonts
  188. doc = DocxDocument(str(docx_path))
  189. blocks = []
  190. block_order = 0
  191. # 加载主题字体并设置为当前主题
  192. _current_theme_fonts = _load_theme_fonts(docx_path)
  193. # 标题计数器(按 level 分别计数)
  194. heading_counters = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}
  195. # 其他类型的全局计数器
  196. type_counters = {
  197. 'paragraph': 0,
  198. 'image': 0,
  199. 'table': 0
  200. }
  201. parent_stack = [] # 维护父标题栈
  202. # 提取所有图片及其位置信息
  203. from app.services.image_service import extract_images_from_word
  204. images = extract_images_from_word(doc)
  205. image_map = {}
  206. for img in images:
  207. para_idx = img['paragraph_index']
  208. if para_idx not in image_map:
  209. image_map[para_idx] = []
  210. image_map[para_idx].append(img)
  211. # 收集所有元素(段落、表格、SDT)并按文档顺序排列
  212. elements = []
  213. body = doc.element.body
  214. para_map = {p._element: p for p in doc.paragraphs}
  215. table_map = {t._element: t for t in doc.tables}
  216. for child in body:
  217. tag = child.tag
  218. if tag.endswith('p'):
  219. para = para_map.get(child)
  220. if para:
  221. elements.append(('para', para))
  222. elif tag.endswith('tbl'):
  223. table = table_map.get(child)
  224. if table:
  225. elements.append(('table', table))
  226. elif tag.endswith('sdt'): # ★ 新增:检测 SDT(可能是目录)
  227. elements.append(('sdt', child))
  228. # 记录段落索引
  229. para_idx_in_elements = {}
  230. actual_para_idx = 0
  231. for elem_idx, (elem_type, elem) in enumerate(elements):
  232. if elem_type == "para":
  233. para_idx_in_elements[actual_para_idx] = elem_idx
  234. actual_para_idx += 1
  235. # 转换为 Blocks
  236. for elem_idx, (elem_type, elem) in enumerate(elements):
  237. if elem_type == "para":
  238. para = elem
  239. style_name = para.style.name if para.style else "Normal"
  240. # 判断是否为标题
  241. level = _identify_heading_level(para, style_name)
  242. if level:
  243. # 提取内容(支持富文本)
  244. content = _extract_rich_text(para)
  245. # 跳过空标题(没有内容的标题)
  246. if not content:
  247. # 空标题不添加到 blocks,继续下一个段落
  248. continue
  249. # 标题块
  250. index = heading_counters[level] * 100 # 稀疏排序:0, 100, 200...
  251. heading_counters[level] += 1
  252. # 注意:不重置更深层级的计数器
  253. # index 是全局的(按 level 独立计数),不受父标题影响
  254. # 更新父标题栈
  255. while parent_stack and parent_stack[-1]['level'] >= level:
  256. parent_stack.pop()
  257. parent_id = parent_stack[-1]['id'] if parent_stack else None
  258. # 提取段落级样式
  259. para_style = _extract_paragraph_format(para)
  260. block = {
  261. 'id': f'block-h{level}-{index}', # 使用 index 而不是 block_order
  262. 'block_order': block_order * 100, # 稀疏排序
  263. 'type': 'heading',
  264. 'level': level,
  265. 'index': index, # 稀疏 index
  266. 'content': content,
  267. 'word_style': style_name,
  268. 'style': para_style, # 颗粒度样式
  269. 'metadata': {
  270. 'parent_heading_id': parent_id
  271. }
  272. }
  273. blocks.append(block)
  274. parent_stack.append({'id': block['id'], 'level': level})
  275. block_order += 1
  276. else:
  277. # 普通段落
  278. content = _extract_rich_text(para)
  279. para_style = _extract_paragraph_format(para) # 提取段落级样式
  280. # 空行处理:与普通 paragraph 一致,只是 content 为空
  281. if not content and not (actual_para_idx - 1 in image_map):
  282. # 空行:作为普通段落,content 为空字符串
  283. parent_id = parent_stack[-1]['id'] if parent_stack else None
  284. index = type_counters['paragraph'] * 100
  285. type_counters['paragraph'] += 1
  286. block = {
  287. 'id': f'block-p-{index}',
  288. 'block_order': block_order * 100,
  289. 'type': 'paragraph',
  290. 'level': 0,
  291. 'index': index,
  292. 'content': '', # 空内容
  293. 'word_style': style_name,
  294. 'style': para_style, # 保留空行的样式(如果有)
  295. 'metadata': {
  296. 'parent_heading_id': parent_id
  297. }
  298. }
  299. blocks.append(block)
  300. block_order += 1
  301. elif content:
  302. # 有内容的段落
  303. parent_id = parent_stack[-1]['id'] if parent_stack else None
  304. index = type_counters['paragraph'] * 100
  305. type_counters['paragraph'] += 1
  306. # 如果是富文本数组,Block 样式为空;如果是纯文本,Block 有样式
  307. block_style = {} if isinstance(content, list) else para_style
  308. block = {
  309. 'id': f'block-p-{index}',
  310. 'block_order': block_order * 100,
  311. 'type': 'paragraph',
  312. 'level': 0,
  313. 'index': index,
  314. 'content': content,
  315. 'word_style': style_name,
  316. 'style': block_style,
  317. 'metadata': {
  318. 'parent_heading_id': parent_id
  319. }
  320. }
  321. blocks.append(block)
  322. block_order += 1
  323. # 检查是否有图片
  324. current_para_idx = None
  325. for p_idx, e_idx in para_idx_in_elements.items():
  326. if e_idx == elem_idx:
  327. current_para_idx = p_idx
  328. break
  329. if current_para_idx is not None and current_para_idx in image_map:
  330. for img in image_map[current_para_idx]:
  331. parent_id = parent_stack[-1]['id'] if parent_stack else None
  332. index = type_counters['image'] * 100 # 稀疏 index
  333. type_counters['image'] += 1
  334. block = {
  335. 'id': f'block-img-{index}', # 使用 index
  336. 'block_order': block_order * 100,
  337. 'type': 'image',
  338. 'level': 0,
  339. 'index': index, # 稀疏 index
  340. 'content': img['data_url'],
  341. 'word_style': img['style'].get('para_style', 'Normal'),
  342. 'style': {
  343. 'width': img['style'].get('width', 10.0),
  344. 'height': img['style'].get('height', 7.0),
  345. 'unit': img['style'].get('unit', 'cm'),
  346. 'align': img['style'].get('align', 'left')
  347. },
  348. 'metadata': {
  349. 'alt': '图片',
  350. 'parent_heading_id': parent_id
  351. }
  352. }
  353. blocks.append(block)
  354. block_order += 1
  355. elif elem_type == "table":
  356. # 表格块
  357. table = elem
  358. table_content = _extract_table(table, doc)
  359. parent_id = parent_stack[-1]['id'] if parent_stack else None
  360. # 计算表格元数据
  361. rows = table_content.get('rows', [])
  362. # 遍历所有行,找出最大的列数(考虑 colspan)
  363. max_cols = 0
  364. for row_data in rows:
  365. row_cols = sum(cell.get('colspan', 1) for cell in row_data.get('cells', []))
  366. max_cols = max(max_cols, row_cols)
  367. cols = max_cols if max_cols > 0 else 0
  368. index = type_counters['table'] * 100 # 稀疏 index
  369. type_counters['table'] += 1
  370. block = {
  371. 'id': f'block-table-{index}', # 使用 index
  372. 'block_order': block_order * 100,
  373. 'type': 'table',
  374. 'level': 0,
  375. 'index': index, # 稀疏 index
  376. 'content': table_content,
  377. 'word_style': 'Table Grid',
  378. 'style': {},
  379. 'metadata': {
  380. 'cols': cols,
  381. 'rows': len(rows),
  382. 'table_width': 100,
  383. 'table_width_unit': 'percent',
  384. 'col_widths': [100 // cols] * cols if cols > 0 else [],
  385. 'parent_heading_id': parent_id
  386. }
  387. }
  388. blocks.append(block)
  389. block_order += 1
  390. elif elem_type == "sdt": # ★ 新增:处理 SDT(目录)
  391. sdt = elem
  392. toc_block = _extract_toc_from_sdt(sdt, block_order, parent_stack)
  393. if toc_block:
  394. blocks.append(toc_block)
  395. block_order += 1
  396. return blocks
  397. def _extract_toc_from_sdt(sdt, block_order: int, parent_stack: list) -> Optional[dict]:
  398. """从 SDT 中提取目录 Block
  399. Args:
  400. sdt: SDT XML 元素
  401. block_order: 当前 block 顺序
  402. parent_stack: 父标题栈
  403. Returns:
  404. TOC Block 字典,如果不是目录则返回 None
  405. """
  406. import re
  407. # 1. 检查 SDT 内是否包含 TOC 域
  408. instr_texts = sdt.findall('.//' + qn('w:instrText'))
  409. has_toc = False
  410. toc_levels = "1-1" # 默认值
  411. use_hyperlinks = False
  412. use_page_numbers = True
  413. hide_page_numbers_in_web = False
  414. use_outline_levels = False
  415. for instr in instr_texts:
  416. text = instr.text if instr.text else ''
  417. if 'TOC' in text.upper():
  418. has_toc = True
  419. # 提取层级参数
  420. # 例如:TOC \o "1-3" \h \z \u
  421. match = re.search(r'\\o\s+"(\d+-\d+)"', text)
  422. if match:
  423. toc_levels = match.group(1)
  424. # 提取开关
  425. use_hyperlinks = '\\h' in text
  426. hide_page_numbers_in_web = '\\z' in text
  427. use_outline_levels = '\\u' in text
  428. break
  429. if not has_toc:
  430. return None
  431. # 2. 提取目录标题(SDT 内第一个段落)
  432. toc_title = "目录"
  433. paragraphs = sdt.findall('.//' + qn('w:p'))
  434. if paragraphs:
  435. first_para = paragraphs[0]
  436. text_elems = first_para.findall('.//' + qn('w:t'))
  437. title_text = ''.join([t.text for t in text_elems if t.text])
  438. if title_text:
  439. toc_title = title_text.strip()
  440. # 3. 获取父标题 ID
  441. parent_id = parent_stack[-1]['id'] if parent_stack else None
  442. # 4. 构建 TOC Block
  443. toc_block = {
  444. 'id': 'block-toc-0',
  445. 'block_order': block_order * 100,
  446. 'type': 'toc',
  447. 'level': 0,
  448. 'index': 0,
  449. 'content': {
  450. 'title': toc_title
  451. },
  452. 'word_style': 'TOC',
  453. 'metadata': {
  454. 'toc_config': {
  455. 'levels': toc_levels,
  456. 'use_hyperlinks': use_hyperlinks,
  457. 'use_page_numbers': use_page_numbers,
  458. 'hide_page_numbers_in_web': hide_page_numbers_in_web,
  459. 'use_outline_levels': use_outline_levels,
  460. 'show_leader_dots': True,
  461. 'leader_char': '.'
  462. },
  463. 'is_auto_generated': True,
  464. 'readonly': True,
  465. 'deletable': True
  466. }
  467. }
  468. return toc_block
  469. def _identify_heading_level(para, style_name: str) -> Optional[int]:
  470. """识别段落的标题级别(1-6 或 None)"""
  471. # 方法1:检查样式名称(内置样式)
  472. if style_name.startswith('Heading'):
  473. try:
  474. level = int(style_name.split()[-1])
  475. return level
  476. except (ValueError, IndexError):
  477. pass
  478. # 方法2:检查样式的大纲级别
  479. style = para.style
  480. if hasattr(style, 'element') and hasattr(style.element, 'pPr'):
  481. pPr = style.element.pPr
  482. if pPr is not None:
  483. outline_lvl = pPr.find(qn('w:outlineLvl'))
  484. if outline_lvl is not None:
  485. try:
  486. level = int(outline_lvl.get(qn('w:val'))) + 1
  487. if 1 <= level <= 6:
  488. return level
  489. except (ValueError, TypeError):
  490. pass
  491. # 方法3:检查段落格式的大纲级别
  492. if para._element.pPr is not None:
  493. outline_lvl = para._element.pPr.find(qn('w:outlineLvl'))
  494. if outline_lvl is not None:
  495. try:
  496. level = int(outline_lvl.get(qn('w:val'))) + 1
  497. if 1 <= level <= 6:
  498. return level
  499. except (ValueError, TypeError):
  500. pass
  501. return None
  502. def _extract_paragraph_format(para) -> dict:
  503. """提取段落级样式(Block 级别的 style)"""
  504. style = {}
  505. # 对齐方式
  506. if para.alignment is not None:
  507. align_map = {0: 'left', 1: 'center', 2: 'right', 3: 'justify'}
  508. style['align'] = align_map.get(para.alignment, 'left')
  509. # 字体和字号(检查第一个 run,如果整段统一则提取到 Block 级)
  510. if para.runs:
  511. first_run = para.runs[0]
  512. # 检查是否整段使用相同字体(支持 eastAsia,忽略 None 值)
  513. first_font = _get_font_name(first_run)
  514. # 如果所有 runs 都没有字体设置(都是 None),从段落样式提取
  515. if first_font is None:
  516. # 检查是否所有 runs 都没有字体
  517. all_none = all(
  518. _get_font_name(run) is None
  519. for run in para.runs if run.text
  520. )
  521. if all_none:
  522. # 从段落样式提取字体
  523. style_font = _get_paragraph_style_font(para)
  524. if style_font:
  525. style['font_name'] = style_font
  526. elif first_font:
  527. # 如果第一个 run 有字体,检查是否整段统一
  528. all_same_font = all(
  529. _get_font_name(run) == first_font
  530. for run in para.runs if run.text and _get_font_name(run) is not None
  531. )
  532. if all_same_font:
  533. style['font_name'] = first_font
  534. # 检查是否整段使用相同字号(忽略 None 值)
  535. if first_run.font.size:
  536. # 只比较有字号的 runs
  537. all_same_size = all(
  538. run.font.size == first_run.font.size
  539. for run in para.runs if run.text and run.font.size is not None
  540. )
  541. if all_same_size:
  542. style['font_size'] = first_run.font.size.pt
  543. # 检查是否整段加粗
  544. if first_run.bold:
  545. all_bold = all(run.bold for run in para.runs if run.text)
  546. if all_bold:
  547. style['bold'] = True
  548. # 检查是否整段斜体
  549. if first_run.italic:
  550. all_italic = all(run.italic for run in para.runs if run.text)
  551. if all_italic:
  552. style['italic'] = True
  553. # 检查是否整段下划线
  554. if first_run.underline:
  555. all_underline = all(run.underline for run in para.runs if run.text)
  556. if all_underline:
  557. style['underline'] = True
  558. # 检查是否整段相同颜色
  559. if first_run.font.color and first_run.font.color.rgb:
  560. first_color = str(first_run.font.color.rgb)
  561. all_same_color = all(
  562. (run.font.color and str(run.font.color.rgb) == first_color)
  563. for run in para.runs if run.text
  564. )
  565. if all_same_color:
  566. style['color'] = first_color
  567. else:
  568. # 空段落(没有 runs):先尝试从段落属性 XML 中提取直接格式化的字体和字号
  569. pPr = para._element.find(qn('w:pPr'))
  570. if pPr is not None:
  571. rPr = pPr.find(qn('w:rPr'))
  572. if rPr is not None:
  573. # 从段落属性中提取字号(w:sz,单位是半磅)
  574. sz = rPr.find(qn('w:sz'))
  575. if sz is not None:
  576. size_val = sz.get(qn('w:val'))
  577. if size_val:
  578. try:
  579. style['font_size'] = int(size_val) / 2 # 转换为磅值
  580. except (ValueError, TypeError):
  581. pass
  582. # 从段落属性中提取字体
  583. rFonts = rPr.find(qn('w:rFonts'))
  584. if rFonts is not None:
  585. # 优先使用 eastAsia 字体(中文)
  586. eastAsia = rFonts.get(qn('w:eastAsia'))
  587. ascii_font = rFonts.get(qn('w:ascii'))
  588. # 如果有 eastAsia 字体就用,否则用 ascii
  589. if eastAsia:
  590. style['font_name'] = eastAsia
  591. elif ascii_font:
  592. style['font_name'] = ascii_font
  593. # 如果 XML 中没有找到,再从段落样式中提取默认字体和字号
  594. if 'font_name' not in style:
  595. style_font = _get_paragraph_style_font(para)
  596. if style_font:
  597. style['font_name'] = style_font
  598. if 'font_size' not in style:
  599. # 尝试从段落样式中提取字号
  600. try:
  601. if hasattr(para.style, 'font') and para.style.font.size:
  602. style['font_size'] = para.style.font.size.pt
  603. except Exception:
  604. pass
  605. return style
  606. def _extract_rich_text(para) -> str | list:
  607. """提取段落的富文本内容(纯文本字符串或富文本片段列表)"""
  608. text = para.text.strip()
  609. if not text:
  610. return ""
  611. # 没有 runs 或只有一个 run,返回纯文本
  612. if not para.runs or len(para.runs) == 0:
  613. return text
  614. # 提取所有 runs 的样式(用于判断是否统一)
  615. valid_runs = [run for run in para.runs if run.text]
  616. if len(valid_runs) <= 1:
  617. return text
  618. # 检查所有 runs 的样式是否完全相同
  619. def get_run_style_signature(run):
  620. """获取 run 的样式签名,用于比较"""
  621. return (
  622. _get_font_name(run),
  623. run.font.size.pt if run.font.size else None,
  624. run.bold,
  625. run.italic,
  626. run.underline,
  627. run.font.strike,
  628. str(run.font.color.rgb) if run.font.color and run.font.color.rgb else None
  629. )
  630. first_sig = get_run_style_signature(valid_runs[0])
  631. all_same = all(get_run_style_signature(run) == first_sig for run in valid_runs)
  632. if all_same:
  633. # 所有 runs 样式相同,返回纯文本
  634. return text
  635. # 样式不同,返回富文本数组
  636. # 每个 run 包含完整样式和 word_style
  637. segments = []
  638. for run in para.runs:
  639. if not run.text:
  640. continue
  641. style = {}
  642. # 字体
  643. font_name = _get_font_name(run)
  644. if font_name:
  645. style['font_name'] = font_name
  646. # 字号
  647. if run.font.size:
  648. style['font_size'] = run.font.size.pt
  649. # 加粗
  650. if run.bold:
  651. style['bold'] = True
  652. # 斜体
  653. if run.italic:
  654. style['italic'] = True
  655. # 删除线
  656. if run.font.strike:
  657. style['strike'] = True
  658. # 下划线
  659. if run.underline:
  660. style['underline'] = True
  661. # 颜色
  662. if run.font.color and run.font.color.rgb:
  663. style['color'] = str(run.font.color.rgb)
  664. # 提取 word_style(字符样式或段落样式)
  665. word_style = None
  666. if run.style:
  667. word_style = run.style.name
  668. else:
  669. # run 没有独立样式,使用段落样式
  670. word_style = para.style.name if para.style else None
  671. segment = {
  672. 'text': run.text,
  673. 'style': style
  674. }
  675. # 添加 word_style(方案 A:总是添加)
  676. if word_style:
  677. segment['word_style'] = word_style
  678. segments.append(segment)
  679. return segments if segments else text
  680. def _extract_table(table, doc=None) -> dict:
  681. """提取表格内容(包含合并单元格和尺寸信息)- 完整修复版
  682. Args:
  683. table: python-docx 表格对象
  684. doc: python-docx 文档对象(用于获取样式名称)
  685. """
  686. rows_data = []
  687. # 提取表格列宽(从 tblGrid)
  688. col_widths = []
  689. tbl_elem = table._element
  690. tbl_grid = tbl_elem.find(qn('w:tblGrid'))
  691. if tbl_grid is not None:
  692. for grid_col in tbl_grid.findall(qn('w:gridCol')):
  693. width = grid_col.get(qn('w:w'))
  694. if width:
  695. # twips 转 pt (1 pt = 20 twips)
  696. col_widths.append(int(width) / 20)
  697. # 创建 XML 元素到 python-docx 单元格对象的映射
  698. cell_map = {}
  699. for row in table.rows:
  700. for cell in row.cells:
  701. cell_map[id(cell._element)] = cell
  702. # 第一遍:从 XML 直接读取,建立列索引到行合并信息的映射
  703. # col_index -> [{start_row, end_row}, ...] # 可能有多个合并区间
  704. vmerge_map = {}
  705. trs = tbl_elem.findall(qn('w:tr'))
  706. for row_idx, tr in enumerate(trs):
  707. tcs = tr.findall(qn('w:tc'))
  708. col_offset = 0
  709. for tc in tcs:
  710. tcPr = tc.find(qn('w:tcPr'))
  711. colspan = 1
  712. has_vmerge_restart = False
  713. has_vmerge_continue = False
  714. is_empty = False
  715. if tcPr is not None:
  716. # 列合并
  717. grid_span = tcPr.find(qn('w:gridSpan'))
  718. if grid_span is not None:
  719. colspan = int(grid_span.get(qn('w:val')))
  720. # 行合并
  721. v_merge = tcPr.find(qn('w:vMerge'))
  722. if v_merge is not None:
  723. v_merge_val = v_merge.get(qn('w:val'))
  724. if v_merge_val == 'restart':
  725. has_vmerge_restart = True
  726. else:
  727. # 'continue' 或 None/空字符串都表示继续合并
  728. has_vmerge_continue = True
  729. # 检查是否为空单元格(用于判断行合并)
  730. paras = tc.findall(qn('w:p'))
  731. text_parts = []
  732. for p in paras:
  733. runs = p.findall(qn('w:r'))
  734. for r in runs:
  735. ts = r.findall(qn('w:t'))
  736. for t in ts:
  737. if t.text and t.text.strip():
  738. text_parts.append(t.text)
  739. is_empty = len(text_parts) == 0
  740. # 处理行合并逻辑 - 记录所有合并区间
  741. if col_offset not in vmerge_map:
  742. vmerge_map[col_offset] = []
  743. merges = vmerge_map[col_offset]
  744. if has_vmerge_restart:
  745. # 开始新的行合并
  746. merges.append({
  747. 'start_row': row_idx,
  748. 'end_row': row_idx # 初始结束行等于开始行,后续会扩展
  749. })
  750. elif has_vmerge_continue:
  751. # 明确标记为 continue - 扩展最后一个合并
  752. if merges:
  753. merges[-1]['end_row'] = row_idx
  754. # 注意:移除了 "is_empty" 的判断,因为空单元格不一定意味着合并
  755. col_offset += colspan
  756. # 第二遍:完全从 XML 提取单元格数据
  757. for row_idx, tr in enumerate(trs):
  758. cells_data = []
  759. # 提取行高(从 python-docx,因为 XML 提取行高比较复杂)
  760. row_height = None
  761. if row_idx < len(table.rows):
  762. row = table.rows[row_idx]
  763. if row.height:
  764. row_height = row.height.pt
  765. # 遍历 XML 的 tc 元素
  766. tcs = tr.findall(qn('w:tc'))
  767. col_offset = 0
  768. for tc in tcs:
  769. # 首先提取 colspan 和 vMerge 信息
  770. tcPr = tc.find(qn('w:tcPr'))
  771. # 提取 colspan
  772. colspan = 1
  773. if tcPr is not None:
  774. grid_span = tcPr.find(qn('w:gridSpan'))
  775. if grid_span is not None:
  776. colspan = int(grid_span.get(qn('w:val')))
  777. # 检查 vMerge - 如果是 continue,跳过这个单元格
  778. is_vmerge_continue = False
  779. if tcPr is not None:
  780. v_merge = tcPr.find(qn('w:vMerge'))
  781. if v_merge is not None:
  782. v_merge_val = v_merge.get(qn('w:val'))
  783. # 'continue' 或 None/空字符串都表示继续合并
  784. if v_merge_val != 'restart':
  785. is_vmerge_continue = True
  786. if is_vmerge_continue:
  787. # 这是被合并的单元格,跳过
  788. col_offset += colspan
  789. continue
  790. # 不需要跳过被占用的列,因为 XML 中已经包含了占位符
  791. # (上面的 is_vmerge_continue 检查已经处理了)
  792. # 检查是否为空单元格
  793. is_empty = True
  794. paras = tc.findall(qn('w:p'))
  795. text_parts = []
  796. for p in paras:
  797. runs = p.findall(qn('w:r'))
  798. for r in runs:
  799. ts = r.findall(qn('w:t'))
  800. for t in ts:
  801. if t.text and t.text.strip():
  802. is_empty = False
  803. text_parts.append(t.text)
  804. # 判断是否应该提取此单元格
  805. should_extract = True
  806. rowspan = 1
  807. # 查找该列该行所在的合并区间
  808. if col_offset in vmerge_map:
  809. merges = vmerge_map[col_offset]
  810. for merge in merges:
  811. if merge['start_row'] == row_idx:
  812. # 这是合并的起始行
  813. rowspan = merge['end_row'] - merge['start_row'] + 1
  814. break
  815. elif row_idx > merge['start_row'] and row_idx <= merge['end_row']:
  816. # 这是被合并的行
  817. should_extract = False # 跳过被合并的单元格
  818. break
  819. if should_extract:
  820. # 从 XML 提取文本(支持富文本)
  821. cell_text_segments = []
  822. for p in paras:
  823. para_segments = []
  824. runs = p.findall(qn('w:r'))
  825. for r in runs:
  826. # 提取文本
  827. run_text = []
  828. for t in r.findall(qn('w:t')):
  829. if t.text:
  830. run_text.append(t.text)
  831. if run_text:
  832. # 提取 run 级样式
  833. run_style = {}
  834. rPr = r.find(qn('w:rPr'))
  835. if rPr is not None:
  836. # 加粗
  837. if rPr.find(qn('w:b')) is not None:
  838. run_style['bold'] = True
  839. # 斜体
  840. if rPr.find(qn('w:i')) is not None:
  841. run_style['italic'] = True
  842. # 下划线
  843. if rPr.find(qn('w:u')) is not None:
  844. run_style['underline'] = True
  845. # 字号
  846. sz = rPr.find(qn('w:sz'))
  847. if sz is not None:
  848. size_val = sz.get(qn('w:val'))
  849. if size_val:
  850. run_style['font_size'] = int(size_val) / 2 # 半磅转磅
  851. # 颜色
  852. color = rPr.find(qn('w:color'))
  853. if color is not None:
  854. color_val = color.get(qn('w:val'))
  855. if color_val and color_val != 'auto':
  856. run_style['color'] = color_val
  857. para_segments.append({
  858. 'text': ''.join(run_text),
  859. 'style': run_style
  860. })
  861. if para_segments:
  862. cell_text_segments.extend(para_segments)
  863. # 合并文本
  864. if len(cell_text_segments) == 0:
  865. text_content = ""
  866. elif len(cell_text_segments) == 1 and not cell_text_segments[0]['style']:
  867. # 纯文本
  868. text_content = cell_text_segments[0]['text']
  869. else:
  870. # 富文本或多个片段 - 简化处理:合并为纯文本
  871. text_content = ''.join(seg['text'] for seg in cell_text_segments)
  872. # 提取单元格样式(从第一个段落的第一个 run)
  873. cell_style = {}
  874. cell_word_style = None
  875. # 尝试使用 python-docx API 获取样式
  876. cell_obj = cell_map.get(id(tc))
  877. if cell_obj and cell_obj.paragraphs:
  878. first_para = cell_obj.paragraphs[0]
  879. if first_para.style:
  880. cell_word_style = first_para.style.name
  881. # 如果没有通过 API 获取到,尝试从 XML 获取
  882. if not cell_word_style and paras:
  883. first_p = paras[0]
  884. pPr = first_p.find(qn('w:pPr'))
  885. if pPr is not None:
  886. # 段落样式名称 - 从 XML 获取样式 ID
  887. pStyle = pPr.find(qn('w:pStyle'))
  888. if pStyle is not None:
  889. style_id = pStyle.get(qn('w:val'))
  890. # 尝试从已知的样式映射中查找
  891. # 注意:这里只能使用样式 ID 作为备选
  892. cell_word_style = style_id
  893. # 提取其他样式属性
  894. if paras:
  895. first_p = paras[0]
  896. pPr = first_p.find(qn('w:pPr'))
  897. if pPr is not None:
  898. # 对齐方式
  899. jc = pPr.find(qn('w:jc'))
  900. if jc is not None:
  901. align_val = jc.get(qn('w:val'))
  902. align_map = {'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify'}
  903. cell_style['align'] = align_map.get(align_val, 'left')
  904. # 从第一个 run 提取样式
  905. runs = first_p.findall(qn('w:r'))
  906. if runs:
  907. first_r = runs[0]
  908. rPr = first_r.find(qn('w:rPr'))
  909. if rPr is not None:
  910. # 加粗
  911. if rPr.find(qn('w:b')) is not None:
  912. cell_style['bold'] = True
  913. # 斜体
  914. if rPr.find(qn('w:i')) is not None:
  915. cell_style['italic'] = True
  916. # 下划线
  917. if rPr.find(qn('w:u')) is not None:
  918. cell_style['underline'] = True
  919. # 字号
  920. sz = rPr.find(qn('w:sz'))
  921. if sz is not None:
  922. size_val = sz.get(qn('w:val'))
  923. if size_val:
  924. cell_style['font_size'] = int(size_val) / 2
  925. # 颜色
  926. color = rPr.find(qn('w:color'))
  927. if color is not None:
  928. color_val = color.get(qn('w:val'))
  929. if color_val and color_val != 'auto':
  930. cell_style['color'] = color_val
  931. # 字体(复杂,需要处理主题字体)
  932. rFonts = rPr.find(qn('w:rFonts'))
  933. if rFonts is not None:
  934. font_name = (rFonts.get(qn('w:eastAsia')) or
  935. rFonts.get(qn('w:ascii')) or
  936. rFonts.get(qn('w:hAnsi')))
  937. if font_name:
  938. cell_style['font_name'] = font_name
  939. # 提取单元格宽度
  940. cell_width = None
  941. if tcPr is not None:
  942. tcW = tcPr.find(qn('w:tcW'))
  943. if tcW is not None:
  944. width_val = tcW.get(qn('w:w'))
  945. width_type = tcW.get(qn('w:type'))
  946. if width_val and width_type != 'pct':
  947. cell_width = int(width_val) / 20
  948. if cell_width is None and col_offset < len(col_widths):
  949. if colspan == 1:
  950. cell_width = col_widths[col_offset]
  951. else:
  952. cell_width = sum(col_widths[col_offset:col_offset + colspan])
  953. # 构建单元格数据
  954. cell_data = {
  955. 'text': text_content,
  956. 'rowspan': rowspan,
  957. 'colspan': colspan,
  958. 'col_index': col_offset, # 记录该单元格的绝对列位置
  959. 'style': cell_style
  960. }
  961. if cell_word_style:
  962. cell_data['word_style'] = cell_word_style
  963. if cell_width is not None:
  964. cell_data['width'] = round(cell_width, 2)
  965. cells_data.append(cell_data)
  966. col_offset += colspan
  967. # 构建行数据
  968. row_data = {'cells': cells_data}
  969. if row_height is not None:
  970. row_data['height'] = round(row_height, 2)
  971. rows_data.append(row_data)
  972. return {
  973. 'rows': rows_data,
  974. 'col_widths': [round(w, 2) for w in col_widths] if col_widths else None
  975. }