| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036 |
- """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)
- parent_id = parent_stack[-1]['id'] if parent_stack else None
-
- # 计算表格元数据
- rows = table_content.get('rows', [])
- cols = len(rows[0]['cells']) if rows 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) -> dict:
- """提取表格内容(包含合并单元格和尺寸信息)- 完整修复版"""
- 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 直接读取,建立列索引到行合并信息的映射
- # 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
-
- if paras:
- first_p = paras[0]
- pPr = first_p.find(qn('w:pPr'))
-
- if pPr is not None:
- # 段落样式名称
- pStyle = pPr.find(qn('w:pStyle'))
- if pStyle is not None:
- cell_word_style = pStyle.get(qn('w:val'))
-
- # 对齐方式
- 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
- }
|