☰
docx2md:从Word到Markdown的XML解析与工程实践
2026/10/14 15:44:42 网站建设 项目流程

简介:docx2md是一款基于Go语言开发的命令行转换工具,能够将Microsoft Word文档批量转换为Markdown格式,同时保留标题层级、超链接、表格、嵌图、加粗斜体等常见排版样式。对需要维护技术文档、博客内容或知识库的开发者与内容创作者而言,可直接安装使用,节省手工排版时间。资源包共11个文件、71KB,内含Go源码、Makefile构建配置、Go module依赖文件、GitHub Actions工作流及仓库配置文件,结构清晰,适合初学者参考Go CLI项目脚手架或二次移植。已在CSDN吸引2728人学习下载。通过源码可深入理解docx XML解析、Markdown语法生成、图片提取与输出等核心逻辑,既可作为日常转换利器,也可作为学习Go语言工程化实践的完整样例。

1. docx2md 能解决什么:从「复制粘贴必乱」到「一条命令出干净 Markdown」

docx2md 这个名字乍看是几句脚本的事,但它解决的是每个写文档的人都被折磨过的问题:从 Word 复制带标题、表格、图片的内容到 Markdown 编辑器,粘贴出来标题层级全丢、表格挤成一团、图片路径乱飞。docx2md 要做的事情,是把这个过程变成一条可靠、可重复的命令。

核心思路反直觉但很关键:docx 不是「纯文本文件」,而是一个 zip 压缩包,正文以 XML 形式躺在里面。所以转换的本质是「解包 + 解析结构 + 重排输出」,不是拿文本提取器去扫。理解这一点,后面所有参数和坑就都有了坐标。

需要它的典型场景:把历史 Word 方案批量迁进 Git 仓库、给知识库做文档导入、统一团队交付格式。它不解决排版精美的问题,它解决结构不丢的问题。

2. docx 格式拆解:为什么转换是解析 XML,不是「读文本」

在动手写转换器之前,必须先接受一个事实:Microsoft Word 文档的 .docx 后缀,本质是一个 zip 压缩包。你把它当文本文件去读,只能得到一堆乱码;把后缀改成 .zip 再解压,会看到一个清晰的目录结构,正文、样式、图片各归其位。理解这套结构,是写好 docx2md 的前提。

2.1 先用 unzip 看穿 docx:zip 包里的四个关键文件

unzip -l 示例文档.docx

这条命令会把压缩包内的文件清单列出来,顶层一般是_rels、docProps、word这几个目录。不需要全部搞懂,只需要盯住四类文件。

文件路径作用
word/document.xml正文主体,段落、表格、文本都在这里
word/styles.xml样式定义,标题样式、正文样式的大本营
word/media/文档里插入的图片等二进制资源
word/_rels/document.xml.rels正文与资源的映射关系表

为什么要强调这些?因为 docx2md 的所有逻辑,本质上都是围绕这张表展开的:document.xml 决定「哪些文本是标题」,styles.xml 决定「标题长什么样」,media 决定「图片存哪」,rels 决定「图片怎么被引用」。这四个文件缺一个,转换结果就残缺。

如果你打开 document.xml 看正文,会发现文本被「打散」成一个个 run(w:r),每个 run 可能带不同的加粗、字体、颜色。直接在 XML 里搜索一句话,通常会被拆成好几段,中间还夹着各种属性标签。这就是为什么「把 docx 转成 md」不能靠文本提取工具完成,必须按结构解析。

2.2 document.xml 的三个核心元素:段落、文本与表格

拿一个最简化的 document.xml 片段,应该是这样的:

<w:body> <w:p> <w:pPr> <w:pStyle w:val="Heading1"/> </w:pPr> <w:r> <w:t>项目概述</w:t> </w:r> </w:p> <w:tbl> <w:tr> <w:tc> <w:p> <w:r> <w:t>模块</w:t> </w:r> </w:p> </w:tc> </w:tr> </w:tbl> </w:body>

w:body 是正文容器,所有内容都在它下面。w:p 表示一个段落,Markdown 里的一个块级元素大多对应一个 w:p。w:pPr 是段落属性,里面的 pStyle 用 w:val 引用一个样式 ID,这个 ID 指向 styles.xml 里某条样式定义,比如 Heading1。w:r 是 run,一段连续相同格式的文本;w:t 是 run 里真正存文字的地方。

