工业级方案,格式完整转换,图片自动提取,零Office依赖
🤦 先说说这个需求是怎么来的
其实做这个工具前,我试着用了一些MS的markItDown项目,我试了一下,转换效果我觉得不行,可能是我不太会用吧,于是做了这个工具。做技术文档的同学,十有八九踩过这个坑Word写完了,要发博客。复制粘贴进去,格式全乱。图片不见了。表格变成一坨纯文本。然后你开始手动一行一行地加#、加**、补图片链接……
半小时过去了。你盯着屏幕,深呼吸。
我见过不止一个同事在这件事上消耗掉整个下午。说实话,这种重复性的体力劳动,真的不应该由人来做。所以我就想——用C#写个工具,彻底解决这事。
不依赖Office组件(很多服务器环境根本没装Word),用纯开源库,做成一个Winform桌面应用,拖进去、点一下、完事。
🧱 技术选型:为什么选 DocumentFormat.OpenXml
市面上能解析.docx的C#方案,大概有这几条路:
Interop(Office COM组件)——需要本机装了Word才能用,部署麻烦,服务器环境直接GG。
Aspose.Words——功能强大,但收费,而且不便宜。
NPOI——主要是Excel场景,Word支持相对弱。
DocumentFormat.OpenXml——微软官方开源,MIT协议,免费,.docx本质上就是ZIP+XML,这个库就是专门干这个的。
选它,没什么悬念。
# NuGet 安装,一行搞定Install-Package DocumentFormat.OpenXmlInstall-Package System.Drawing.Common # .NET 6+ 需要单独装🗂️ .docx 的本质:一个伪装成文件的压缩包
很多人不知道——你把任意一个.docx文件改后缀成.zip,直接解压,里面是一堆XML。
document.docx (解压后)├── word/│ ├── document.xml ← 正文内容在这里│ ├── media/ ← 图片都在这│ │ ├── image1.png│ │ └── image2.jpg│ └── _rels/│ └── document.xml.rels ← 图片关系映射└── [Content_Types].xml理解了这个结构,整个转换逻辑就清晰多了:解析XML,提取文本和图片,按Markdown规则重新组装。就这么简单粗暴。
🏗️ 整体架构设计
工具分三层,逻辑不混:

