| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982 |
- """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 文档中加载主题字体定义
-
- Args:
- docx_path: Word 文档路径
-
- Returns:
- 主题字体映射字典,例如: {'minorEastAsia': '宋体', 'majorEastAsia': '黑体'}
- """
- # 检查缓存
- 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 字体(用于中文字体)
-
- Args:
- element: rPr XML 元素
-
- Returns:
- eastAsia 字体名称或 None
- """
- 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,支持主题字体)
-
- 特殊处理:如果 run 只定义了 ascii 字体(如 Times New Roman),
- 但没有定义 eastAsia,则忽略 run 的字体,返回 None 让其从样式继承中文字体。
- 这样可以正确处理混合语言的字体继承。
-
- Args:
- run: python-docx Run 对象
- theme_fonts: 主题字体映射字典(可选,默认使用全局的 _current_theme_fonts)
-
- Returns:
- 字体名称或 None
- """
- 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(中文字体),如果没有则查找基础样式的 eastAsia
-
- Args:
- para: python-docx 段落对象
-
- Returns:
- 字体名称或 None
- """
- 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 字体(用于基础样式查找)
-
- Args:
- style: python-docx Style 对象
-
- Returns:
- eastAsia 字体名称或 None
- """
- 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):
- """从样式中提取格式属性(加粗、斜体、下划线等)
-
- Args:
- style: python-docx Style 对象
-
- Returns:
- 格式属性字典 {'bold': True/False, 'italic': True/False, ...}
- """
- 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 列表
-
- Args:
- docx_path: Word 文档路径
-
- Returns:
- Block 列表,每个 Block 包含 id, block_order, type, level, index, content 等字段
- """
- 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]:
- """识别段落的标题级别
-
- Args:
- para: python-docx 段落对象
- style_name: 样式名称
-
- Returns:
- 标题级别(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)
-
- Args:
- para: python-docx 段落对象
-
- Returns:
- 段落样式字典(只包含设计文档 5.3 中可支持的属性)
- """
- 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):从段落样式中提取默认字体和字号
- style_font = _get_paragraph_style_font(para)
- if style_font:
- style['font_name'] = style_font
-
- # 尝试从段落样式中提取字号
- 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:
- """提取段落的富文本内容
-
- Args:
- para: python-docx 段落对象
-
- Returns:
- 纯文本字符串 或 富文本片段列表
- - 纯文本:所有 runs 样式相同,返回字符串
- - 富文本:runs 样式不同,返回数组,每个元素包含完整样式
- """
- 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:
- """提取表格内容
-
- Args:
- table: python-docx 表格对象
-
- Returns:
- 表格数据字典,包含合并单元格和尺寸信息
- """
- 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)
-
- # 用于跟踪行合并(vMerge)
- # col_index -> {start_row, rowspan_count}
- vmerge_tracking = {}
-
- for row_idx, row in enumerate(table.rows):
- cells_data = []
-
- # 提取行高
- row_height = None
- if row.height:
- row_height = row.height.pt
-
- col_offset = 0 # 当前列偏移(考虑 colspan)
- seen_cells = set() # 用于去重(基于对象 ID)
-
- for cell_idx, cell in enumerate(row.cells):
- # 去重:跳过重复的单元格对象(合并单元格会返回同一个对象)
- cell_id = id(cell)
- if cell_id in seen_cells:
- continue
- seen_cells.add(cell_id)
- # 提取单元格文本
- cell_text = []
- for para in cell.paragraphs:
- para_text = _extract_rich_text(para)
- if para_text:
- cell_text.append(para_text if isinstance(para_text, str) else para_text)
-
- # 检测单元格样式(从第一个段落的第一个 run)
- cell_style = {}
- cell_word_style = None # 单元格的 word_style
-
- if cell.paragraphs:
- first_para = cell.paragraphs[0]
-
- # 提取 word_style(段落样式)
- if first_para.style:
- cell_word_style = first_para.style.name
-
- # 从样式中提取格式(加粗、斜体等)
- style_formatting = _get_style_formatting(first_para.style)
- # 将样式中定义的格式作为基础
- cell_style.update(style_formatting)
-
- if first_para.runs:
- first_run = first_para.runs[0]
-
- # 加粗(run 明确设置会覆盖样式)
- if first_run.bold is True:
- cell_style['bold'] = True
- elif first_run.bold is False:
- # 明确设置为不加粗,移除样式的加粗
- cell_style.pop('bold', None)
- # 如果 run.bold 为 None,保持样式中的设置
-
- # 斜体(run 明确设置会覆盖样式)
- if first_run.italic is True:
- cell_style['italic'] = True
- elif first_run.italic is False:
- cell_style.pop('italic', None)
-
- # 下划线(run 明确设置会覆盖样式)
- if first_run.underline:
- cell_style['underline'] = True
-
- # 字体
- font_name = _get_font_name(first_run)
- if font_name:
- cell_style['font_name'] = font_name
-
- # 字号
- if first_run.font.size:
- cell_style['font_size'] = first_run.font.size.pt
-
- # 颜色
- if first_run.font.color and first_run.font.color.rgb:
- cell_style['color'] = str(first_run.font.color.rgb)
-
- # 对齐方式
- if first_para.alignment is not None:
- align_map = {0: 'left', 1: 'center', 2: 'right', 3: 'justify'}
- cell_style['align'] = align_map.get(first_para.alignment, 'left')
-
- # 合并多个段落的文本
- if len(cell_text) == 1:
- text_content = cell_text[0]
- elif len(cell_text) > 1:
- # 多个段落,用换行符连接
- text_content = ' '.join(str(t) for t in cell_text)
- else:
- text_content = ""
-
- # 提取合并信息
- tc_elem = cell._tc
- tcPr = tc_elem.find(qn('w:tcPr'))
-
- colspan = 1
- rowspan = 1
- is_vmerge_continue = False
-
- if tcPr is not None:
- # 列合并 (gridSpan)
- grid_span = tcPr.find(qn('w:gridSpan'))
- if grid_span is not None:
- colspan = int(grid_span.get(qn('w:val')))
-
- # 行合并 (vMerge)
- 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':
- # 行合并起始
- vmerge_tracking[col_offset] = {
- 'start_row': row_idx,
- 'count': 1
- }
- elif v_merge_val is None:
- # 行合并继续(被合并的单元格)
- is_vmerge_continue = True
- if col_offset in vmerge_tracking:
- vmerge_tracking[col_offset]['count'] += 1
-
- # 计算实际的 rowspan
- if col_offset in vmerge_tracking:
- if vmerge_tracking[col_offset]['start_row'] == row_idx:
- # 这是起始行,后续会更新 rowspan
- rowspan = vmerge_tracking[col_offset]['count']
- elif is_vmerge_continue:
- # 这是被合并的单元格,标记为 0(表示被合并)
- rowspan = 0
-
- # 提取单元格宽度
- 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':
- # twips 转 pt
- 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])
-
- # 构建单元格数据(方案 D:包含 word_style)
- cell_data = {
- 'text': text_content,
- 'rowspan': rowspan,
- 'colspan': colspan,
- 'style': cell_style
- }
-
- # 添加 word_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)
-
- # 第二遍:更新 rowspan 值
- for col_idx, info in vmerge_tracking.items():
- start_row = info['start_row']
- count = info['count']
- # 找到起始行的单元格并更新 rowspan
- if start_row < len(rows_data):
- for cell in rows_data[start_row]['cells']:
- # 简化:假设 col_idx 对应 cells 索引(实际可能需要考虑 colspan)
- if 'rowspan' in cell and cell['rowspan'] > 0:
- cell['rowspan'] = count
- break
-
- return {
- 'rows': rows_data,
- 'col_widths': [round(w, 2) for w in col_widths] if col_widths else None
- }
|