这里有个新手最容易踩的坑:表格的文本并不直接在 w:tbl 下,而是在 w:tc(单元格)里嵌套的 w:p 里。也就是说,想拿表格内容,得走「表格 → 行 → 单元格 → 段落 → 文本」这条链路,跳一步就取不到。初次写转换器时,我在这里打印了半天空列表,最后才发现是路径少了一层。

标题判断的关键也在这里:一个段落是不是标题,取决于它的 w:pStyle 引用的是哪个样式 ID,而不是看文字内容。内容叫「项目概述」,样式是正文,它在 Markdown 里就是普通段落。判断依据错了,输出再好看也是错的。

2.3 样式继承和直接格式:标题判断为什么不能只看一个标签

docx 的样式是级联的,从上到下大致是:默认样式(docDefaults)→ 段落样式 → run 样式 → 直接格式。意思是,如果一个段落没有显式设置字体,它会继承段落样式;段落样式没设置的,继承默认样式;而直接格式(比如手动点加粗、改字号)优先级最高,直接写在 XML 的 rPr 里。

这对 docx2md 意味着什么?意味着你判断一个标题时,不能只查 pStyle。大量从网页复制进 Word 的内容根本不用标题样式,而是普通段落里手动加大字号、加粗。在 XML 里,这种「假标题」的 pPr 里没有 pStyle,但 run 的 rPr 里有 b 标签和 sz 标签。要不要识别、识别到什么程度,是转换器里最需要拿捏的部分。我一般会做三层判断:优先 pStyle 的样式 ID;查不到就查 styles.xml 里该样式的大纲级别;再兜底看 run 的直接格式。这个顺序能覆盖绝大多数文档来源,剩下的个别怪文档就交给第五章的避坑内容处理。

3. 转换选型:pandoc、python-docx、自解析 XML 三条路的边界

熟悉结构之后,下一个问题是:用什么实现?常见做法有三条路,各有各的边界。选错路不会写不出程序,但会在某个具体的文档上耗尽耐心。

3.1 pandoc:一条命令跑通,但自定义映射要付出的成本不低

pandoc 示例文档.docx -o 示例文档.md

这是上手最快的方式,一句话就把 docx 转成了 Markdown。pandoc 内置了 docx 读取器,标题、段落、表格、脚注这些常规结构都能处理。如果文档里带图片,最好加上图片抽取参数:

pandoc 示例文档.docx -o 示例文档.md --wrap=none --extract-media=assets

--wrap=none的作用是关掉自动换行,让每个段落保持一行,方便后面做 diff 比对;--extract-media=assets会把文档里的图片导出到 assets 目录,并在 Markdown 里改写相对路径。这两个参数几乎是批量转换的标配。

但 pandoc 的黑匣子成分不小:它如何映射 docx 样式到 Markdown 标记、如何处理合并单元格,你只能看到结果,改起来要写自定义 Lua 过滤器,学习成本不低。如果你的目标是「把一批文档转完就不管了」,pandoc 足够;如果目标文档要长期维护、输出格式有严格要求,pandoc 的灵活性反而成了瓶颈。

3.2 python-docx:上手快,可底层 XML 细节终究要自己抓

from docx import Document doc = Document("示例文档.docx") for para in doc.paragraphs: print(para.style.name, "|", para.text) for table in doc.tables: for row in table.rows: print([cell.text for cell in row.cells])

python-docx 把段落和表格封装成了对象,代码读起来很直观。doc.paragraphs是正文里的段落列表,para.style.name能拿到样式显示名,para.text是段落文本;doc.tables是正文里的表格列表。用它做文档结构预览、抽取文本,非常方便。

但它有两个边界。第一,很多底层细节它不暴露:分页符、域代码、修订标记、图片的具体位置,这些要么得通过doc.element回到 lxml 层自己找,要么干脆不支持。第二,表格合并单元格时,row.cells对纵向合并的单元格会返回同一个 cell 对象好几次,直接遍历得到的是重复文本,容易写出「看着对、实际错」的代码。所以我的看法是:python-docx 适合做预览和简单抽取,不适合做保真转换。它真正有价值的地方是doc.element这个入口,拿到它就能继续往底层走,和自解析 XML 并不冲突。

3.3 自解析 XML:最灵活,适合知识库迁移这种要精确控制的场景

import zipfile from xml.etree import ElementTree as ET W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" def w(tag): return f"{{{W_NS}}}{tag}" with zipfile.ZipFile("示例文档.docx") as z: xml = z.read("word/document.xml") root = ET.fromstring(xml) body = root.find(w("body")) for p in body.iter(w("p")): texts = [t.text for t in p.iter(w("t")) if t.text] print("".join(texts))

