"""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 _extract_font_from_rfonts(rFonts, theme_fonts: dict = None): """从 w:rFonts 元素提取字体(优先级: eastAsia → 主题引用 → ascii/hAnsi)""" if rFonts is None: return None # 优先 eastAsia(中文) if font := rFonts.get(qn('w:eastAsia')): return font # 主题字体引用 if theme_fonts: if theme_key := rFonts.get(qn('w:eastAsiaTheme')): if theme_font := theme_fonts.get(theme_key): return theme_font # 回退到 ascii/hAnsi(西文) return rFonts.get(qn('w:ascii')) or rFonts.get(qn('w:hAnsi')) def _get_font_name(run, theme_fonts: dict = None): """获取 run 的字体名(优先 eastAsia,其次 ascii,支持主题字体)""" if theme_fonts is None: theme_fonts = _current_theme_fonts # 从 run 的 XML 提取字体 if hasattr(run._element, 'rPr') and run._element.rPr is not None: rFonts = run._element.rPr.find(qn('w:rFonts')) if font := _extract_font_from_rfonts(rFonts, theme_fonts): return font # 特殊情况:只定义了 ascii/hAnsi 但没有 eastAsia,返回 None 让调用者从段落样式提取 if rFonts is not None and (rFonts.get(qn('w:ascii')) or rFonts.get(qn('w:hAnsi'))): return None # 回退到标准 API return run.font.name def _get_paragraph_style_font(para, theme_fonts: dict = None): """从段落样式中提取字体(递归查找基础样式)""" if theme_fonts is None: theme_fonts = _current_theme_fonts try: if not para.style or not hasattr(para.style, 'element'): return None return _get_style_font_recursive(para.style, theme_fonts) except Exception: return None def _get_style_font_recursive(style, theme_fonts: dict = None, depth: int = 0): """递归查找样式字体(限制深度防止死循环)""" if depth > 10 or not style or not hasattr(style, 'element'): return None try: rPr = style.element.find(qn('w:rPr')) if rPr is not None: rFonts = rPr.find(qn('w:rFonts')) if font := _extract_font_from_rfonts(rFonts, theme_fonts): return font # 递归查找基础样式 if hasattr(style, 'base_style') and style.base_style: return _get_style_font_recursive(style.base_style, theme_fonts, depth + 1) 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 None: return formatting # 检查加粗、斜体(w:val 为 None/'1'/'true' 表示启用) for prop_name in ['b', 'i']: if elem := rPr.find(qn(f'w:{prop_name}')): val = elem.get(qn('w:val')) if val is None or val in ('1', 'true'): formatting[{'b': 'bold', 'i': 'italic'}[prop_name]] = True # 检查下划线(有多种类型) if underline_elem := rPr.find(qn('w:u')): 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)) _current_theme_fonts = _load_theme_fonts(docx_path) # 初始化解析上下文 context = _init_parse_context() # 提取图片映射 image_map = _build_image_map(doc) # 收集文档元素 elements = _collect_document_elements(doc) # 建立段落索引映射 para_idx_map = _build_paragraph_index_map(elements) # 转换元素为 blocks blocks = _convert_elements_to_blocks(elements, context, image_map, para_idx_map) return blocks def _init_parse_context(): """初始化解析上下文""" return { 'blocks': [], 'block_order': 0, 'heading_counters': {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}, 'type_counters': {'paragraph': 0, 'image': 0, 'table': 0}, 'parent_stack': [] } def _build_image_map(doc): """构建图片位置映射""" 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) return image_map def _collect_document_elements(doc): """收集文档中的所有元素(段落、表格、SDT)""" 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'): if para := para_map.get(child): elements.append(('para', para)) elif tag.endswith('tbl'): if table := table_map.get(child): elements.append(('table', table)) elif tag.endswith('sdt'): elements.append(('sdt', child)) return elements def _build_paragraph_index_map(elements): """建立段落索引映射""" para_idx_map = {} actual_para_idx = 0 for elem_idx, (elem_type, elem) in enumerate(elements): if elem_type == "para": para_idx_map[actual_para_idx] = elem_idx actual_para_idx += 1 return para_idx_map def _convert_elements_to_blocks(elements, context, image_map, para_idx_map): """将元素列表转换为 blocks""" for elem_idx, (elem_type, elem) in enumerate(elements): if elem_type == "para": _process_paragraph_element(elem, elem_idx, context, image_map, para_idx_map) elif elem_type == "table": _process_table_element(elem, context) elif elem_type == "sdt": _process_sdt_element(elem, context) return context['blocks'] def _process_paragraph_element(para, elem_idx, context, image_map, para_idx_map): """处理段落元素""" style_name = para.style.name if para.style else "Normal" level = _identify_heading_level(para, style_name) if level: _process_heading_paragraph(para, style_name, level, context) else: _process_normal_paragraph(para, style_name, elem_idx, context, image_map, para_idx_map) def _process_heading_paragraph(para, style_name, level, context): """处理标题段落""" content = _extract_rich_text(para) if not content: return # 跳过空标题 # 计算 index 并更新计数器 index = context['heading_counters'][level] * 100 context['heading_counters'][level] += 1 # 更新父标题栈 parent_stack = context['parent_stack'] 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}', 'block_order': context['block_order'] * 100, 'type': 'heading', 'level': level, 'index': index, 'content': content, 'word_style': style_name, 'style': para_style, 'metadata': {'parent_heading_id': parent_id} } context['blocks'].append(block) parent_stack.append({'id': block['id'], 'level': level}) context['block_order'] += 1 def _process_normal_paragraph(para, style_name, elem_idx, context, image_map, para_idx_map): """处理普通段落(包括空行)""" content = _extract_rich_text(para) para_style = _extract_paragraph_format(para) # 查找当前段落索引 current_para_idx = None for p_idx, e_idx in para_idx_map.items(): if e_idx == elem_idx: current_para_idx = p_idx break # 空行处理 if not content and (current_para_idx is None or current_para_idx - 1 not in image_map): _create_paragraph_block('', style_name, para_style, context) elif content: # 有内容的段落 block_style = {} if isinstance(content, list) else para_style _create_paragraph_block(content, style_name, block_style, context) # 处理段落后的图片 if current_para_idx is not None and current_para_idx in image_map: for img in image_map[current_para_idx]: _create_image_block(img, context) def _create_paragraph_block(content, style_name, style, context): """创建段落 block""" parent_id = context['parent_stack'][-1]['id'] if context['parent_stack'] else None index = context['type_counters']['paragraph'] * 100 context['type_counters']['paragraph'] += 1 block = { 'id': f'block-p-{index}', 'block_order': context['block_order'] * 100, 'type': 'paragraph', 'level': 0, 'index': index, 'content': content, 'word_style': style_name, 'style': style, 'metadata': {'parent_heading_id': parent_id} } context['blocks'].append(block) context['block_order'] += 1 def _create_image_block(img, context): """创建图片 block""" parent_id = context['parent_stack'][-1]['id'] if context['parent_stack'] else None index = context['type_counters']['image'] * 100 context['type_counters']['image'] += 1 block = { 'id': f'block-img-{index}', 'block_order': context['block_order'] * 100, 'type': 'image', 'level': 0, '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 } } context['blocks'].append(block) context['block_order'] += 1 def _process_table_element(table, context): """处理表格元素""" table_content = _extract_table(table, None) parent_id = context['parent_stack'][-1]['id'] if context['parent_stack'] else None # 计算表格列数 rows = table_content.get('rows', []) max_cols = max( (sum(cell.get('colspan', 1) for cell in row_data.get('cells', [])) for row_data in rows), default=0 ) index = context['type_counters']['table'] * 100 context['type_counters']['table'] += 1 block = { 'id': f'block-table-{index}', 'block_order': context['block_order'] * 100, 'type': 'table', 'level': 0, 'index': index, 'content': table_content, 'word_style': 'Table Grid', 'style': {}, 'metadata': { 'cols': max_cols, 'rows': len(rows), 'table_width': 100, 'table_width_unit': 'percent', 'col_widths': [100 // max_cols] * max_cols if max_cols > 0 else [], 'parent_heading_id': parent_id } } context['blocks'].append(block) context['block_order'] += 1 def _process_sdt_element(sdt, context): """处理 SDT 元素(目录)""" toc_block = _extract_toc_from_sdt(sdt, context['block_order'], context['parent_stack']) if toc_block: context['blocks'].append(toc_block) context['block_order'] += 1 def _extract_toc_from_sdt(sdt, block_order: int, parent_stack: list) -> Optional[dict]: """从 SDT 中提取目录 Block Args: sdt: SDT XML 元素 block_order: 当前 block 顺序 parent_stack: 父标题栈 Returns: TOC Block 字典,如果不是目录则返回 None """ import re # 1. 检查 SDT 内是否包含 TOC 域 instr_texts = sdt.findall('.//' + qn('w:instrText')) has_toc = False toc_levels = "1-1" # 默认值 use_hyperlinks = False use_page_numbers = True hide_page_numbers_in_web = False use_outline_levels = False for instr in instr_texts: text = instr.text if instr.text else '' if 'TOC' in text.upper(): has_toc = True # 提取层级参数 # 例如:TOC \o "1-3" \h \z \u match = re.search(r'\\o\s+"(\d+-\d+)"', text) if match: toc_levels = match.group(1) # 提取开关 use_hyperlinks = '\\h' in text hide_page_numbers_in_web = '\\z' in text use_outline_levels = '\\u' in text break if not has_toc: return None # 2. 提取目录标题(SDT 内第一个段落) toc_title = "目录" paragraphs = sdt.findall('.//' + qn('w:p')) if paragraphs: first_para = paragraphs[0] text_elems = first_para.findall('.//' + qn('w:t')) title_text = ''.join([t.text for t in text_elems if t.text]) if title_text: toc_title = title_text.strip() # 3. 获取父标题 ID parent_id = parent_stack[-1]['id'] if parent_stack else None # 4. 构建 TOC Block toc_block = { 'id': 'block-toc-0', 'block_order': block_order * 100, 'type': 'toc', 'level': 0, 'index': 0, 'content': { 'title': toc_title }, 'word_style': 'TOC', 'metadata': { 'toc_config': { 'levels': toc_levels, 'use_hyperlinks': use_hyperlinks, 'use_page_numbers': use_page_numbers, 'hide_page_numbers_in_web': hide_page_numbers_in_web, 'use_outline_levels': use_outline_levels, 'show_leader_dots': True, 'leader_char': '.' }, 'is_auto_generated': True, 'readonly': True, 'deletable': True } } return toc_block 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 }