UI和业务逻辑完全分离,Designer.cs只管控件布局,FrmMain.cs只管事件和转换逻辑。这个原则在实际项目里真的很重要——我见过太多把SQL写在Button_Click里的"祖传代码"了,维护起来堪称噩梦。
🔍 核心代码拆解
段落处理:识别标题层级
Word里的标题,本质上是段落样式。Heading 1对应#,Heading 2对应##,以此类推。
// 读取段落的样式ID,映射成Markdown前缀privatestaticstringGetStyleName(Paragraph para){var styleId = para.ParagraphProperties ?.ParagraphStyleId?.Val?.Value ?? string.Empty;return styleId.ToLowerInvariant();}privatestaticstringHeadingPrefix(string styleName) => styleName switch{"heading1"or"1" => "#","heading2"or"2" => "##","heading3"or"3" => "###","heading4"or"4" => "####","heading5"or"5" => "#####","heading6"or"6" => "######", _ => string.Empty // 普通段落,无前缀};这里用了C# 8的switch表达式——比一堆if-else清爽多了。顺便说一句,or模式匹配是C# 9加进来的,用之前确认一下项目的目标框架。
Run级别的富文本:粗体、斜体、代码
Word里,一个段落(Paragraph)由多个Run组成。每个Run可以有自己的格式属性。
privatestaticstringBuildInlineText(Paragraph para){var sb = new StringBuilder();foreach (var run in para.Elements<Run>()) {var rpr = run.RunProperties;var bold = rpr?.Bold != null;var italic = rpr?.Italic != null;// 等宽字体 → 行内代码var code = rpr?.RunFonts?.Ascii?.Value ?.Contains("Courier", StringComparison.OrdinalIgnoreCase) ?? false;var text = string.Concat( run.Elements<Text>().Select(t => t.Text));if (string.IsNullOrEmpty(text)) continue;// 装饰顺序:code > bold > italic text = code ? $"`{text}`" : text; text = bold ? $"**{text}**" : text; text = italic ? $"*{text}*" : text; sb.Append(text); }return sb.ToString();}有个小细节值得注意:Bold != null这个判断,而不是判断它的Value是否为true。原因是OpenXml里,<w:b/>这个空标签本身就代表"启用粗体",Value不一定存在。踩过这个坑的举个手。
图片提取:关键在关系映射
图片是整个转换里最有意思的部分。Word里的图片,通过Drawing → Blip → embed关系ID这条链路找到实际的图片文件。
privatestringExtractImage( Drawing drawing, WordprocessingDocument doc,string imagesDir, string imgFmt,string baseName, refint imgIndex){// 1. 找到Blip(图片引用节点)var blip = drawing.Descendants<A.Blip>().FirstOrDefault();var relId = blip?.Embed?.Value;if (string.IsNullOrEmpty(relId)) returnstring.Empty;// 2. 通过关系ID拿到图片Partvar part = doc.MainDocumentPart.GetPartById(relId);// 3. 确定输出扩展名var ext = imgFmt == "原始格式" ? Path.GetExtension(part.Uri.ToString()).TrimStart('.') : imgFmt; imgIndex++;var fileName = $"{baseName}_img{imgIndex:D3}.{ext}";var destPath = Path.Combine(imagesDir, fileName);// 4. 写出图片文件usingvar stream = part.GetStream();usingvar ms = new MemoryStream(); stream.CopyTo(ms); ms.Position = 0;if (imgFmt == "原始格式") { File.WriteAllBytes(destPath, ms.ToArray()); }else {// 需要格式转换时,走System.Drawingusingvar bmp = new Bitmap(ms);var format = imgFmt == "jpg" ? System.Drawing.Imaging.ImageFormat.Jpeg : System.Drawing.Imaging.ImageFormat.Png; bmp.Save(destPath, format); } AppendLog($"[图片] 提取 → images/{fileName}", Color.LightBlue);// 5. 返回Markdown图片语法return$"";}提取出来的图片统一放在images/子目录,Markdown里的引用路径用相对路径——这样整个目录打包发给别人,图片链接依然有效。这个细节很多人忽视,结果发出去的文章图片全是404。
表格转换:GFM标准格式
GitHub Flavored Markdown(GFM)的表格语法,第一行是表头,第二行是分隔符---,之后是数据行。
privatestaticstringConvertTable(Table table){var sb = new StringBuilder();var rows = table.Elements<TableRow>().ToList();var isFirst = true;foreach (var row in rows) {var cells = row.Elements<TableCell>() .Select(c => BuildInlineText( c.Elements<Paragraph>().FirstOrDefault() ?? new Paragraph())) .ToList(); sb.AppendLine("| " + string.Join(" | ", cells) + " |");// 首行之后插入分隔行if (!isFirst) continue; sb.AppendLine("| " + string.Join(" | ", cells.Select(_ => "---")) + " |"); isFirst = false; }return sb.ToString().TrimEnd();}🖥️ UI设计:够用就好,别过度设计
界面没做得很花哨。实用主义风格——该有的都有,不该有的一概不加。

彩色日志是个小心思——信息用白色,成功用绿色,图片提取用蓝色,警告用黄色,错误用橙红色。眼睛一扫就知道哪里出问题了,不用逐行读。
转换用async/await + Task.Run跑在后台线程,UI全程不卡。进度条在转换中显示Marquee滚动动画,转完归位100%。这些细节,用起来体验差很多。
⚠️ 几个真实踩坑记录
坑一:嵌套表格。Word允许表格里嵌表格,当前实现只处理顶层表格。遇到复杂嵌套结构,内层表格会被当成纯文本处理。暂时够用,有需要可以递归扩展。
坑二:文本框和形状。Word里的文本框(TextBox)、艺术字这类元素,不在Body的直接子元素里,当前版本会跳过。这类内容在文档里本来就是例外情况,影响不大。
坑三:复杂列表。有序列表和无序列表在OpenXml里用NumPr(编号属性)表示,解析起来比标题复杂得多,需要读取NumberingDefinitions才能确定层级和样式。当前版本将列表项当普通段落处理——后续可以单独扩展这块。
坑四:System.Drawing在Linux上。如果你打算把这工具移植到Linux或Docker环境,System.Drawing.Common在非Windows平台有限制(.NET 6+已经不推荐)。那种场景建议换ImageSharp库。
🚀 实际效果对比
一份32页的技术文档,含18张截图、6个表格:
💬 最后说几句
这个工具的核心思路并不复杂,但把它做成一个真正好用的桌面应用,有很多细节要打磨——异步不卡UI、彩色日志、格式选项、覆盖保护……这些东西单独拿出来都是小事,但组合在一起,才是"好用"和"能用"的区别。
代码遵循了UI与逻辑分离的原则,后续想扩展(比如批量转换多个文件、支持列表解析、加脚注处理)都很方便,改FrmMain.cs的业务层就好,不用动界面。
有类似需求的同学可以直接基于这个框架改造,DocumentFormat.OpenXml的文档虽然有点晦涩,但一旦理解了它的对象模型,扩展起来相当顺手。
如果你在实际使用中遇到特殊的Word格式没被正确处理,欢迎留言聊聊——说不定下篇文章就是解决你的问题。
夜雨聆风