| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137 |
- """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
- }
|