这套骨架自己解析 document.xml,绕开了所有封装,直接面对原始结构。它依赖两个事实:docx 是 zip 包,正文在 word/document.xml;ElementTree 读取标签时,tag 会是「命名空间 + 标签名」的完整形式。w(tag)这个辅助函数就是用来拼命名空间的,拼错是新手写自解析时最常见的第一个报错。

body.iter(w("p"))会迭代 body 下所有层级的段落,包含表格单元格里的段落,顺序和文档阅读顺序一致。这在多数场景下是优点,但如果你想「先处理所有段落,再单独处理表格」,iter 会打乱你的块级顺序。对转换器来说,更稳妥的是按 body 的直接子节点顺序遍历,这会在第四章的实现部分展开。

自解析的代价是你要自己处理命名空间、自己解析 rels、自己处理样式继承。换来的是对输出的完全控制:每个标题映射成几级、图片放哪、表格怎么处理空单元格,都由你说了算。对知识库迁移、统一格式转换这类需要长期维护输出的场景,这个代价是值得的。

3.4 选型决策:按文档来源和输出要求选,不按流行度选

三种方案适合的场景差异,用一张表说清楚:

方案上手成本控制粒度适合场景
pandoc 命令低中一次性批量转换,输出要求不苛刻
python-docx低中结构预览、文本抽取、快速原型
自解析 XML中高高知识库迁移、格式敏感、需精确控制

我的习惯是:只要文档要进 Git 长期维护,或者来源里有各种排版软件产出的文件,就直接走自解析。pandoc 适合一次性的活,python-docx 适合写探查脚本,而 docx2md 这类工具要扛住长期输入变化,最终都会落到自解析这条路上。接下来的实现部分,也以自解析为主线展开。

4. 手写一个够用的 docx2md:从最小骨架到表格与图片

理论已经立住了,这章开始写代码。目标不是一行命令解决,而是让你理解每一层结构是怎么被翻译成 Markdown 的。我会按「骨架 → 标题列表 → 表格 → 图片链接」四步推进,每一步都在前一步基础上加能力。

4.1 最小骨架:解包、定位 document.xml、按块遍历

import zipfile from xml.etree import ElementTree as ET W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" def w(tag): return f"{{{W_NS}}}{tag}" def read_document_xml(docx_path): with zipfile.ZipFile(docx_path) as z: return z.read("word/document.xml") def convert_docx(docx_path): root = ET.fromstring(read_document_xml(docx_path)) body = root.find(w("body")) lines = [] for child in body: if child.tag == w("p"): texts = [t.text for t in child.iter(w("t")) if t.text] line = "".join(texts).strip() if line: lines.append(line) elif child.tag == w("tbl"): lines.append("[TABLE]") return "\n".join(lines) if __name__ == "__main__": print(convert_docx("示例文档.docx"))

逻辑说明:read_document_xml用 zipfile 读取压缩包里的正文 XML,返回字节串。convert_docx把 XML 解析成 ElementTree,找到 body 节点,然后按直接子节点的顺序遍历。遇到w:p就把里面所有w:t的文本拼起来,遇到w:tbl就先输出一个占位符,保证后面章节有展开的空间。

参数说明:body 的直接子节点可能是w:p、w:tbl、w:sectPr等;w:sectPr是节属性,不含文本,直接忽略。child.iter(w("t"))会收集单元格里的所有文本,所以在 4.3 节做表格时不能直接照搬这个逻辑,否则文档顺序和表格内文本会混在一起。

