在日常文档处理中,我们经常需要将Word文档转换为HTML格式以便在网页上展示。虽然存在多种第三方库可以实现这一功能,但Python的标准库同样提供了强大而灵活的工具来处理.docx文件。本文将详细解析一个使用纯Python标准库实现的Word到HTML转换脚本,展示如何直接处理.docx文件的内部结构。
1. Word文档格式基础:了解.docx文件本质
.docx文件本质上是一个ZIP压缩包,其中包含多个XML文件和其他资源。这种格式基于Office Open XML标准,使得我们可以通过解压和解析XML来直接访问文档内容。与使用第三方库如python-docx相比,直接使用标准库处理.docx文件提供了更底层的控制能力,能够实现更精细的转换控制。
脚本首先利用zipfile模块解压.docx文件,然后通过xml.etree.ElementTree解析XML结构。这种方法不依赖任何外部依赖,保证了代码的轻量性和可移植性。
2. 脚本核心功能解析
2.1 文档结构分析
脚本通过list_docx_contents函数列出.docx文件中的所有内部文件,这些文件包括文档主体、样式、关系、媒体资源等。这种分析有助于理解Word文档的复杂结构,为准确提取内容奠定基础。
python
def list_docx_contents(filepath):
"""列出 Word 文档中的所有文件"""
with zipfile.ZipFile(filepath, 'r') as docx:
return docx.namelist()
2.2 文本内容提取
通过extract_text_from_docx函数,脚本从word/document.xml中提取所有文本内容。Word文档使用特定的XML命名空间和结构来存储文本,脚本需要正确解析这些结构才能准确提取内容。
2.3 文档结构解析
analyze_docx_structure函数提供了深入的文档结构分析,包括段落样式、列表属性、文本格式等。这一功能对于理解Word文档的视觉层次结构至关重要,是实现高质量HTML转换的基础。
3. 转换过程关键技术细节
3.1 段落处理与格式转换
脚本的process_paragraph函数负责将Word段落转换为相应的HTML元素。它识别各种段落样式(如标题、正文、列表项)并应用对应的HTML标签:
- 标题段落(Heading1-Heading6)转换为
<h1>-<h6>标签 - 普通段落转换为
<p>标签 - 列表项转换为
<li>标签,并自动管理<ul>或<ol>父容器
此外,该函数还处理段落级格式设置,包括对齐方式、缩进、行间距和背景色等。
3.2 文本格式处理
process_paragraph_formatting函数深入处理段落内的丰富文本格式:
- 字体样式 :识别粗体(
<strong>)、斜体(<em>)、下划线(<u>)和删除线 - 上下标 :正确处理上标(
<sup>)和下标(<sub>) - 字体属性:提取字体大小、颜色、高亮和字符间距
- 特殊内容:处理超链接、图片、表单字段和修订标记(删除和插入内容)
这种精细的格式处理确保了转换后的HTML能够保留原文档的视觉特征。
3.3 表格转换
process_table函数处理Word表格的复杂结构,支持:
- 基本表格结构(
<table>、<tr>、<td>) - 合并单元格(跨行rowspan和跨列colspan)
- 表头识别(第一行自动使用
<th>标签)
该函数使用矩阵跟踪法正确处理单元格合并关系,确保复杂表格结构的准确转换。
3.4 图片和超链接处理
脚本能够提取文档中的图片并将其转换为Base64编码的HTML图片标签,同时正确处理内部和外部超链接:
python
def process_image(docx, image_id, doc_rels):
"""处理图片"""
# 查找图片关系并转换为Base64编码
# ...
def process_hyperlink(rel_id, text, doc_rels):
"""处理超链接"""
# 查找关系中的目标URL并生成<a>标签
# ...
这种方法确保图片和链接在HTML中正确显示,即使图片是嵌入在.docx文件中的。
4. 高级功能实现
4.1 页眉页脚处理
process_headers_footers函数单独处理文档的页眉和页脚内容,将其转换为具有特定样式的HTML元素,保持文档的完整结构。
4.2 修订标记处理
脚本能够识别和处理Word的修订标记(跟踪更改),将删除的内容显示为带删除线的文本,插入的内容显示为带背景高亮,这在协作编辑环境中特别有用。
4.3 样式应用
生成的HTML包含完整的CSS样式定义,确保转换结果在浏览器中的显示效果接近原Word文档。脚本允许自定义样式表,满足不同的视觉需求。
5. 使用方法和输出示例
运行脚本非常简单,只需执行主函数即可完成整个转换过程:
python
def main():
print("Word 文档处理工具")
print_docx_info('test_input.docx') # 分析文档结构
html_content = docx_to_html('test_input.docx') # 转换为HTML
save_html('output.html', html_content) # 保存结果
脚本会输出文档结构分析、转换进度和结果文件路径,提供完整的处理反馈。
6. 与其他方法的比较
与使用第三方库(如python-docx、Apache POI等)相比,这种基于标准库的方法具有独特优势:
- 零依赖:仅使用Python标准库,无需安装额外包
- 深度控制:直接访问.docx文件内部结构,可实现高度定制化转换
- 学习价值:深入了解Word文档的底层结构和Open XML标准
然而,这种方法也需要更多的代码量和对XML结构的深入理解,在开发效率上可能不如使用成熟的第三方库。
7. 实际应用场景
这种Word到HTML转换技术在多个领域有广泛应用:
- 内容管理系统:将Word格式的内容转换为网页展示
- 文档自动化:批量处理大量Word文档并发布为网页
- 协作平台:保留修订记录和评论的文档转换
- 电子邮件模板:将Word设计的模板转换为HTML邮件格式
8. 总结与扩展建议
本文详细解析了使用Python标准库将Word文档转换为HTML的完整实现。这种方法不依赖任何第三方库,通过直接解析.docx文件的ZIP和XML结构,实现了高质量的文档转换。
脚本的主要优点包括完整的格式支持、精细的转换控制和零外部依赖。如果需要进一步扩展功能,可以考虑添加对更复杂元素(如图表、公式、注释)的支持,或优化输出HTML的样式和响应式设计。
通过深入理解这一实现,开发者可以更好地掌握Word文档处理的底层原理,并根据具体需求定制更专业的文档转换解决方案。
python
# Word 文档处理脚本
# 使用 Python 标准库处理 .docx 文件并转换为 HTML
import zipfile
import xml.etree.ElementTree as ET
import html
import base64
import os
def list_docx_contents(filepath):
"""列出 Word 文档中的所有文件"""
with zipfile.ZipFile(filepath, 'r') as docx:
return docx.namelist()
def extract_text_from_docx(filepath):
"""从 Word 文档中提取文本内容"""
with zipfile.ZipFile(filepath) as docx:
# 读取 document.xml
xml_content = docx.read('word/document.xml')
tree = ET.fromstring(xml_content)
# 定义命名空间
namespaces = {
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
}
# 提取所有段落文本
paragraphs = tree.findall('.//w:p', namespaces)
text_parts = []
for para in paragraphs:
# 提取段落中所有文本
texts = para.findall('.//w:t', namespaces)
para_text = ''.join([t.text for t in texts if t.text])
if para_text:
text_parts.append(para_text)
return '\n'.join(text_parts)
def analyze_docx_structure(filepath):
"""分析 Word 文档结构"""
with zipfile.ZipFile(filepath) as docx:
# 读取 document.xml
xml_content = docx.read('word/document.xml')
tree = ET.fromstring(xml_content)
# 定义命名空间
namespaces = {
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
}
# 分析段落
paragraphs = tree.findall('.//w:p', namespaces)
print(f"总共找到 {len(paragraphs)} 个段落")
for i, para in enumerate(paragraphs[:20]): # 分析前20个段落
num_pr = para.find('.//w:numPr', namespaces)
p_style_elem = para.find('.//w:pStyle', namespaces)
p_style = p_style_elem.get(f'{{{namespaces["w"]}}}val') if p_style_elem is not None else None
# 检查段落中是否有加粗文本
bold_runs = []
for run in para.findall('.//w:r', namespaces):
text_elem = run.find('.//w:t', namespaces)
bold_elem = run.find('.//w:b', namespaces)
if text_elem is not None and text_elem.text:
bold_runs.append((text_elem.text, bold_elem is not None))
# 提取文本
texts = [r.find('.//w:t', namespaces) for r in para.findall('.//w:r', namespaces)]
para_text = ''.join([t.text for t in texts if t is not None and t.text])
# 检查是否有加粗文本
has_bold = any(bold for _, bold in bold_runs)
# 显示哪些具体文本是加粗的
bold_texts = [text for text, is_bold in bold_runs if is_bold]
print(f"段落 {i}: 样式={p_style}, 列表属性={num_pr is not None}, 有加粗={has_bold}, 加粗文本={bold_texts}, 文本='{para_text[:50]}{'...' if len(para_text) > 50 else ''}'")
def process_hyperlink(rel_id, text, doc_rels):
"""处理超链接"""
# 查找关系中的目标URL
target_url = None
for rel in doc_rels.findall('.//Relationship'):
if rel.get('Id') == rel_id:
target_url = rel.get('Target')
break
if target_url:
return f'<a href="{html.escape(target_url)}">{html.escape(text)}</a>'
else:
return html.escape(text)
def process_image(docx, image_id, doc_rels):
"""处理图片"""
# 查找图片关系
image_path = None
for rel in doc_rels.findall('.//Relationship'):
if rel.get('Id') == image_id:
image_path = rel.get('Target')
break
if not image_path:
return '<!-- 图片未找到 -->'
# 规范化路径(处理类似"../media/image1.png"的相对路径)
if image_path.startswith('..'):
image_path = image_path[3:] # 移除 "../" 前缀
try:
# 从docx文件中读取图片数据
image_data = docx.read(f'word/{image_path}')
# 将图片编码为base64
encoded_image = base64.b64encode(image_data).decode('utf-8')
# 获取图片扩展名以确定MIME类型
ext = os.path.splitext(image_path)[1].lower()
mime_type = 'image/png' # 默认
if ext == '.jpg' or ext == '.jpeg':
mime_type = 'image/jpeg'
elif ext == '.gif':
mime_type = 'image/gif'
elif ext == '.bmp':
mime_type = 'image/bmp'
elif ext == '.svg':
mime_type = 'image/svg+xml'
return f'<img src="data:{mime_type};base64,{encoded_image}" alt="文档图片">'
except Exception as e:
return f'<!-- 图片加载失败: {str(e)} -->'
def process_paragraph_formatting(para, namespaces, docx=None, doc_rels=None):
"""处理段落内的格式化文本(粗体、斜体、颜色等)"""
html_text = ""
last_text = "" # 用于跟踪上一个文本,避免重复
# 遍历段落中的所有文本元素
for run in para.findall('.//w:r', namespaces):
# 检查是否有删除线文本(修订模式下的删除内容)
del_text_elem = run.find('.//w:delText', namespaces)
if del_text_elem is not None and del_text_elem.text is not None:
# 处理删除的文本(带删除线)
del_text = del_text_elem.text
escaped_text = html.escape(del_text)
html_text += f'<span style="text-decoration:line-through;">{escaped_text}</span>'
continue
# 检查是否有插入的文本(修订模式下的新增内容)
ins_elem = run.find('.//w:ins', namespaces)
if ins_elem is not None:
# 获取插入的文本
ins_texts = ins_elem.findall('.//w:t', namespaces)
for ins_text_elem in ins_texts:
if ins_text_elem.text:
ins_text = ins_text_elem.text
escaped_text = html.escape(ins_text)
html_text += f'<span style="background-color:#ccffcc;">{escaped_text}</span>'
continue
# 提取文本
text_elem = run.find('.//w:t', namespaces)
if text_elem is None or text_elem.text is None:
# 检查是否是制表符
if run.find('.//w:tab', namespaces) is not None:
html_text += " " # 处理制表符
continue
# 检查是否是图片
drawing_elem = run.find('.//w:drawing', namespaces)
if drawing_elem is not None and docx is not None and doc_rels is not None:
# 处理图片
blip_elem = drawing_elem.find('.//a:blip', {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'
})
if blip_elem is not None:
embed_id = blip_elem.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
if embed_id:
img_html = process_image(docx, embed_id, doc_rels)
html_text += img_html
continue
# 检查是否是下划线字符(空的文本但有下划线格式)
underline_elem = run.find('.//w:u', namespaces)
if underline_elem is not None:
# 获取下划线类型
underline_val = underline_elem.get(f'{{{namespaces["w"]}}}val')
# 添加表单字段占位符
html_text += '<span class="form-field"> </span>'
continue
text = text_elem.text
# 检查是否是重复文本(有时候Word中会有重复的文本节点)
if text == last_text:
continue
last_text = text
escaped_text = html.escape(text)
# 检查超链接
hyperlink_elem = run.find('.//w:hyperlink', namespaces)
if hyperlink_elem is not None and doc_rels is not None:
rel_id = hyperlink_elem.get(f'{{{namespaces["w"]}}}anchor') or hyperlink_elem.get(f'{{{namespaces["w"]}}}id')
if rel_id:
hyperlink_html = process_hyperlink(rel_id, text, doc_rels)
html_text += hyperlink_html
continue
# 检查格式
bold_elem = run.find('.//w:b', namespaces)
bold = False
if bold_elem is not None:
# 检查是否明确设置了val属性为false
val = bold_elem.get(f'{{{namespaces["w"]}}}val')
if val is None or val != 'false':
bold = True
# 检查字体,如果使用黑体等加粗字体,也应视为加粗
rpr_elem = run.find('.//w:rPr', namespaces)
if rpr_elem is not None:
# 检查字体设置
font_elem = rpr_elem.find('.//w:rFonts', namespaces)
if font_elem is not None:
# 获取各种字体属性
ascii_font = font_elem.get(f'{{{namespaces["w"]}}}ascii')
hansi_font = font_elem.get(f'{{{namespaces["w"]}}}hAnsi')
east_asia_font = font_elem.get(f'{{{namespaces["w"]}}}eastAsia')
# 如果使用黑体等加粗字体,视为加粗
bold_fonts = ['黑体', 'Bold', 'Arial Black', 'Impact']
if (ascii_font and any(bf in ascii_font for bf in bold_fonts)) or \
(hansi_font and any(bf in hansi_font for bf in bold_fonts)) or \
(east_asia_font and any(bf in east_asia_font for bf in bold_fonts)):
bold = True
italic_elem = run.find('.//w:i', namespaces)
italic = False
if italic_elem is not None:
val = italic_elem.get(f'{{{namespaces["w"]}}}val')
if val is None or val != 'false':
italic = True
underline_elem = run.find('.//w:u', namespaces)
underline = False
if underline_elem is not None:
val = underline_elem.get(f'{{{namespaces["w"]}}}val')
if val is None or val != 'none':
underline = True
strike_elem = run.find('.//w:strike', namespaces)
strike = False
if strike_elem is not None:
val = strike_elem.get(f'{{{namespaces["w"]}}}val')
if val is None or val != 'false':
strike = True
# 检查上下标
vert_align_elem = run.find('.//w:vertAlign', namespaces)
vert_align = None
if vert_align_elem is not None:
align_val = vert_align_elem.get(f'{{{namespaces["w"]}}}val')
if align_val == 'superscript':
vert_align = 'sup'
elif align_val == 'subscript':
vert_align = 'sub'
# 检查字体大小
sz_elem = run.find('.//w:sz', namespaces)
font_size = None
if sz_elem is not None:
sz_val = sz_elem.get(f'{{{namespaces["w"]}}}val')
if sz_val:
# Word中的字号是半点为单位,需要转换为pt
font_size = f"{int(sz_val)/2}pt"
# 检查高亮色
highlight_elem = run.find('.//w:highlight', namespaces)
background_color = None
if highlight_elem is not None:
highlight_val = highlight_elem.get(f'{{{namespaces["w"]}}}val')
# 需要将Word颜色名称映射为CSS颜色值
color_map = {
'yellow': '#ffff00',
'green': '#00ff00',
'red': '#ff0000',
'blue': '#0000ff',
'cyan': '#00ffff',
'magenta': '#ff00ff',
'black': '#000000',
'white': '#ffffff'
}
background_color = color_map.get(highlight_val, highlight_val)
# 检查颜色
color_elem = run.find('.//w:color', namespaces)
color = None
if color_elem is not None:
color_val = color_elem.get(f'{{{namespaces["w"]}}}val')
if color_val and color_val != '000000': # 忽略黑色(默认颜色)
# 处理自动颜色
if color_val.lower() == 'auto':
color = 'inherit'
else:
color = f"#{color_val}"
# 检查字符间距
spacing_elem = run.find('.//w:spacing', namespaces)
letter_spacing = None
if spacing_elem is not None:
spacing_val = spacing_elem.get(f'{{{namespaces["w"]}}}val')
if spacing_val:
# Word中字符间距单位是 twentieths of a point (1/1440 inch)
# 转换为CSS的em单位
spacing_em = int(spacing_val) / 120 # 120 = 20 * 6 (approximation)
if spacing_em != 0:
letter_spacing = f"{spacing_em:.2f}em"
# 检查背景色
shd_elem = rpr_elem.find('.//w:shd', namespaces) if rpr_elem is not None else None
shading_color = None
if shd_elem is not None:
shd_fill = shd_elem.get(f'{{{namespaces["w"]}}}fill')
if shd_fill and shd_fill != 'auto' and shd_fill != 'clear':
shading_color = f"#{shd_fill}"
# 构建样式字符串
styles = []
if font_size:
styles.append(f"font-size:{font_size}")
if background_color:
styles.append(f"background-color:{background_color}")
if color and color != 'inherit':
styles.append(f"color:{color}")
elif color == 'inherit':
styles.append(f"color:{color}")
if letter_spacing:
styles.append(f"letter-spacing:{letter_spacing}")
if shading_color:
styles.append(f"background-color:{shading_color}")
style_attr = f' style="{";".join(styles)}"' if styles else ''
# 应用格式
if bold:
escaped_text = f'<strong>{escaped_text}</strong>'
if italic:
escaped_text = f'<em>{escaped_text}</em>'
if underline:
escaped_text = f'<u>{escaped_text}</u>' # 使用标准HTML下划线标签
if strike:
escaped_text = f'<span style="text-decoration:line-through">{escaped_text}</span>'
if vert_align:
escaped_text = f'<{vert_align}>{escaped_text}</{vert_align}>'
if style_attr:
escaped_text = f'<span{style_attr}>{escaped_text}</span>'
html_text += escaped_text
return html_text
def process_paragraph(p, namespaces, docx=None, doc_rels=None):
"""处理段落"""
# 获取段落属性
p_pr = p.find('./w:pPr', namespaces)
# 检查段落样式
p_style_elem = p_pr.find('.//w:pStyle', namespaces) if p_pr is not None else None
p_style = p_style_elem.get(f'{{{namespaces["w"]}}}val') if p_style_elem is not None else None
# 如果是标题样式,直接返回标题标签
if p_style and p_style.startswith('Heading'):
# 处理段落中的文本
formatted_text = process_paragraph_formatting(p, namespaces, docx, doc_rels)
# 根据样式确定标题级别
heading_level = 1
if p_style == 'Heading1':
heading_level = 1
elif p_style == 'Heading2':
heading_level = 2
elif p_style == 'Heading3':
heading_level = 3
elif p_style == 'Heading4':
heading_level = 4
elif p_style == 'Heading5':
heading_level = 5
elif p_style == 'Heading6':
heading_level = 6
return f'<h{heading_level}>{formatted_text}</h{heading_level}>'
# 处理段落样式
style_attrs = []
# 处理文本对齐
jc_elem = p_pr.find('.//w:jc', namespaces) if p_pr is not None else None
if jc_elem is not None:
jc_val = jc_elem.get(f'{{{namespaces["w"]}}}val')
if jc_val == 'center':
style_attrs.append('text-align:center')
elif jc_val == 'right':
style_attrs.append('text-align:right')
elif jc_val == 'both':
style_attrs.append('text-align:justify')
else:
style_attrs.append('text-align:left')
else:
style_attrs.append('text-align:left')
# 处理段落缩进
ind_elem = p_pr.find('.//w:ind', namespaces) if p_pr is not None else None
if ind_elem is not None:
# 左缩进
left_ind = ind_elem.get(f'{{{namespaces["w"]}}}left')
if left_ind:
# Word中单位为twips,1 twip = 1/1440 inch,约等于1/20 pt
style_attrs.append(f'margin-left:{int(left_ind)/20}pt')
# 首行缩进
first_line_ind = ind_elem.get(f'{{{namespaces["w"]}}}firstLine')
if first_line_ind:
style_attrs.append(f'text-indent:{int(first_line_ind)/20}pt')
# 悬挂缩进
hanging_ind = ind_elem.get(f'{{{namespaces["w"]}}}hanging')
if hanging_ind:
# 悬挂缩进需要特殊处理
style_attrs.append(f'text-indent:-{int(hanging_ind)/20}pt;padding-left:{int(hanging_ind)/20}pt')
# 处理行间距
spacing_elem = p_pr.find('.//w:spacing', namespaces) if p_pr is not None else None
if spacing_elem is not None:
# 行间距
line_val = spacing_elem.get(f'{{{namespaces["w"]}}}line')
if line_val:
# Word中的行距单位是twips (1/20 pt)
# 通常240 twips = 12 pt = 单倍行距
line_height = int(line_val) / 240
if line_height > 0:
# 避免重复添加line-height样式
existing_line_height = any(attr.startswith('line-height:') for attr in style_attrs)
if not existing_line_height:
style_attrs.append(f'line-height:{line_height}em')
# 行距规则
line_rule = spacing_elem.get(f'{{{namespaces["w"]}}}lineRule')
if line_rule == 'auto':
# 自动行距已在上面处理
pass
elif line_rule == 'atLeast':
# 最小行距
line_val = spacing_elem.get(f'{{{namespaces["w"]}}}line')
if line_val:
min_line_height = int(line_val) / 240
# 避免重复添加line-height样式
existing_line_height = any(attr.startswith('line-height:') for attr in style_attrs)
if not existing_line_height:
style_attrs.append(f'line-height:min({min_line_height}em)')
elif line_rule == 'exact':
# 固定行距
line_val = spacing_elem.get(f'{{{namespaces["w"]}}}line')
if line_val:
fixed_line_height = int(line_val) / 240
# 避免重复添加line-height样式
existing_line_height = any(attr.startswith('line-height:') for attr in style_attrs)
if not existing_line_height:
style_attrs.append(f'line-height:{fixed_line_height}em')
# 处理段落底纹/背景色
shd_elem = p_pr.find('.//w:shd', namespaces) if p_pr is not None else None
if shd_elem is not None:
shd_fill = shd_elem.get(f'{{{namespaces["w"]}}}fill')
if shd_fill and shd_fill != 'auto' and shd_fill != 'clear':
style_attrs.append(f'background-color:#{shd_fill}')
style_str = f' style="{";".join(style_attrs)}"' if style_attrs else ''
# 处理段落中的文本
formatted_text = process_paragraph_formatting(p, namespaces, docx, doc_rels)
# 检查是否为列表项
num_pr = p_pr.find('.//w:numPr', namespaces) if p_pr is not None else None
if num_pr is not None:
# 获取编号信息以确定是有序还是无序列表
num_id_elem = num_pr.find('.//w:numId', namespaces)
ilvl_elem = num_pr.find('.//w:ilvl', namespaces)
# 默认使用无序列表
list_type = 'ul'
# 如果有编号ID,则尝试判断是否为有序列表
if num_id_elem is not None:
num_id_val = num_id_elem.get(f'{{{namespaces["w"]}}}val')
# 可以根据num_id_val的值来判断是否为有序列表
# 这里简化处理,默认认为所有列表都是无序列表
# 实际项目中可以从word/numbering.xml中获取详细信息
return f'<li{style_str}>{formatted_text}</li>'
else:
return f'<p{style_str}>{formatted_text}</p>'
def process_table(table, namespaces, docx=None, doc_rels=None):
"""处理表格,支持合并单元格等复杂结构"""
html_parts = ['<table>']
# 处理表格行
rows = table.findall('.//w:tr', namespaces)
# 创建一个矩阵来跟踪已被占用的单元格位置,用于处理跨行合并
cell_matrix = [[False for _ in range(20)] for _ in range(len(rows))] # 假设最多20列
for i, row in enumerate(rows):
html_parts.append('<tr>')
# 处理单元格
cells = row.findall('.//w:tc', namespaces)
cell_index = 0 # 当前行的实际单元格索引
matrix_col = 0 # 在矩阵中的列位置
# 找到下一个可用的列位置
while matrix_col < len(cell_matrix[i]) and cell_matrix[i][matrix_col]:
matrix_col += 1
for cell in cells:
# 跳过已经被前面的跨行单元格占据的位置
while matrix_col < len(cell_matrix[i]) and cell_matrix[i][matrix_col]:
matrix_col += 1
# 确定是表头还是普通单元格
cell_tag = 'th' if i == 0 else 'td'
# 检查单元格合并属性
tc_pr = cell.find('.//w:tcPr', namespaces)
colspan = ''
rowspan = ''
if tc_pr is not None:
# 检查水平合并 (gridSpan)
grid_span = tc_pr.find('.//w:gridSpan', namespaces)
if grid_span is not None:
span_val = grid_span.get(f'{{{namespaces["w"]}}}val')
if span_val:
colspan = f' colspan="{span_val}"'
# 标记这些列位置已被占用
span_count = int(span_val)
for col_offset in range(span_count):
if matrix_col + col_offset < len(cell_matrix[i]):
cell_matrix[i][matrix_col + col_offset] = True
# 检查垂直合并 (vMerge)
v_merge = tc_pr.find('.//w:vMerge', namespaces)
if v_merge is not None:
merge_val = v_merge.get(f'{{{namespaces["w"]}}}val')
# 只有当是合并起点时才添加 rowspan
if merge_val == 'restart':
# 查找跨越的行数
rowspan_count = 1
# 计算需要合并多少行
for next_row_idx in range(i + 1, len(rows)):
next_row = rows[next_row_idx]
next_cells = next_row.findall('.//w:tc', namespaces)
# 检查下一行对应位置是否有继续合并的标记
# 先找到下一行中对应位置的单元格
next_cell_found = False
next_cell_index = 0
current_col_pos = 0
# 计算在下一行中对应列位置的单元格
for next_cell in next_cells:
next_tc_pr = next_cell.find('.//w:tcPr', namespaces)
if next_tc_pr is not None:
next_grid_span = next_tc_pr.find('.//w:gridSpan', namespaces)
next_colspan = 1
if next_grid_span is not None:
next_span_val = next_grid_span.get(f'{{{namespaces["w"]}}}val')
if next_span_val:
next_colspan = int(next_span_val)
# 检查当前位置是否是我们寻找的位置
if current_col_pos == matrix_col:
# 检查这个单元格是否是继续合并的单元格
next_v_merge = next_tc_pr.find('.//w:vMerge', namespaces)
if next_v_merge is not None:
next_merge_val = next_v_merge.get(f'{{{namespaces["w"]}}}val')
if next_merge_val is None: # 继续合并
rowspan_count += 1
# 标记这个位置被跨行单元格占据
for col_offset in range(next_colspan):
if matrix_col + col_offset < len(cell_matrix[next_row_idx]):
cell_matrix[next_row_idx][matrix_col + col_offset] = True
next_cell_found = True
break
else:
next_cell_found = True
break
else:
# 没有vMerge标记,说明合并结束
next_cell_found = True
break
current_col_pos += next_colspan
if not next_cell_found:
# 如果没找到对应的单元格,可能是被跨列占据了位置
# 检查这个位置是否被标记为已占用
if matrix_col < len(cell_matrix[next_row_idx]) and not cell_matrix[next_row_idx][matrix_col]:
# 位置未被占用,说明合并结束
break
else:
# 位置被占用,继续检查下一行
rowspan_count += 1
# 标记这个位置被跨行单元格占据
if matrix_col < len(cell_matrix[next_row_idx]):
cell_matrix[next_row_idx][matrix_col] = True
elif next_cell_found:
next_v_merge = None
if next_cell_index < len(next_cells):
next_tc_pr = next_cells[next_cell_index].find('.//w:tcPr', namespaces)
if next_tc_pr is not None:
next_v_merge = next_tc_pr.find('.//w:vMerge', namespaces)
if next_v_merge is not None:
next_merge_val = next_v_merge.get(f'{{{namespaces["w"]}}}val')
if next_merge_val is None: # 继续合并
rowspan_count += 1
# 标记这个位置被跨行单元格占据
if matrix_col < len(cell_matrix[next_row_idx]):
cell_matrix[next_row_idx][matrix_col] = True
else:
break
else:
break
if rowspan_count > 1:
rowspan = f' rowspan="{rowspan_count}"'
else:
# 不是合并起点,跳过这个单元格
cell_index += 1
continue
else:
# 标记当前位置被使用
if matrix_col < len(cell_matrix[i]):
cell_matrix[i][matrix_col] = True
# 如果没有设置rowspan,也要标记当前位置被使用
if not rowspan and matrix_col < len(cell_matrix[i]):
cell_matrix[i][matrix_col] = True
# 提取单元格中的段落
cell_paragraphs = cell.findall('.//w:p', namespaces)
cell_content = ""
for para in cell_paragraphs:
para_content = process_paragraph_formatting(para, namespaces, docx, doc_rels)
if para_content:
cell_content += para_content + "<br>" if cell_content else para_content
# 如果没有内容,添加空白字符以确保单元格可见
if not cell_content:
cell_content = " "
html_parts.append(f'<{cell_tag}{colspan}{rowspan}>{cell_content}</{cell_tag}>')
cell_index += 1
matrix_col += 1
# 调整matrix_col到下一个可用位置
while matrix_col < len(cell_matrix[i]) and cell_matrix[i][matrix_col]:
matrix_col += 1
html_parts.append('</tr>')
html_parts.append('</table>')
return '\n'.join(html_parts)
def process_textbox(textBox, namespaces, docx=None, doc_rels=None):
"""处理文本框"""
# 文本框本质上是一个容器,包含段落或其他元素
html_parts = ['<div class="textbox">']
# 处理文本框中的所有段落
paragraphs = textBox.findall('.//w:p', namespaces)
for p in paragraphs:
html_parts.append(process_paragraph(p, namespaces, docx, doc_rels))
html_parts.append('</div>')
return '\n'.join(html_parts)
def process_headers_footers(docx, namespaces, doc_rels):
"""处理页眉和页脚"""
header_content = []
footer_content = []
# 尝试读取页眉
try:
header_files = [f for f in docx.namelist() if f.startswith('word/header') and f.endswith('.xml')]
for header_file in header_files:
header_xml = docx.read(header_file)
header_tree = ET.fromstring(header_xml)
# 处理页眉中的段落
paragraphs = header_tree.findall('.//w:p', namespaces)
for p in paragraphs:
header_text = process_paragraph(p, namespaces, docx, doc_rels)
if header_text.strip():
header_content.append(header_text)
except:
pass # 页眉处理失败时忽略
# 尝试读取页脚
try:
footer_files = [f for f in docx.namelist() if f.startswith('word/footer') and f.endswith('.xml')]
for footer_file in footer_files:
footer_xml = docx.read(footer_file)
footer_tree = ET.fromstring(footer_xml)
# 处理页脚中的段落
paragraphs = footer_tree.findall('.//w:p', namespaces)
for p in paragraphs:
footer_text = process_paragraph(p, namespaces, docx, doc_rels)
if footer_text.strip():
footer_content.append(footer_text)
except:
pass # 页脚处理失败时忽略
return header_content, footer_content
def process_revisions(tree, namespaces):
"""处理修订(删除和插入的内容)"""
revision_content = []
# 处理删除的内容
del_elements = tree.findall('.//w:del', namespaces)
for del_elem in del_elements:
del_texts = del_elem.findall('.//w:delText', namespaces)
for del_text in del_texts:
if del_text.text:
revision_content.append(f'<span style="text-decoration:line-through;background-color:#ffcccc;">{html.escape(del_text.text)}</span>')
# 处理插入的内容
ins_elements = tree.findall('.//w:ins', namespaces)
for ins_elem in ins_elements:
ins_texts = ins_elem.findall('.//w:t', namespaces)
for ins_text in ins_texts:
if ins_text.text:
revision_content.append(f'<span style="background-color:#ccffcc;">{html.escape(ins_text.text)}</span>')
return revision_content
def docx_to_html(filepath):
"""将 Word 文档转换为 HTML"""
with zipfile.ZipFile(filepath) as docx:
# 读取 document.xml
xml_content = docx.read('word/document.xml')
tree = ET.fromstring(xml_content)
# 读取关系文件
try:
rels_content = docx.read('word/_rels/document.xml.rels')
doc_rels = ET.fromstring(rels_content)
except:
doc_rels = None
# 定义命名空间
namespaces = {
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
}
# 创建 HTML 结构
html_output = ['<!DOCTYPE html>', '<html>', '<head>', '<meta charset="UTF-8">',
'<title>Word 文档转换</title>',
'<style>',
'body { font-family: sans-serif; margin: 40px auto; max-width: 800px; line-height: 1.6; }',
'h1 { color: #333; border-bottom: 2px solid #eee; padding-bottom: 10px; }',
'h2 { color: #666; border-bottom: 1px solid #eee; padding-bottom: 5px; }',
'h3 { color: #999; }',
'h4 { color: #333; }',
'h5 { color: #666; }',
'h6 { color: #999; }',
'p { margin: 1em 0; }',
'ul, ol { margin: 1em 0; padding-left: 40px; }',
'li { margin: 0.5em 0; }',
'table { border-collapse: collapse; width: 100%; margin: 1em 0; }',
'td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }',
'strong { font-weight: bold; }',
'em { font-style: italic; }',
'.bold { font-weight: bold; }',
'.italic { font-style: italic; }',
'u { text-decoration: underline; }',
'.underline { text-decoration: underline; }',
'img { max-width: 100%; height: auto; }',
'.header, .footer { background-color: #f5f5f5; padding: 10px; margin: 10px 0; border: 1px solid #ddd; }',
'.header { border-bottom: 2px solid #ccc; }',
'.footer { border-top: 2px solid #ccc; }',
'.textbox { border: 1px solid #ccc; padding: 10px; margin: 10px 0; }',
'.floating-element { position: relative; float: right; margin: 10px 0 10px 10px; }',
'.form-field { border-bottom: 1px solid #000; display: inline-block; min-width: 50px; text-align: center; margin: 0 2px; padding: 0 5px; }',
'</style>',
'</head>', '<body>']
# 处理页眉和页脚
header_footer_content = process_headers_footers(docx, namespaces, doc_rels)
# 添加页眉内容到页面顶部
header_content = [item for item in header_footer_content if 'class="header"' in item]
footer_content = [item for item in header_footer_content if 'class="footer"' in item]
# 添加页眉
html_output.extend(header_content)
# 处理所有元素
# 注意:在Word XML中,内容在<w:body>标签内
body = tree.find('.//w:body', namespaces)
if body is not None:
# 跟踪列表状态
in_list = False
list_type = None
for elem in body:
if elem.tag.endswith('p'): # 段落
p_pr = elem.find('./w:pPr', namespaces)
p_style_elem = p_pr.find('.//w:pStyle', namespaces) if p_pr is not None else None
p_style = p_style_elem.get(f'{{{namespaces["w"]}}}val') if p_style_elem is not None else None
num_pr = p_pr.find('.//w:numPr', namespaces) if p_pr is not None else None
# 检查是否为标题
is_heading = p_style and p_style.startswith('Heading')
# 检查是否为列表项
is_list_item = num_pr is not None
# 如果之前在列表中,但现在不是列表项,则关闭列表
# 标题也不应在列表中,所以也要关闭列表
if in_list and (not is_list_item or is_heading):
html_output.append(f'</{list_type}>')
in_list = False
list_type = None
# 如果之前不在列表中,但现在是列表项,则开启列表
if not in_list and is_list_item and not is_heading:
# 尝试区分有序和无序列表
# 简单处理:如果有编号信息则认为是有序列表,否则为无序列表
list_type = 'ol' if num_pr is not None else 'ul'
html_output.append(f'<{list_type}>')
in_list = True
html_output.append(process_paragraph(elem, namespaces, docx, doc_rels))
elif elem.tag.endswith('tbl'): # 表格
# 如果在列表中,则先关闭列表
if in_list:
html_output.append(f'</{list_type}>')
in_list = False
list_type = None
html_output.append(process_table(elem, namespaces, docx, doc_rels))
# 如果循环结束后还在列表中,则关闭列表
if in_list:
html_output.append(f'</{list_type}>')
else:
# 如果找不到body标签,则直接处理tree的子元素
for elem in tree:
if elem.tag.endswith('p'): # 段落
html_output.append(process_paragraph(elem, namespaces, docx, doc_rels))
elif elem.tag.endswith('tbl'): # 表格
html_output.append(process_table(elem, namespaces, docx, doc_rels))
# 添加页脚内容到页面底部
html_output.extend(footer_content)
html_output.extend(['</body>', '</html>'])
return '\n'.join(html_output)
def print_docx_info(filepath):
"""打印 Word 文档的基本信息"""
print(f"正在分析文档: {filepath}")
# 列出文档内容
contents = list_docx_contents(filepath)
print("\n文档包含以下文件:")
for item in contents[:10]: # 只显示前10个
print(f" {item}")
if len(contents) > 10:
print(f" ... 还有 {len(contents) - 10} 个文件")
# 提取并显示文本内容
print("\n文档文本内容预览:")
text = extract_text_from_docx(filepath)
print(text[:500] + "..." if len(text) > 500 else text)
def save_html(filepath, html_content):
"""保存 HTML 到文件"""
with open(filepath, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"HTML 文件已保存到: {filepath}")
def main():
"""主函数"""
print("Word 文档处理工具")
print("==================")
try:
# 显示文档信息
print_docx_info('test_input.docx')
# 分析文档结构
print("\n文档结构分析:")
analyze_docx_structure('test_input.docx')
# 转换为 HTML
print("\n正在将 Word 文档转换为 HTML...")
html_content = docx_to_html('test_input.docx')
# 保存 HTML 文件
save_html('output.html', html_content)
print("转换完成!")
except FileNotFoundError:
print("未找到 test_input.docx 文件,请确保文件存在于项目根目录中")
except Exception as e:
print(f"处理文档时发生错误: {e}")
import traceback
traceback.print_exc()
# 程序入口点
if __name__ == '__main__':
main()