一、背景与动机
在日常开发中,技术文档通常以 markdown 格式编写,但交付给客户或非技术人员时,往往需要 word(docx)格式。手工复制粘贴不仅效率低,还会丢失格式。基于 python 的 python-docx 库,可以编写一个自动化脚本,将 markdown 文件批量转换为排版美观的 word 文档。
二、技术选型
| 依赖库 | 版本 | 用途 |
|---|---|---|
| python-docx | 0.8+ | 创建和修改 docx 文件 |
| re | 内置 | 正则匹配 markdown 语法 |
| docx.shared | — | 设置字体大小、颜色、英寸等单位 |
| docx.enum.text | — | 段落对齐方式等枚举 |
| docx.oxml.ns | — | 操作 word 底层 xml 命名空间 |
核心依赖只有一个:
pip install python-docx
三、整体架构
markdown 文件(.md)
│
▼
读取文件内容(utf-8 编码)
│
▼
逐行解析 markdown 语法
┌────────────────────┐
│ # ~ ##### │ → 标题(h1 ~ h5)
│ ```代码块 ``` │ → 代码块(consolas 字体)
│ | 表格 | 表格 | │ → 带边框表格
│ - 列表 / 1. 列表 │ → 无序/有序列表
│ **粗体** │ → 加粗文本
│ `行内代码` │ → 等宽高亮文本
│ --- │ → 分隔线
│ 流程图字符 │ → 等宽小号文本
│ 普通文本 │ → 正文段落
└────────────────────┘
│
▼
生成 docx 文档并保存
四、核心实现
4.1 文档初始化与默认字体
word 默认字体不支持中文,需要手动设置中文字体(微软雅黑):
from docx import document
from docx.shared import pt
from docx.oxml.ns import qn
doc = document()
# 设置默认字体
doc.styles['normal'].font.name = '微软雅黑'
doc.styles['normal']._element.rpr.rfonts.set(qn('w:eastasia'), '微软雅黑')
doc.styles['normal'].font.size = pt(11)
关键点:
font.name只设置了西文字体- 需要通过底层 xml
rfonts.set(qn('w:eastasia'), '微软雅黑')单独设置中文字体,否则中文会回退到宋体
4.2 标题解析
通过正则匹配 # 的数量判断标题级别:
if line.startswith('# '):
p = doc.add_heading(line[2:].strip(), level=1)
p.alignment = wd_align_paragraph.left
elif line.startswith('## '):
p = doc.add_heading(line[3:].strip(), level=2)
elif line.startswith('### '):
p = doc.add_heading(line[4:].strip(), level=3)
doc.add_heading(text, level) 支持级别 1~5,word 会自动应用对应的标题样式。
4.3 代码块处理
代码块用三个反引号包裹,需要维护一个状态标志:
in_code_block = false
code_content = []
while i < len(lines):
line = lines[i]
if line.strip().startswith('```'):
if not in_code_block:
in_code_block = true
code_content = []
else:
# 代码块结束,写入文档
code_text = '\n'.join(code_content)
p = doc.add_paragraph()
run = p.add_run(code_text)
run.font.name = 'consolas'
run.font.size = pt(9)
run.font.color.rgb = rgbcolor(39, 86, 136) # 深蓝色
p.style = 'no spacing' # 取消段落间距
in_code_block = false
i += 1
continue
if in_code_block:
code_content.append(line)
i += 1
continue
关键点:
p.style = 'no spacing'— 取消代码块上下方的段落间距,避免代码块之间出现大段空白- 使用
consolas等宽字体,字体大小设为 9pt(比正文小),颜色设为深蓝色以区分正文
4.4 表格解析
markdown 表格使用 | 分隔列,--- 分隔表头和数据行:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 主键 |
| name | string | 名称 |
解析逻辑:
# 收集连续的表格行
table_lines = []
while i < len(lines) and lines[i].startswith('|'):
table_lines.append(lines[i])
i += 1
# 解析表格数据,跳过分隔行
rows = []
for tl in table_lines:
if '---' not in tl:
cells = [c.strip() for c in tl.split('|')[1:-1]]
rows.append(cells)
# 创建 word 表格
table = doc.add_table(rows=len(rows), cols=len(rows[0]))
table.style = 'light grid accent 1'
for ri, row_data in enumerate(rows):
for ci, cell_data in enumerate(row_data):
cell = table.rows[ri].cells[ci]
cell.text = cell_data
set_cell_border(cell) # 设置边框
if ri == 0: # 表头加粗
run = cell.paragraphs[0].runs[0]
run.font.bold = true
run.font.size = pt(11)
else:
run = cell.paragraphs[0].runs[0]
run.font.size = pt(10)
4.5 单元格边框设置
python-docx 创建的表格默认可能缺少边框,需要通过底层 xml 手动添加:
from docx.oxml import oxmlelement
def set_cell_border(cell):
tcpr = cell._element.get_or_add_tcpr()
tcborders = oxmlelement('w:tcborders')
for border_name in ['top', 'left', 'bottom', 'right']:
border = oxmlelement(f'w:{border_name}')
border.set(qn('w:val'), 'single')
border.set(qn('w:sz'), '4') # 边框宽度(1/8 磅为单位)
border.set(qn('w:space'), '0')
border.set(qn('w:color'), '000000') # 黑色
tcborders.append(border)
tcpr.append(tcborders)
原理:word 的单元格边框定义在 w:tcborders xml 元素中,python-docx 的高级 api 没有直接暴露此功能,需要操作 ooxml 底层元素。
4.6 行内格式解析
粗体(**text**)
elif '**' in line:
p = doc.add_paragraph()
parts = re.split(r'\*\*(.+?)\*\*', line)
for j, part in enumerate(parts):
if j % 2 == 1: # 奇数索引 = 粗体部分
run = p.add_run(part)
run.bold = true
else: # 偶数索引 = 普通文本
if part:
p.add_run(part)
# 如果段落为空则移除
if not p.text.strip():
p._element.getparent().remove(p._element)
核心思路:用正则 re.split(r'\*\*(.+?)\*\*', line) 将文本按粗体标记分割,奇数位就是粗体内容,偶数位是普通文本。
行内代码(`code`)
elif '`' in line:
p = doc.add_paragraph()
parts = re.split(r'`(.+?)`', line)
for j, part in enumerate(parts):
if j % 2 == 1: # 奇数索引 = 代码部分
run = p.add_run(part)
run.font.name = 'consolas'
run.font.size = pt(10)
run.font.color.rgb = rgbcolor(199, 37, 78) # 红色
else:
if part:
p.add_run(part)
4.7 列表解析
# 无序列表(- / * / +)
elif re.match(r'^\s*[\-\*\+]\s+', line):
p = doc.add_paragraph(line.strip()[2:].strip(), style='list bullet')
# 有序列表(1. 2. 3.)
elif re.match(r'^\s*\d+\.\s+', line):
p = doc.add_paragraph(
re.sub(r'^\s*\d+\.\s+', '', line),
style='list number'
)
4.8 流程图与特殊字符
markdown 中的流程图(如用 ┌│└─├→ 等字符绘制的图)需要等宽小号字体才能对齐:
flow_chars = ['┌', '│', '└', '─', '├', '┼', '┬', '┐', '┘', '┤', '┴', '▲', '▼', '→']
if any(x in line for x in flow_chars):
p = doc.add_paragraph()
run = p.add_run(line)
run.font.name = 'consolas'
run.font.size = pt(8) # 更小的字号
p.style = 'no spacing' # 无间距
五、批量转换主函数
import os
def main():
md_files = [
'/path/to/doc1.md',
'/path/to/doc2.md'
]
output_files = [
'/path/to/doc1.docx',
'/path/to/doc2.docx'
]
for md_file, output_file in zip(md_files, output_files):
print(f'正在转换: {md_file}')
with open(md_file, 'r', encoding='utf-8') as f:
md_content = f.read()
title = md_content.split('\n')[0].replace('#', '').strip()
doc = markdown_to_docx(md_content, title)
doc.save(output_file)
print(f'已生成: {output_file}')
六、支持的 markdown 语法汇总
| 语法 | markdown 写法 | 转换效果 |
|---|---|---|
| 一级标题 | # 标题 | word heading 1 |
| 二级标题 | ## 标题 | word heading 2 |
| 三~五级标题 | ### ~ ##### | word heading 3~5 |
| 代码块 | ` ```code ```` | consolas 9pt 蓝色 |
| 行内代码 | `code` | consolas 10pt 红色 |
| 粗体 | **text** | 加粗 |
| 无序列表 | - item | list bullet 样式 |
| 有序列表 | 1. item | list number 样式 |
| 表格 | | col | col | | 带边框的 word 表格 |
| 分隔线 | --- | 下划线 |
| 流程图 | ┌───┐ | consolas 8pt 等宽 |
七、局限性与优化方向
当前局限
- 不支持图片(markdown 的
未解析) - 不支持超链接
- 不支持嵌套列表(多层缩进的列表)
- 行内格式只支持粗体和行内代码,不支持斜体、
删除线等 - 表格不支持合并单元格
优化方向
# 1. 图片支持:下载网络图片并插入
from docx.shared import inches
p = doc.add_paragraph()
run = p.add_run()
run.add_picture('image.png', width=inches(5.0))
# 2. 超链接支持
from docx.oxml.ns import qn
from docx.oxml import oxmlelement
def add_hyperlink(paragraph, text, url):
part = paragraph.part
r_id = part.relate_to(url, 'http://schemas.openxmlformats.org/officedocument/2006/relationships/hyperlink', is_external=true)
hyperlink = oxmlelement('w:hyperlink')
hyperlink.set(qn('r:id'), r_id)
new_run = oxmlelement('w:r')
rpr = oxmlelement('w:rpr')
new_run.append(rpr)
text_elem = oxmlelement('w:t')
text_elem.text = text
new_run.append(text_elem)
hyperlink.append(new_run)
paragraph._p.append(hyperlink)
return hyperlink
更优的替代方案
如果对格式要求更高,可以考虑以下成熟工具:
| 工具 | 特点 | 安装方式 |
|---|---|---|
pandoc | 功能最全,支持几乎所有 markdown 扩展语法 | brew install pandoc |
markdown2docx | 轻量级,专为中文优化 | pip install markdown2docx |
mammoth | 反向转换(docx → html/markdown) | pip install mammoth |
# pandoc 一行命令即可完成转换 pandoc input.md -o output.docx --reference-doc=template.docx
pandoc 支持 --reference-doc 参数,可以指定一个 word 模板文件来控制输出样式(字体、颜色、页边距等),这是最推荐的方案。
八、完整代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
from docx import document
from docx.shared import pt, inches, rgbcolor
from docx.enum.text import wd_align_paragraph
from docx.oxml.ns import qn
def set_cell_border(cell):
"""设置单元格边框"""
from docx.oxml import oxmlelement
tcpr = cell._element.get_or_add_tcpr()
tcborders = oxmlelement('w:tcborders')
for border_name in ['top', 'left', 'bottom', 'right']:
border = oxmlelement(f'w:{border_name}')
border.set(qn('w:val'), 'single')
border.set(qn('w:sz'), '4')
border.set(qn('w:space'), '0')
border.set(qn('w:color'), '000000')
tcborders.append(border)
tcpr.append(tcborders)
def markdown_to_docx(md_content, title):
"""将 markdown 内容转换为 docx 文档"""
doc = document()
doc.styles['normal'].font.name = '微软雅黑'
doc.styles['normal']._element.rpr.rfonts.set(qn('w:eastasia'), '微软雅黑')
doc.styles['normal'].font.size = pt(11)
lines = md_content.split('\n')
i = 0
in_code_block = false
code_content = []
while i < len(lines):
line = lines[i]
# 代码块
if line.strip().startswith('```'):
if not in_code_block:
in_code_block = true
code_content = []
else:
p = doc.add_paragraph()
run = p.add_run('\n'.join(code_content))
run.font.name = 'consolas'
run.font.size = pt(9)
run.font.color.rgb = rgbcolor(39, 86, 136)
p.style = 'no spacing'
in_code_block = false
i += 1
continue
if in_code_block:
code_content.append(line)
i += 1
continue
# 标题
if line.startswith('# '):
doc.add_heading(line[2:].strip(), level=1)
elif line.startswith('## '):
doc.add_heading(line[3:].strip(), level=2)
elif line.startswith('### '):
doc.add_heading(line[4:].strip(), level=3)
# 表格
elif line.startswith('|') and '|' in line:
table_lines = []
while i < len(lines) and lines[i].startswith('|'):
table_lines.append(lines[i])
i += 1
rows = []
for tl in table_lines:
if '---' not in tl:
cells = [c.strip() for c in tl.split('|')[1:-1]]
rows.append(cells)
if rows:
table = doc.add_table(rows=len(rows), cols=len(rows[0]))
table.style = 'light grid accent 1'
for ri, row_data in enumerate(rows):
for ci, cell_data in enumerate(row_data):
cell = table.rows[ri].cells[ci]
cell.text = cell_data
set_cell_border(cell)
continue
# 列表
elif re.match(r'^\s*[\-\*\+]\s+', line):
doc.add_paragraph(line.strip()[2:].strip(), style='list bullet')
elif re.match(r'^\s*\d+\.\s+', line):
doc.add_paragraph(re.sub(r'^\s*\d+\.\s+', '', line), style='list number')
# 粗体
elif '**' in line:
p = doc.add_paragraph()
parts = re.split(r'\*\*(.+?)\*\*', line)
for j, part in enumerate(parts):
if j % 2 == 1:
run = p.add_run(part)
run.bold = true
elif part:
p.add_run(part)
# 行内代码
elif '`' in line:
p = doc.add_paragraph()
parts = re.split(r'`(.+?)`', line)
for j, part in enumerate(parts):
if j % 2 == 1:
run = p.add_run(part)
run.font.name = 'consolas'
run.font.size = pt(10)
run.font.color.rgb = rgbcolor(199, 37, 78)
elif part:
p.add_run(part)
# 普通段落
elif line.strip():
doc.add_paragraph(line.strip())
i += 1
return doc
九、总结
本脚本通过逐行解析 markdown 文本,利用 python-docx 库生成对应格式的 word 文档,主要涉及以下关键技术点:
- 中文字体设置:通过 ooxml 底层 xml 设置东亚字体
- 状态机解析:用
in_code_block标志处理多行代码块 - 正则分割:用
re.split处理行内格式(粗体、行内代码) - ooxml 操作:通过
oxmlelement手动添加表格边框 - 样式控制:使用
no spacing样式消除代码块间距
对于简单的文档转换需求,这个脚本足够使用;如果需要更完整的格式支持,建议使用 pandoc 等成熟工具。
到此这篇关于python实现markdown转word文档的工具详解的文章就介绍到这了,更多相关python markdown转 word内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论