注意:w("p")返回的是带花括号的完整标签名,比如{http://...}p。直接用child.tag == "p"永远不成立,这是自解析 XML 最容易踩的起点坑。

这个骨架的输出已经能看,但它丢掉了所有结构信息:标题没加#,列表没加-,表格是占位符,图片根本看不见。接下来逐节补上。

4.2 标题与列表:从 pStyle 和 numPr 生成 Markdown 标记

HEADING_MAP = { "Heading1": "#", "Heading2": "##", "Heading3": "###", "Heading4": "####", "Heading5": "#####", "Heading6": "######", "标题1": "#", "标题2": "##", "标题3": "###", "标题4": "####", "标题5": "#####", "标题6": "######", } def get_style_id(p): pPr = p.find(w("pPr")) if pPr is None: return "" pStyle = pPr.find(w("pStyle")) if pStyle is None: return "" return pStyle.get(w("val"), "") def has_numpr(p): pPr = p.find(w("pPr")) if pPr is None: return False return pPr.find(w("numPr")) is not None

逻辑说明:get_style_id从段落属性里取 pStyle 的 val,这个 val 是样式 ID。HEADING_MAP 同时映射英文和中文两种样式 ID,覆盖不同语言版本和不同排版软件生成的文档。has_numpr检测段落是否带编号属性 numPr,有就按列表处理。

在遍历主循环里加上判断:

for child in body: if child.tag == w("p"): style_id = get_style_id(child) text = "".join(t.text for t in child.iter(w("t")) if t.text).strip() if not text: continue if style_id in HEADING_MAP: lines.append(f"{HEADING_MAP[style_id]} {text}") elif has_numpr(child): lines.append(f"- {text}") else: lines.append(text)

参数说明:这里对列表做了简化处理——只要带 numPr 就输出无序列表的-,不区分有序还是无序。要精确区分,需要继续解析 numbering.xml,把 numId 映射到具体的编号格式。对第一版转换器,统一输出-是可接受的,后续要支持有序列表再加一层映射即可。

有一个细节要注意:text为空时直接continue,这个判断能挡掉大量空段落。docx 里空段落非常多,不过滤的话 Markdown 会变成空行堆叠。

4.3 表格:先处理纵向合并,再按行列拼线

def convert_table(tbl): lines = [] for tr in tbl.findall(w("tr")): cells = [] for tc in tr.findall(w("tc")): tcPr = tc.find(w("tcPr")) vmerge = None if tcPr is not None: vmerge = tcPr.find(w("vMerge")) if vmerge is not None and vmerge.get(w("val")) != "restart": cells.append("") continue texts = [t.text for t in tc.iter(w("t")) if t.text] cell_text = "".join(texts).strip() cells.append(cell_text.replace("|", "\\|")) lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines)

逻辑说明:convert_table按行遍历 tr,再按单元格遍历 tc。每个单元格里的文本通过tc.iter(w("t"))提取,拼起来后去掉首尾空格。vMerge 的判断是重点:纵向合并开始时,vMerge 带val="restart";后续被合并的单元格只有一个空的 vMerge 标签,没有 val。这种情况下输出空字符串占位,行数才能对齐。

参数说明:cell_text.replace("|", "\\|")是防转义处理,避免单元格内容里的竖线被当成表格分隔符。这个容易被忽略,但真实文档里单元格带竖线的情况不少,不处理会直接破坏整个表格渲染。

注意:vMerge 的判断一定要在文本提取之前。如果先提取文本再判断,被合并单元格里残留的文字会被带进输出,破坏列对齐。

回到主循环,把占位符替换成真实转换:

elif child.tag == w("tbl"): lines.append(convert_table(child))

这里有个容易混淆的点:convert_table里用的是tbl.findall(w("tr")),只找直接子级的行。有的文档在 tbl 里嵌着自定义属性节点(w:tblPr),findall 不受影响,但如果你用了iter(w("tr")),可能把嵌套表格的行也抓进来,导致拼接错乱。第一版建议用 findall。

4.4 图片与链接:通过 rels 找到真实文件并落盘

图片是 docx2md 里最绕的一环,因为 document.xml 里引用图片用的是关系 ID,不是文件名。关系 ID 要查 rels 文件才能映射到真实的 media 路径。

def load_rels(docx_path): rels = {} R_NS = "http://schemas.openxmlformats.org/package/2006/relationships" with zipfile.ZipFile(docx_path) as z: rels_xml = z.read("word/_rels/document.xml.rels") root = ET.fromstring(rels_xml) for rel in root: rid = rel.get("Id") target = rel.get("Target") rels[rid] = target return rels

逻辑说明:load_rels读取 document.xml.rels,把每个 Relationship 节点的 Id 和 Target 存成字典。Id 是 document.xml 里 r:embed 引用的那个字符串,Target 是相对于 word 目录的文件路径,比如media/image1.png。

提示:rels 文件的命名空间是package/2006/relationships,和正文的wordprocessingml不是同一个。两个命名空间常量挨在一起,拼错一个,查出来的就是空表。

取图并落盘的逻辑:

