| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070 |
- """word_parser.py — 将 Word 文档解析为 Block 列表"""
- import base64
- import io
- from pathlib import Path
- from typing import Optional
- import zipfile
- from docx import Document as DocxDocument
- from docx.oxml.ns import qn
- from lxml import etree
- # 全局缓存:主题字体映射
- _theme_fonts_cache = {}
- # 当前文档的主题字体(用于在解析过程中传递)
- _current_theme_fonts = {}
- def _load_theme_fonts(docx_path: Path) -> dict:
- """从 Word 文档中加载主题字体定义"""
- # 检查缓存
- cache_key = str(docx_path)
- if cache_key in _theme_fonts_cache:
- return _theme_fonts_cache[cache_key]
-
- theme_fonts = {}
-
- try:
- with zipfile.ZipFile(docx_path, 'r') as docx_zip:
- # 查找主题文件
- theme_files = [name for name in docx_zip.namelist()
- if 'theme' in name.lower() and name.endswith('.xml')]
-
- if not theme_files:
- return theme_fonts
-
- # 读取主题 XML
- theme_xml = docx_zip.read(theme_files[0])
- root = etree.fromstring(theme_xml)
-
- # 命名空间
- ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
-
- # 解析 majorFont(标题字体)
- major_font = root.find('.//a:majorFont', ns)
- if major_font is not None:
- ea = major_font.find('.//a:ea', ns)
- if ea is not None and ea.get('typeface'):
- theme_fonts['majorEastAsia'] = ea.get('typeface')
- # 回退到简体中文
- hans = major_font.find('.//a:font[@script="Hans"]', ns)
- if hans is not None and hans.get('typeface'):
- if 'majorEastAsia' not in theme_fonts:
- theme_fonts['majorEastAsia'] = hans.get('typeface')
-
- # 解析 minorFont(正文字体)
- minor_font = root.find('.//a:minorFont', ns)
- if minor_font is not None:
- ea = minor_font.find('.//a:ea', ns)
- if ea is not None and ea.get('typeface'):
- theme_fonts['minorEastAsia'] = ea.get('typeface')
- # 回退到简体中文
- hans = minor_font.find('.//a:font[@script="Hans"]', ns)
- if hans is not None and hans.get('typeface'):
- if 'minorEastAsia' not in theme_fonts:
- theme_fonts['minorEastAsia'] = hans.get('typeface')
-
- except Exception:
- # 如果读取失败,返回空字典
- pass
-
- # 缓存结果
- _theme_fonts_cache[cache_key] = theme_fonts
- return theme_fonts
- def _get_eastasia_font_from_element(element):
- """从 XML 元素中提取 eastAsia 字体(用于中文字体)"""
- if element is None:
- return None
- rFonts = element.find(qn('w:rFonts'))
- if rFonts is not None:
- east_asia = rFonts.get(qn('w:eastAsia'))
- if east_asia:
- return east_asia
- return None
- def _get_font_name(run, theme_fonts: dict = None):
- """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体;特殊处理混合语言字体继承)"""
- if theme_fonts is None:
- theme_fonts = _current_theme_fonts
-
- # 1. 尝试从 XML 读取字体
- if hasattr(run._element, 'rPr'):
- rPr = run._element.rPr
- if rPr is not None:
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- # 1a. 优先 eastAsia(中文字体)
- east_asia = rFonts.get(qn('w:eastAsia'))
- if east_asia:
- return east_asia
-
- # 1b. 主题字体引用
- if theme_fonts:
- east_asia_theme = rFonts.get(qn('w:eastAsiaTheme'))
- if east_asia_theme and east_asia_theme in theme_fonts:
- return theme_fonts[east_asia_theme]
-
- # 1c. 如果只定义了 ascii/hAnsi,没有 eastAsia
- # 返回 None 让其从样式继承中文字体
- # 这样可以正确处理 Heading 2 等情况
- ascii_font = rFonts.get(qn('w:ascii'))
- hAnsi_font = rFonts.get(qn('w:hAnsi'))
- if ascii_font or hAnsi_font:
- # 有西文字体但没有中文字体,返回 None
- # 让 _extract_paragraph_format 从样式提取
- return None
-
- # 2. 回退到标准 API(ascii 字体)
- if run.font.name:
- return run.font.name
-
- return None
- def _get_paragraph_style_font(para):
- """从段落样式中提取字体(当 run 级别没有字体设置时使用,优先 eastAsia)"""
- try:
- style = para.style
- if hasattr(style, 'element'):
- rPr = style.element.find(qn('w:rPr'))
- if rPr is not None:
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- # 优先 eastAsia(中文字体)
- east_asia = rFonts.get(qn('w:eastAsia'))
- if east_asia:
- return east_asia
-
- # 如果当前样式没有 eastAsia,查找基础样式的 eastAsia
- # 这样可以正确处理 Heading 2 等只定义 ascii 但基于 Normal 的样式
- if hasattr(style, 'base_style') and style.base_style:
- base_font = _get_paragraph_style_font_recursive(style.base_style)
- if base_font:
- return base_font
-
- # 如果没有 eastAsia,回退到 ascii/hAnsi
- if rPr is not None:
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- # 其次 ascii
- ascii_font = rFonts.get(qn('w:ascii'))
- if ascii_font:
- return ascii_font
- # 最后 hAnsi
- hAnsi = rFonts.get(qn('w:hAnsi'))
- if hAnsi:
- return hAnsi
- except Exception:
- pass
-
- return None
- def _get_paragraph_style_font_recursive(style):
- """递归查找样式的 eastAsia 字体(用于基础样式查找)"""
- try:
- if hasattr(style, 'element'):
- rPr = style.element.find(qn('w:rPr'))
- if rPr is not None:
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- east_asia = rFonts.get(qn('w:eastAsia'))
- if east_asia:
- return east_asia
-
- # 继续查找基础样式
- if hasattr(style, 'base_style') and style.base_style:
- return _get_paragraph_style_font_recursive(style.base_style)
- except Exception:
- pass
-
- return None
- def _get_style_formatting(style):
- """从样式中提取格式属性(加粗、斜体、下划线等)"""
- formatting = {}
-
- if not style or not hasattr(style, 'element'):
- return formatting
-
- try:
- rPr = style.element.find(qn('w:rPr'))
- if rPr is not None:
- # 加粗
- bold_elem = rPr.find(qn('w:b'))
- if bold_elem is not None:
- bold_val = bold_elem.get(qn('w:val'))
- # w:val 为 None、'1' 或 'true' 表示加粗
- if bold_val is None or bold_val in ('1', 'true'):
- formatting['bold'] = True
-
- # 斜体
- italic_elem = rPr.find(qn('w:i'))
- if italic_elem is not None:
- italic_val = italic_elem.get(qn('w:val'))
- if italic_val is None or italic_val in ('1', 'true'):
- formatting['italic'] = True
-
- # 下划线
- underline_elem = rPr.find(qn('w:u'))
- if underline_elem is not None:
- underline_val = underline_elem.get(qn('w:val'))
- # 下划线有多种类型,只要存在就算有下划线
- if underline_val and underline_val != 'none':
- formatting['underline'] = True
- except Exception:
- pass
-
- return formatting
- def parse_word_to_blocks(docx_path: Path) -> list[dict]:
- """将 Word 文档解析为 Block 列表"""
- global _current_theme_fonts
-
- doc = DocxDocument(str(docx_path))
- blocks = []
- block_order = 0
-
- # 加载主题字体并设置为当前主题
- _current_theme_fonts = _load_theme_fonts(docx_path)
-
- # 标题计数器(按 level 分别计数)
- heading_counters = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}
- # 其他类型的全局计数器
- type_counters = {
- 'paragraph': 0,
- 'image': 0,
- 'table': 0
- }
- parent_stack = [] # 维护父标题栈
-
- # 提取所有图片及其位置信息
- from app.services.image_service import extract_images_from_word
- images = extract_images_from_word(doc)
- image_map = {}
- for img in images:
- para_idx = img['paragraph_index']
- if para_idx not in image_map:
- image_map[para_idx] = []
- image_map[para_idx].append(img)
-
- # 收集所有元素(段落和表格)并按文档顺序排列
- elements = []
- body = doc.element.body
- para_map = {p._element: p for p in doc.paragraphs}
- table_map = {t._element: t for t in doc.tables}
-
- for child in body:
- tag = child.tag
- if tag.endswith('p'):
- para = para_map.get(child)
- if para:
- elements.append(('para', para))
- elif tag.endswith('tbl'):
- table = table_map.get(child)
- if table:
- elements.append(('table', table))
-
- # 记录段落索引
- para_idx_in_elements = {}
- actual_para_idx = 0
- for elem_idx, (elem_type, elem) in enumerate(elements):
- if elem_type == "para":
- para_idx_in_elements[actual_para_idx] = elem_idx
- actual_para_idx += 1
-
- # 转换为 Blocks
- for elem_idx, (elem_type, elem) in enumerate(elements):
- if elem_type == "para":
- para = elem
- style_name = para.style.name if para.style else "Normal"
-
- # 判断是否为标题
- level = _identify_heading_level(para, style_name)
-
- if level:
- # 提取内容(支持富文本)
- content = _extract_rich_text(para)
-
- # 跳过空标题(没有内容的标题)
- if not content:
- # 空标题不添加到 blocks,继续下一个段落
- continue
-
- # 标题块
- index = heading_counters[level] * 100 # 稀疏排序:0, 100, 200...
- heading_counters[level] += 1
-
- # 注意:不重置更深层级的计数器
- # index 是全局的(按 level 独立计数),不受父标题影响
-
- # 更新父标题栈
- while parent_stack and parent_stack[-1]['level'] >= level:
- parent_stack.pop()
-
- parent_id = parent_stack[-1]['id'] if parent_stack else None
-
- # 提取段落级样式
- para_style = _extract_paragraph_format(para)
-
- block = {
- 'id': f'block-h{level}-{index}', # 使用 index 而不是 block_order
- 'block_order': block_order * 100, # 稀疏排序
- 'type': 'heading',
- 'level': level,
- 'index': index, # 稀疏 index
- 'content': content,
- 'word_style': style_name,
- 'style': para_style, # 颗粒度样式
- 'metadata': {
- 'parent_heading_id': parent_id
- }
- }
- blocks.append(block)
- parent_stack.append({'id': block['id'], 'level': level})
- block_order += 1
-
- else:
- # 普通段落
- content = _extract_rich_text(para)
- para_style = _extract_paragraph_format(para) # 提取段落级样式
-
- # 空行处理:与普通 paragraph 一致,只是 content 为空
- if not content and not (actual_para_idx - 1 in image_map):
- # 空行:作为普通段落,content 为空字符串
- parent_id = parent_stack[-1]['id'] if parent_stack else None
- index = type_counters['paragraph'] * 100
- type_counters['paragraph'] += 1
-
- block = {
- 'id': f'block-p-{index}',
- 'block_order': block_order * 100,
- 'type': 'paragraph',
- 'level': 0,
- 'index': index,
- 'content': '', # 空内容
- 'word_style': style_name,
- 'style': para_style, # 保留空行的样式(如果有)
- 'metadata': {
- 'parent_heading_id': parent_id
- }
- }
- blocks.append(block)
- block_order += 1
-
- elif content:
- # 有内容的段落
- parent_id = parent_stack[-1]['id'] if parent_stack else None
- index = type_counters['paragraph'] * 100
- type_counters['paragraph'] += 1
-
- # 如果是富文本数组,Block 样式为空;如果是纯文本,Block 有样式
- block_style = {} if isinstance(content, list) else para_style
-
- block = {
- 'id': f'block-p-{index}',
- 'block_order': block_order * 100,
- 'type': 'paragraph',
- 'level': 0,
- 'index': index,
- 'content': content,
- 'word_style': style_name,
- 'style': block_style,
- 'metadata': {
- 'parent_heading_id': parent_id
- }
- }
- blocks.append(block)
- block_order += 1
-
- # 检查是否有图片
- current_para_idx = None
- for p_idx, e_idx in para_idx_in_elements.items():
- if e_idx == elem_idx:
- current_para_idx = p_idx
- break
-
- if current_para_idx is not None and current_para_idx in image_map:
- for img in image_map[current_para_idx]:
- parent_id = parent_stack[-1]['id'] if parent_stack else None
- index = type_counters['image'] * 100 # 稀疏 index
- type_counters['image'] += 1
-
- block = {
- 'id': f'block-img-{index}', # 使用 index
- 'block_order': block_order * 100,
- 'type': 'image',
- 'level': 0,
- 'index': index, # 稀疏 index
- 'content': img['data_url'],
- 'word_style': img['style'].get('para_style', 'Normal'),
- 'style': {
- 'width': img['style'].get('width', 10.0),
- 'height': img['style'].get('height', 7.0),
- 'unit': img['style'].get('unit', 'cm'),
- 'align': img['style'].get('align', 'left')
- },
- 'metadata': {
- 'alt': '图片',
- 'parent_heading_id': parent_id
- }
- }
- blocks.append(block)
- block_order += 1
-
- elif elem_type == "table":
- # 表格块
- table = elem
- table_content = _extract_table(table, doc)
- parent_id = parent_stack[-1]['id'] if parent_stack else None
-
- # 计算表格元数据
- rows = table_content.get('rows', [])
- # 遍历所有行,找出最大的列数(考虑 colspan)
- max_cols = 0
- for row_data in rows:
- row_cols = sum(cell.get('colspan', 1) for cell in row_data.get('cells', []))
- max_cols = max(max_cols, row_cols)
-
- cols = max_cols if max_cols > 0 else 0
-
- index = type_counters['table'] * 100 # 稀疏 index
- type_counters['table'] += 1
-
- block = {
- 'id': f'block-table-{index}', # 使用 index
- 'block_order': block_order * 100,
- 'type': 'table',
- 'level': 0,
- 'index': index, # 稀疏 index
- 'content': table_content,
- 'word_style': 'Table Grid',
- 'style': {},
- 'metadata': {
- 'cols': cols,
- 'rows': len(rows),
- 'table_width': 100,
- 'table_width_unit': 'percent',
- 'col_widths': [100 // cols] * cols if cols > 0 else [],
- 'parent_heading_id': parent_id
- }
- }
- blocks.append(block)
- block_order += 1
-
- return blocks
- def _identify_heading_level(para, style_name: str) -> Optional[int]:
- """识别段落的标题级别(1-6 或 None)"""
- # 方法1:检查样式名称(内置样式)
- if style_name.startswith('Heading'):
- try:
- level = int(style_name.split()[-1])
- return level
- except (ValueError, IndexError):
- pass
-
- # 方法2:检查样式的大纲级别
- style = para.style
- if hasattr(style, 'element') and hasattr(style.element, 'pPr'):
- pPr = style.element.pPr
- if pPr is not None:
- outline_lvl = pPr.find(qn('w:outlineLvl'))
- if outline_lvl is not None:
- try:
- level = int(outline_lvl.get(qn('w:val'))) + 1
- if 1 <= level <= 6:
- return level
- except (ValueError, TypeError):
- pass
-
- # 方法3:检查段落格式的大纲级别
- if para._element.pPr is not None:
- outline_lvl = para._element.pPr.find(qn('w:outlineLvl'))
- if outline_lvl is not None:
- try:
- level = int(outline_lvl.get(qn('w:val'))) + 1
- if 1 <= level <= 6:
- return level
- except (ValueError, TypeError):
- pass
-
- return None
- def _extract_paragraph_format(para) -> dict:
- """提取段落级样式(Block 级别的 style)"""
- style = {}
-
- # 对齐方式
- if para.alignment is not None:
- align_map = {0: 'left', 1: 'center', 2: 'right', 3: 'justify'}
- style['align'] = align_map.get(para.alignment, 'left')
-
- # 字体和字号(检查第一个 run,如果整段统一则提取到 Block 级)
- if para.runs:
- first_run = para.runs[0]
-
- # 检查是否整段使用相同字体(支持 eastAsia,忽略 None 值)
- first_font = _get_font_name(first_run)
-
- # 如果所有 runs 都没有字体设置(都是 None),从段落样式提取
- if first_font is None:
- # 检查是否所有 runs 都没有字体
- all_none = all(
- _get_font_name(run) is None
- for run in para.runs if run.text
- )
- if all_none:
- # 从段落样式提取字体
- style_font = _get_paragraph_style_font(para)
- if style_font:
- style['font_name'] = style_font
- elif first_font:
- # 如果第一个 run 有字体,检查是否整段统一
- all_same_font = all(
- _get_font_name(run) == first_font
- for run in para.runs if run.text and _get_font_name(run) is not None
- )
- if all_same_font:
- style['font_name'] = first_font
-
- # 检查是否整段使用相同字号(忽略 None 值)
- if first_run.font.size:
- # 只比较有字号的 runs
- all_same_size = all(
- run.font.size == first_run.font.size
- for run in para.runs if run.text and run.font.size is not None
- )
- if all_same_size:
- style['font_size'] = first_run.font.size.pt
-
- # 检查是否整段加粗
- if first_run.bold:
- all_bold = all(run.bold for run in para.runs if run.text)
- if all_bold:
- style['bold'] = True
-
- # 检查是否整段斜体
- if first_run.italic:
- all_italic = all(run.italic for run in para.runs if run.text)
- if all_italic:
- style['italic'] = True
-
- # 检查是否整段下划线
- if first_run.underline:
- all_underline = all(run.underline for run in para.runs if run.text)
- if all_underline:
- style['underline'] = True
-
- # 检查是否整段相同颜色
- if first_run.font.color and first_run.font.color.rgb:
- first_color = str(first_run.font.color.rgb)
- all_same_color = all(
- (run.font.color and str(run.font.color.rgb) == first_color)
- for run in para.runs if run.text
- )
- if all_same_color:
- style['color'] = first_color
- else:
- # 空段落(没有 runs):先尝试从段落属性 XML 中提取直接格式化的字体和字号
- pPr = para._element.find(qn('w:pPr'))
- if pPr is not None:
- rPr = pPr.find(qn('w:rPr'))
- if rPr is not None:
- # 从段落属性中提取字号(w:sz,单位是半磅)
- sz = rPr.find(qn('w:sz'))
- if sz is not None:
- size_val = sz.get(qn('w:val'))
- if size_val:
- try:
- style['font_size'] = int(size_val) / 2 # 转换为磅值
- except (ValueError, TypeError):
- pass
-
- # 从段落属性中提取字体
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- # 优先使用 eastAsia 字体(中文)
- eastAsia = rFonts.get(qn('w:eastAsia'))
- ascii_font = rFonts.get(qn('w:ascii'))
- # 如果有 eastAsia 字体就用,否则用 ascii
- if eastAsia:
- style['font_name'] = eastAsia
- elif ascii_font:
- style['font_name'] = ascii_font
-
- # 如果 XML 中没有找到,再从段落样式中提取默认字体和字号
- if 'font_name' not in style:
- style_font = _get_paragraph_style_font(para)
- if style_font:
- style['font_name'] = style_font
-
- if 'font_size' not in style:
- # 尝试从段落样式中提取字号
- try:
- if hasattr(para.style, 'font') and para.style.font.size:
- style['font_size'] = para.style.font.size.pt
- except Exception:
- pass
-
- return style
- def _extract_rich_text(para) -> str | list:
- """提取段落的富文本内容(纯文本字符串或富文本片段列表)"""
- text = para.text.strip()
- if not text:
- return ""
-
- # 没有 runs 或只有一个 run,返回纯文本
- if not para.runs or len(para.runs) == 0:
- return text
-
- # 提取所有 runs 的样式(用于判断是否统一)
- valid_runs = [run for run in para.runs if run.text]
- if len(valid_runs) <= 1:
- return text
-
- # 检查所有 runs 的样式是否完全相同
- def get_run_style_signature(run):
- """获取 run 的样式签名,用于比较"""
- return (
- _get_font_name(run),
- run.font.size.pt if run.font.size else None,
- run.bold,
- run.italic,
- run.underline,
- run.font.strike,
- str(run.font.color.rgb) if run.font.color and run.font.color.rgb else None
- )
-
- first_sig = get_run_style_signature(valid_runs[0])
- all_same = all(get_run_style_signature(run) == first_sig for run in valid_runs)
-
- if all_same:
- # 所有 runs 样式相同,返回纯文本
- return text
-
- # 样式不同,返回富文本数组
- # 每个 run 包含完整样式和 word_style
- segments = []
- for run in para.runs:
- if not run.text:
- continue
-
- style = {}
-
- # 字体
- font_name = _get_font_name(run)
- if font_name:
- style['font_name'] = font_name
-
- # 字号
- if run.font.size:
- style['font_size'] = run.font.size.pt
-
- # 加粗
- if run.bold:
- style['bold'] = True
-
- # 斜体
- if run.italic:
- style['italic'] = True
-
- # 删除线
- if run.font.strike:
- style['strike'] = True
-
- # 下划线
- if run.underline:
- style['underline'] = True
-
- # 颜色
- if run.font.color and run.font.color.rgb:
- style['color'] = str(run.font.color.rgb)
-
- # 提取 word_style(字符样式或段落样式)
- word_style = None
- if run.style:
- word_style = run.style.name
- else:
- # run 没有独立样式,使用段落样式
- word_style = para.style.name if para.style else None
-
- segment = {
- 'text': run.text,
- 'style': style
- }
-
- # 添加 word_style(方案 A:总是添加)
- if word_style:
- segment['word_style'] = word_style
-
- segments.append(segment)
-
- return segments if segments else text
- def _extract_table(table, doc=None) -> dict:
- """提取表格内容(包含合并单元格和尺寸信息)- 完整修复版
-
- Args:
- table: python-docx 表格对象
- doc: python-docx 文档对象(用于获取样式名称)
- """
- rows_data = []
-
- # 提取表格列宽(从 tblGrid)
- col_widths = []
- tbl_elem = table._element
- tbl_grid = tbl_elem.find(qn('w:tblGrid'))
- if tbl_grid is not None:
- for grid_col in tbl_grid.findall(qn('w:gridCol')):
- width = grid_col.get(qn('w:w'))
- if width:
- # twips 转 pt (1 pt = 20 twips)
- col_widths.append(int(width) / 20)
-
- # 创建 XML 元素到 python-docx 单元格对象的映射
- cell_map = {}
- for row in table.rows:
- for cell in row.cells:
- cell_map[id(cell._element)] = cell
-
- # 第一遍:从 XML 直接读取,建立列索引到行合并信息的映射
- # col_index -> [{start_row, end_row}, ...] # 可能有多个合并区间
- vmerge_map = {}
-
- trs = tbl_elem.findall(qn('w:tr'))
- for row_idx, tr in enumerate(trs):
- tcs = tr.findall(qn('w:tc'))
- col_offset = 0
-
- for tc in tcs:
- tcPr = tc.find(qn('w:tcPr'))
-
- colspan = 1
- has_vmerge_restart = False
- has_vmerge_continue = False
- is_empty = False
-
- if tcPr is not None:
- # 列合并
- grid_span = tcPr.find(qn('w:gridSpan'))
- if grid_span is not None:
- colspan = int(grid_span.get(qn('w:val')))
-
- # 行合并
- v_merge = tcPr.find(qn('w:vMerge'))
- if v_merge is not None:
- v_merge_val = v_merge.get(qn('w:val'))
- if v_merge_val == 'restart':
- has_vmerge_restart = True
- else:
- # 'continue' 或 None/空字符串都表示继续合并
- has_vmerge_continue = True
-
- # 检查是否为空单元格(用于判断行合并)
- paras = tc.findall(qn('w:p'))
- text_parts = []
- for p in paras:
- runs = p.findall(qn('w:r'))
- for r in runs:
- ts = r.findall(qn('w:t'))
- for t in ts:
- if t.text and t.text.strip():
- text_parts.append(t.text)
- is_empty = len(text_parts) == 0
-
- # 处理行合并逻辑 - 记录所有合并区间
- if col_offset not in vmerge_map:
- vmerge_map[col_offset] = []
-
- merges = vmerge_map[col_offset]
-
- if has_vmerge_restart:
- # 开始新的行合并
- merges.append({
- 'start_row': row_idx,
- 'end_row': row_idx # 初始结束行等于开始行,后续会扩展
- })
- elif has_vmerge_continue:
- # 明确标记为 continue - 扩展最后一个合并
- if merges:
- merges[-1]['end_row'] = row_idx
- # 注意:移除了 "is_empty" 的判断,因为空单元格不一定意味着合并
-
- col_offset += colspan
-
- # 第二遍:完全从 XML 提取单元格数据
- for row_idx, tr in enumerate(trs):
- cells_data = []
-
- # 提取行高(从 python-docx,因为 XML 提取行高比较复杂)
- row_height = None
- if row_idx < len(table.rows):
- row = table.rows[row_idx]
- if row.height:
- row_height = row.height.pt
-
- # 遍历 XML 的 tc 元素
- tcs = tr.findall(qn('w:tc'))
- col_offset = 0
-
- for tc in tcs:
- # 首先提取 colspan 和 vMerge 信息
- tcPr = tc.find(qn('w:tcPr'))
-
- # 提取 colspan
- colspan = 1
- if tcPr is not None:
- grid_span = tcPr.find(qn('w:gridSpan'))
- if grid_span is not None:
- colspan = int(grid_span.get(qn('w:val')))
-
- # 检查 vMerge - 如果是 continue,跳过这个单元格
- is_vmerge_continue = False
- if tcPr is not None:
- v_merge = tcPr.find(qn('w:vMerge'))
- if v_merge is not None:
- v_merge_val = v_merge.get(qn('w:val'))
- # 'continue' 或 None/空字符串都表示继续合并
- if v_merge_val != 'restart':
- is_vmerge_continue = True
-
- if is_vmerge_continue:
- # 这是被合并的单元格,跳过
- col_offset += colspan
- continue
-
- # 不需要跳过被占用的列,因为 XML 中已经包含了占位符
- # (上面的 is_vmerge_continue 检查已经处理了)
-
- # 检查是否为空单元格
- is_empty = True
- paras = tc.findall(qn('w:p'))
- text_parts = []
- for p in paras:
- runs = p.findall(qn('w:r'))
- for r in runs:
- ts = r.findall(qn('w:t'))
- for t in ts:
- if t.text and t.text.strip():
- is_empty = False
- text_parts.append(t.text)
-
- # 判断是否应该提取此单元格
- should_extract = True
- rowspan = 1
-
- # 查找该列该行所在的合并区间
- if col_offset in vmerge_map:
- merges = vmerge_map[col_offset]
- for merge in merges:
- if merge['start_row'] == row_idx:
- # 这是合并的起始行
- rowspan = merge['end_row'] - merge['start_row'] + 1
- break
- elif row_idx > merge['start_row'] and row_idx <= merge['end_row']:
- # 这是被合并的行
- should_extract = False # 跳过被合并的单元格
- break
-
- if should_extract:
- # 从 XML 提取文本(支持富文本)
- cell_text_segments = []
- for p in paras:
- para_segments = []
- runs = p.findall(qn('w:r'))
-
- for r in runs:
- # 提取文本
- run_text = []
- for t in r.findall(qn('w:t')):
- if t.text:
- run_text.append(t.text)
-
- if run_text:
- # 提取 run 级样式
- run_style = {}
- rPr = r.find(qn('w:rPr'))
- if rPr is not None:
- # 加粗
- if rPr.find(qn('w:b')) is not None:
- run_style['bold'] = True
-
- # 斜体
- if rPr.find(qn('w:i')) is not None:
- run_style['italic'] = True
-
- # 下划线
- if rPr.find(qn('w:u')) is not None:
- run_style['underline'] = True
-
- # 字号
- sz = rPr.find(qn('w:sz'))
- if sz is not None:
- size_val = sz.get(qn('w:val'))
- if size_val:
- run_style['font_size'] = int(size_val) / 2 # 半磅转磅
-
- # 颜色
- color = rPr.find(qn('w:color'))
- if color is not None:
- color_val = color.get(qn('w:val'))
- if color_val and color_val != 'auto':
- run_style['color'] = color_val
-
- para_segments.append({
- 'text': ''.join(run_text),
- 'style': run_style
- })
-
- if para_segments:
- cell_text_segments.extend(para_segments)
-
- # 合并文本
- if len(cell_text_segments) == 0:
- text_content = ""
- elif len(cell_text_segments) == 1 and not cell_text_segments[0]['style']:
- # 纯文本
- text_content = cell_text_segments[0]['text']
- else:
- # 富文本或多个片段 - 简化处理:合并为纯文本
- text_content = ''.join(seg['text'] for seg in cell_text_segments)
-
- # 提取单元格样式(从第一个段落的第一个 run)
- cell_style = {}
- cell_word_style = None
-
- # 尝试使用 python-docx API 获取样式
- cell_obj = cell_map.get(id(tc))
- if cell_obj and cell_obj.paragraphs:
- first_para = cell_obj.paragraphs[0]
- if first_para.style:
- cell_word_style = first_para.style.name
-
- # 如果没有通过 API 获取到,尝试从 XML 获取
- if not cell_word_style and paras:
- first_p = paras[0]
- pPr = first_p.find(qn('w:pPr'))
-
- if pPr is not None:
- # 段落样式名称 - 从 XML 获取样式 ID
- pStyle = pPr.find(qn('w:pStyle'))
- if pStyle is not None:
- style_id = pStyle.get(qn('w:val'))
- # 尝试从已知的样式映射中查找
- # 注意:这里只能使用样式 ID 作为备选
- cell_word_style = style_id
-
- # 提取其他样式属性
- if paras:
- first_p = paras[0]
- pPr = first_p.find(qn('w:pPr'))
-
- if pPr is not None:
- # 对齐方式
- jc = pPr.find(qn('w:jc'))
- if jc is not None:
- align_val = jc.get(qn('w:val'))
- align_map = {'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify'}
- cell_style['align'] = align_map.get(align_val, 'left')
-
- # 从第一个 run 提取样式
- runs = first_p.findall(qn('w:r'))
- if runs:
- first_r = runs[0]
- rPr = first_r.find(qn('w:rPr'))
- if rPr is not None:
- # 加粗
- if rPr.find(qn('w:b')) is not None:
- cell_style['bold'] = True
-
- # 斜体
- if rPr.find(qn('w:i')) is not None:
- cell_style['italic'] = True
-
- # 下划线
- if rPr.find(qn('w:u')) is not None:
- cell_style['underline'] = True
-
- # 字号
- sz = rPr.find(qn('w:sz'))
- if sz is not None:
- size_val = sz.get(qn('w:val'))
- if size_val:
- cell_style['font_size'] = int(size_val) / 2
-
- # 颜色
- color = rPr.find(qn('w:color'))
- if color is not None:
- color_val = color.get(qn('w:val'))
- if color_val and color_val != 'auto':
- cell_style['color'] = color_val
-
- # 字体(复杂,需要处理主题字体)
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- font_name = (rFonts.get(qn('w:eastAsia')) or
- rFonts.get(qn('w:ascii')) or
- rFonts.get(qn('w:hAnsi')))
- if font_name:
- cell_style['font_name'] = font_name
-
- # 提取单元格宽度
- cell_width = None
- if tcPr is not None:
- tcW = tcPr.find(qn('w:tcW'))
- if tcW is not None:
- width_val = tcW.get(qn('w:w'))
- width_type = tcW.get(qn('w:type'))
- if width_val and width_type != 'pct':
- cell_width = int(width_val) / 20
-
- if cell_width is None and col_offset < len(col_widths):
- if colspan == 1:
- cell_width = col_widths[col_offset]
- else:
- cell_width = sum(col_widths[col_offset:col_offset + colspan])
-
- # 构建单元格数据
- cell_data = {
- 'text': text_content,
- 'rowspan': rowspan,
- 'colspan': colspan,
- 'col_index': col_offset, # 记录该单元格的绝对列位置
- 'style': cell_style
- }
-
- if cell_word_style:
- cell_data['word_style'] = cell_word_style
-
- if cell_width is not None:
- cell_data['width'] = round(cell_width, 2)
-
- cells_data.append(cell_data)
-
- col_offset += colspan
-
- # 构建行数据
- row_data = {'cells': cells_data}
- if row_height is not None:
- row_data['height'] = round(row_height, 2)
-
- rows_data.append(row_data)
-
- return {
- 'rows': rows_data,
- 'col_widths': [round(w, 2) for w in col_widths] if col_widths else None
- }
|