word_parser.py 35 KB

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