A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" R_EMBED = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" def extract_images(p, rels, docx_path, out_dir="assets"): out_names = [] with zipfile.ZipFile(docx_path) as z: for blip in p.iter(f"{{{A_NS}}}blip"): rid = blip.get(f"{{{R_EMBED}}}embed") if not rid: continue target = rels.get(rid, "") if not target: continue src_path = f"word/{target}" out_name = target.split("/")[-1] data = z.read(src_path) with open(f"{out_dir}/{out_name}", "wb") as f: f.write(data) out_names.append(out_name) return out_names

参数说明:r:embed属性所在的命名空间是officeDocument/2006/relationships,不是 wordprocessingml 那个,两个命名空间常量容易搞混。target.split("/")[-1]取文件名,但要注意不同图片可能重名,更稳妥的做法是用 rid 做前缀或查重。这里为了展示主干逻辑,先按最简单的方式落盘。

超链接走的也是同一套 rels:

for hyperlink in p.findall(w("hyperlink")): rid = hyperlink.get(f"{{{R_EMBED}}}id") url = rels.get(rid, "") text = "".join(t.text for t in hyperlink.iter(w("t")) if t.text) if url and text: lines.append(f"[{text}]({url})")

w:hyperlink里的 r:id 属性用的命名空间和图片的 r:embed 一样,都是 officeDocument relationships。查到 url 后拼成 Markdown 链接。如果 url 为空,就退化输出纯文本,避免内容丢失。

到这里,一个能处理标题、列表、表格、图片、链接的 docx2md 已经成型。但离「敢在生产环境用」还差一步,因为真实文档的多样性远超想象。下一章讲那些最常见的翻车现场。

5. docx2md 避坑指南:五个最常翻车的地方

转换器写出来只是开始。真正让 docx2md 难写的,是各种排版软件、历史文档、样式覆盖带来的输入差异。这一章我按「现象 → 原因 → 解决」的顺序,记录五个踩过坑的地方,每条都对应真实文档里见过的情况。

5.1 分页符和分节符变成空行海啸

现象:转换出来的 Markdown 里出现十几行连续空行,渲染后段落之间隔了一大片空白,看起来像排版事故。

原因:docx 里有显式分页符和隐式分页标记两种东西。显式分页符是<w:br w:type="page"/>,如果你把它当成普通换行输出,会多出一个块。隐式的是w:lastRenderedPageBreak,这是 Word 在保存时记录的「这里曾经分页」的软标记,并不代表内容里有分页符。这两种节点被遍历到且不处理,空行就会叠加,再遇到段落末尾的空 run,输出直接变成空行海啸。

解决:遍历段落时,把 br 标签里带type="page"的节点跳过;对lastRenderedPageBreak标签直接忽略。可以在拼文本前统一过滤:

def get_text(p): parts = [] for node in p.iter(): if node.tag == w("br") and node.get(w("type")) == "page": continue if node.tag == w("lastRenderedPageBreak"): continue if node.tag == w("t") and node.text: parts.append(node.text) return "".join(parts).strip()

这样分页符不会产生任何输出,文本拼起来也干净。判断条件是node.get(w("type")) == "page",因为普通换行 br 没有 type 属性,不需要拦。

5.2 同一张图片被导出两次

现象:输出目录里出现两张内容完全一样的图片,文件名不同,占了两倍空间,Markdown 里也有两处引用。

原因:docx 里图片存在两种并存的引用体系。新式的是 a:blip 的 r:embed,兼容模式的是 v:imagedata 的 r:id,有的排版软件会把两种都写进文档,指向同一个关系 ID。转换器如果对这两种标签各自处理一遍,不做去重,同一张图片就会被导出两次。

解决:维护一个 rId 到输出文件名的字典,导出前先查。同一个 rId 只导出一次,后续引用直接复用已导出的文件名。

seen_images = {} def export_image(rid, rels, docx_path, out_dir): if rid in seen_images: return seen_images[rid] target = rels.get(rid, "") if not target: return "" src = f"word/{target}" out_name = target.split("/")[-1] with zipfile.ZipFile(docx_path) as z: data = z.read(src) with open(f"{out_dir}/{out_name}", "wb") as f: f.write(data) seen_images[rid] = out_name return out_name

这个模式的另一个好处是:如果同一个 rId 被正文引用了三次,三次都指向同一个输出文件,Markdown 渲染出来是同一张图,符合预期。

5.3 标题判断失效:样式名中英文差异与直接格式干扰

现象:某文档里一级标题被判断成了正文,输出没有#;或者二级标题消失,直接跳到了三级标题,层级全乱。

原因:常见三种。第一,样式 ID 是「标题1」而映射表里只写了 Heading1,判断自然落空。第二,文档来自某国产办公软件,段落根本不使用标题样式,而是手动加粗、加大字号,pStyle 里什么都没有。第三,样式被覆盖,pStyle 指向的样式在 styles.xml 里被改成了无大纲级别的普通样式。

解决:判断顺序改为三层。先查 pStyle 的样式 ID,命中映射表直接用;没命中就查 styles.xml 里这个样式有没有 outlineLvl 大纲级别;再兜底看 run 的直接格式,b 标签表示加粗,sz 标签超过一定数值(比如 24 磅)就按标题处理。三层逻辑写在一个函数里,优先级从高到低,返回第一个命中的结果。

样式 ID 的映射建议直接做双语表,把 Heading1、标题1 这些都收进来。不要依赖样式显示名去做匹配,显示名在不同语言版本里可能是「heading 1」也可能是「标题 1」,而样式 ID 相对稳定。

5.4 纵向合并单元格让表格行列错位

现象:Markdown 表格渲染后少了一列,或者内容串到上一行的单元格里,对不上号。

原因:纵向合并单元格在 docx 里的实现是:合并开始的单元格带w:vMerge w:val="restart",后续被合并的单元格只带一个w:vMerge空标签。视觉上这些单元格被合并成了一个,但 XML 里它们是完整存在的。转换器如果把 continue 单元格当普通单元格输出,表格会多出一列;如果跳过它,行数又对不上。

解决:遇到 vMerge 且没有 restart 属性时,输出空字符串占位,保证每行列数一致。核心代码在 4.3 节已经写过,这里强调一个容易漏的细节:vMerge 的判断要优先于文本提取,不能在提取完文本后再判断,否则占位单元格里残留的文本会被带出来,影响对齐。

5.5 超链接丢失:r:id 背后还有一层 rels

现象:链接文字还在,但 href 没了,渲染出来是一串普通文本;或者更糟,输出里出现一串花括号和代码片段。

原因:docx 的超链接不是直接存 URL 的,w:hyperlink标签里只有一个 r:id,真正的 URL 在 document.xml.rels 里。只遍历 w:t 拿文本自然拿不到链接地址。另一种情况是文档里有目录域代码,w:instrText里写着 TOC 等域指令,如果不处理,它会原样输出成一大段花括号包裹的代码,非常难看。

解决:处理w:hyperlink时先取 r:id,查 rels 拿 URL,拼成[文字](URL)。对于w:instrText,检测文本内容,如果是以 TOC、PAGE、REF 等域指令开头的,直接丢弃。目录在 Markdown 里不需要保留,渲染器会自己生成,保留旧目录反而会造成重复目录。

6. 用快照测试锁住转换质量:样本集、基线、diff

第五章这些坑说明一个问题:docx2md 的 bug 往往不是逻辑错,而是输入差异。同一份代码在不同文档上的表现千差万别,最怕的是改了一个坑,另一个文档又坏了,还没有任何提示。我后来养成的习惯,是给转换器加一套快照测试。

第一步,准备样本集。至少四类文档:只有标题和正文的;带三层以上表格的;带图片、链接、脚注的;某国产办公软件另存过的。每类一两份就够,关键是覆盖结构差异。凡是转换出过问题的文档,也统统丢进样本集,哪怕它结构很怪,这才是快照测试真正的价值。

第二步,人工核对第一版输出,确认标题、表格、图片都正确,把输出保存为基线。基线一旦确认,就只允许被显式更新,不允许被代码改动悄悄改变。

第三步,写一个回归脚本,每次改完代码就跑一遍:

#!/usr/bin/env bash set -euo pipefail for docx in samples/*.docx; do base=$(basename "$docx" .docx) python docx2md.py "$docx" > "out/$base.md" if ! diff -u "baseline/$base.md" "out/$base.md"; then echo "FAIL: $base" exit 1 fi done echo "ALL PASS"

set -euo pipefail保证脚本在出错时立即退出,不会带着失败继续跑。diff -u输出具体差异,哪个文件、哪一行变了,一目了然。如果预期输出确实要变,先人工确认新输出是对的,再cp覆盖基线,用版本管理记录下来。

我吃过一次亏:某次为了处理目录域代码,改了文本过滤逻辑,结果所有带表格的文档全部错位,是快照测试在提交前发现的。那次之后,我再也不敢不跑回归就提交 docx2md 的改动。转换器的正确性不是靠写出来的,是靠一遍遍回归保住的。样本集越大,越敢改代码,这是个正向循环。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询