说实话,C#开发里做Word文档数据提取的需求一直不少:批量导入报表、解析合同信息、爬取简历附件、从技术文档里抽取图片素材……但网上讲这块的资料特别零散,要么只讲文本读取,要么直接用COM组件把程序搞得又慢又不稳定。我自己在项目里也是踩了好几次坑,才把一套稳定可靠的方案跑通,今天干脆把文本、表格、图片三块提取的完整思路和代码一次性梳理出来,方便以后直接抄作业。
这篇内容主要面向两类人:一类是刚接触Word解析、需要快速做出可用工具的C#开发者,另一类是已经在做文档处理、但当前方案又卡又慢、想换技术路线的朋友。我会把技术选型、底层原理、代码实现、常见坑全部串起来讲,不搞虚的,全部以可落地的代码为准。
1. 为什么需要自动提取Word数据:先搞清楚你的业务场景
1.1 从人工复制到程序化提取的转变
我最早接Word提取需求,是帮一个做档案管理的客户写批量导入工具。他们的业务人员每天要把几百份Word合同里的关键字段——合同编号、甲方名称、签署日期、合同金额——手动复制进Excel,一份合同至少花两三分钟,一天下来光复制粘贴就占掉大半天。后来改成程序提取,一份合同的解析时间降到毫秒级,准确率还比人工高。
这种需求背后是同一个逻辑:Word文档实际上是一种"半结构化"数据容器,文本、表格、图片混排在一起,而真正有价值的业务数据往往藏在特定位置的段落里、表格的行列里、或者内嵌的图片附件中。人工翻阅文档找数据,本质上是在做"模式匹配"工作,只要文档一多,效率和准确性必然出问题。
1.2 三类核心提取需求占比
从我这几年接触的项目来看,Word提取需求大致可以分成三类:文本提取占比最高,表格提取其次,图片提取相对少但一旦碰到就很头疼。文本提取常见的场景是批量读取Word简历中的姓名、电话、工作经历;表格提取常见的是读取报价单、审批表、台账;图片提取则常见于需要把技术文档中的配图、产品图、签名照片批量导出的场景。
这三类需求经常混合出现。比如提取一份完整的项目验收报告,既需要正文文本,也需要验收表格里的结论数据,还可能要把现场照片从文档里抽出来存档。所以做技术方案时,不能只写一个只处理文本的函数就完事,最好把三块能力统一封装,给上层业务提供一致的数据结构。
1.3 提一个容易被忽略的点:文档来源多样性
很多人写代码时默认所有Word文档都是标准样式,实际生产环境根本不是这样。有的文档来自WPS生成,有的文档从PDF转过来,有的文档页眉页脚里藏着重要信息,有的文档内容被放在文本框里不仔细读就丢了。这些边缘情况如果不在设计阶段考虑进去,后面上线一定会被业务人员骂。
因此做Word提取工具的第一原则是:按"文档结构"去读,而不是按"肉眼看到的排版"去读。正文字体多大、颜色如何、是否加粗,这些视觉特征变化太多,真正稳定的是Word文档的XML结构——段落就是Paragraph,表格就是Table,图片就是Drawing。只要稳定读取结构,任何文档都不会跑偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:三种主流方案怎么取舍
2.1 OpenXML SDK、NPOI、COM组件对比
我用过的Word解析方案主要有三条路线:微软官方的OpenXML SDK、开源的NPOI、以及通过COM调用本地Word应用程序。很多新手一上来就搜到COM方案,因为网上老教程多,代码看起来也简单,但实际上这个方案坑最深。
我用一个表格把三条路线的关键差异列出来,方便你决策:
| 对比项 | OpenXML SDK | NPOI | COM组件 |
|---|---|---|---|
| 文档解析方式 | 直接解包读取XML结构 | 底层封装的XWPF API | 启动本机Word进程 |
| 是否需要安装Office | 不需要 | 不需要 | 必须安装Office |
| 跨平台支持 | 支持(Linux可用) | 支持(Java移植过来) | 仅Windows |
| 性能表现 | 快,无进程开销 | 较快 | 慢,频繁启停进程 |
| 文档格式支持 | docx为主,不直接支持doc | docx为主 | doc和docx都支持 |
| 稳定性 | 高 | 较高 | 低,容易崩溃或残留进程 |
| 学习曲线 | 略陡,需理解XML结构 | 中等 | 简单,但隐藏问题多 |
2.2 我的选型建议:默认OpenXML SDK
上面的表格对比其实已经很直观了,我在实际项目里最终选择的是OpenXML SDK,原因有三点:第一,它是官方维护的类库,API设计和文档质量有保障,遇到问题能在官方文档和社区里找到答案;第二,它不依赖Office运行环境,可以部署在服务器上做批量处理任务,这对很多后端服务来说很关键;第三,它的底层模型与docx文件格式天然匹配,能精确读取段落、表格、图片、样式、修订等各类结构数据。
NPOI也不错,我早期用它在Java里处理Excel比较多,后来C#项目里也用NPOI处理过Word,但它对Word处理能力的完整度不如OpenXML SDK,尤其是图片提取、表格合并单元格、复杂样式处理上经常力不从心。所以现在我的默认选择就是OpenXML SDK,只有遇到老版本.doc格式文件时,才会临时用COM组件转换一次,或者另外用工具把.doc转成.docx再继续。
2.3 补充说明:为什么不推荐在服务器上使用COM
有一点必须单独拿出来强调:千万不要在服务器端用COM组件处理Word文档。COM方案会启动一个完整的Word进程,如果代码中途异常没有释放,Windows任务管理器里就会残留大量WINWORD.EXE进程,时间一长内存耗尽,服务器直接卡死。我见过不止一次线上事故,最后排查下来全是COM进程泄漏。
而且微软自己也官方表态,不推荐在服务器端非交互模式下使用Office组件,因为这类组件的设计目标是桌面客户端,并发处理、权限隔离、超时控制都不完善。如果你的应用场景是Web API或后台服务,请优先使用OpenXML SDK这类纯托管库,省心太多。
3. 环境准备与基础封装:先把地基打好
3.1 安装OpenXML SDK
在Visual Studio里通过NuGet包管理器搜索DocumentFormat.OpenXml,安装最新稳定版即可。我当前项目的引用版本是3.x,API相对简洁,推荐直接使用。
bash复制Install-Package DocumentFormat.OpenXml
安装完成后,项目中会出现DocumentFormat.OpenXml.dll以及依赖的WindowsBase。注意一点:如果目标框架是.NET Framework,需要确认项目已安装引用WindowsBase程序集,如果目标框架是.NET Core/.NET 5+,默认就可以识别。
3.2 理解docx的底层结构
想用好OpenXML SDK,必须先理解docx的本质:它其实是一个压缩包,内部包含一组XML文件。主要结构是word/document.xml(正文内容)、word/media/(图片等媒体资源)、word/header*.xml(页眉)、word/footer*.xml(页脚)、word/styles.xml(样式定义)。
这个结构意味着,读取Word内容本质上就是解包再解析XML。OpenXML SDK把这一层封装好了,所以代码里不需要手动处理zip压缩和XML序列化,只需要操作MainDocumentPart、Paragraph、Table这些高级对象。
3.3 统一封装一个文档打开辅助方法
为了后面多次复用,我习惯封装一个安全的文档打开方法,避免每次写重复判断。下面这个封装同时处理了文档是否存在、是否损坏两种常见情况:
csharp复制using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
public static class WordDocumentHelper
{
/// 打开Word文档,返回可用的WordprocessingDocument对象
public static WordprocessingDocument OpenForRead(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"文件不存在:{filePath}");
}
// 第二个参数false表示只读模式,避免误修改原文档
var doc = WordprocessingDocument.Open(filePath, false);
if (doc.MainDocumentPart == null)
{
doc.Dispose();
throw new InvalidDataException($"文档结构无效:{filePath}");
}
return doc;
}
}
这里特别要说明一下第二个参数:传false表示以只读方式打开,这是提取数据场景的安全选择。如果误传true,读取过程中里面对文档对象做了任何写操作,都会直接改动原文件,万一代码有bug,原始文档就毁了。
4. 文本数据提取:从段落走向结构化
4.1 最简单的段落文本读取
拿到WordprocessingDocument对象后,最容易忽略的一步是先定位到Body。Body是正文的根节点,所有段落、表格、图形都挂在这个节点下面。定位到Body之后,用Descendants方法可以按类型筛选出所有Paragraph元素。
csharp复制public static List<string> ExtractParagraphText(string filePath)
{
var result = new List<string>();
using (var doc = WordDocumentHelper.OpenForRead(filePath))
{
var body = doc.MainDocumentPart.Document.Body;
foreach (var paragraph in body.Descendants<Paragraph>())
{
// 直接取InnerText即可拿到该段落下的所有文字
string text = paragraph.InnerText;
if (!string.IsNullOrWhiteSpace(text))
{
result.Add(text);
}
}
}
return result;
}
Paragraph.InnerText之所以能取到完整文字,是因为Word的段落文本由若干Run组成,每个Run又包含若干Text节点。InnerText会把所有子文本节点拼接起来。对于大多数不需要精细化格式的场景,这个写法已经够用。
4.2 保留逐段样式信息:不只要内容,还要格式
有一种常见需求是希望同时知道这段文本是什么样式,比如小标题、正文、还是加粗重点。只用InnerText拿不到这些信息,需要遍历Run的RunProperties读取格式属性。
csharp复制public static List<ExtractedParagraph> ExtractParagraphWithStyle(string filePath)
{
var result = new List<ExtractedParagraph>();
using (var doc = WordDocumentHelper.OpenForRead(filePath))
{
var body = doc.MainDocumentPart.Document.Body;
foreach (var paragraph in body.Descendants<Paragraph>())
{
var styleId = paragraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value;
bool isBold = false;
string text = "";
foreach (var run in paragraph.Elements<Run>())
{
text += run.InnerText;
var bold = run.RunProperties?.Bold;
if (bold != null && bold.Val != null && bold.Val.Value)
{
isBold = true;
}
}
result.Add(new ExtractedParagraph
{
Text = text,
StyleId = styleId,
IsBold = isBold
});
}
}
return result;
}
public class ExtractedParagraph
{
public string Text { get; set; }
public string StyleId { get; set; }
public bool IsBold { get; set; }
}
StyleId可以看作是文档中定义的样式名称的引用ID,如果要拿到真正的样式名称,还需要去Styles.xml里做映射。一般情况下,StyleId本身就够用,因为同一个模板生成的文档,样式ID通常是固定的,可以用作匹配规则。
4.3 不要丢掉文本框、页眉页脚里的文字
这里必须强调一个细节:很多人写文本提取时只遍历Body下的Paragraph,结果发现某些内容怎么都提取不到。最常见的两种遗漏:一是内容被放在文本框(TextBox)里,二是被放在页眉页脚中。
文本框内容实际上在Word文档中也是以Paragraph形式存在的,但是包裹结构不同。更稳定的做法是直接从整个MainDocumentPart的XElement树中查找所有Paragraph节点,然后取InnerText,下面我提供一个更稳妥的写法:
csharp复制public static List<string> ExtractTextFromWholeDocument(string filePath)
{
var result = new List<string>();
using (var doc = WordDocumentHelper.OpenForRead(filePath))
{
var mainPart = doc.MainDocumentPart;
// 遍历正文、页眉、页脚所有部分
var partsToSearch = new List<OpenXmlPart> { mainPart };
partsToSearch.AddRange(mainPart.HeaderParts);
partsToSearch.AddRange(mainPart.FooterParts);
foreach (var part in partsToSearch)
{
var root = part.RootElement;
if (root == null) continue;
foreach (var paragraph in root.Descendants<Paragraph>())
{
string text = paragraph.InnerText;
if (!string.IsNullOrWhiteSpace(text))
{
result.Add(text);
}
}
}
}
return result;
}
这样处理后,正文、文本框、页眉、页脚里的文字都能覆盖到。如果你只关心正文,可以不做HeaderParts和FooterParts的扩展。
5. 表格数据提取:把Word表格变成可编程的数据结构
5.1 表格在Word文档中的XML结构
Word表格在XML里由Table、TableRow、TableCell三层结构构成。Table下面有若干TableRow,每个TableRow下面有若干TableCell,每个TableCell里面又包含若干Paragraph或嵌套表格。
但这里有个很容易出错的地方:直接用Descendants
5.2 读取表格并输出为行集合
下面这段代码可以读取正文中第一个表格,并且正确处理简单合并单元格的情况,把每个单元格的文本拼接起来:
csharp复制public static List<List<string>> ExtractFirstTable(string filePath)
{
var rowsData = new List<List<string>>();
using (var doc = WordDocumentHelper.OpenForRead(filePath))
{
var body = doc.MainDocumentPart.Document.Body;
var table = body.Descendants<Table>().FirstOrDefault();
if (table == null) return rowsData;
foreach (var row in table.Elements<TableRow>())
{
var rowCells = new List<string>();
// 直接取表格行的子单元格,避免嵌套表格干扰
foreach (var cell in row.Elements<TableCell>())
{
// 单元格内容可能跨多个段落,拼接时用换行符保留结构
var cellTexts = cell.Descendants<Paragraph>()
.Select(p => p.InnerText);
string cellText = string.Join(Environment.NewLine, cellTexts);
rowCells.Add(cellText.Trim());
}
rowsData.Add(rowCells);
}
}
return rowsData;
}
需要注意Table.Elements
5.3 处理合并单元格:GridSpan和VerticalMerge
很多人会碰到的另一个问题是读取到的行数对不上,或者单元格行列错位,原因就是合并单元格。合并分两种:水平合并(Horizontal Merge)和垂直合并(Vertical Merge)。
水平合并会为单元格添加GridSpan属性,表示该单元格横跨几列,读取时要把实际展开列数算出来。垂直合并则会给单元格设置VerticalMerge属性,其中Restart表示该合并区域的起点,Continue表示后续合并的延续行,Continue行的文本可能会是空的。
我一般在读取时会把GridSpan值解析出来,并保留"合并起始"标记,这样后续做数据清洗时能知道哪些位置需要向上补数据:
csharp复制public static List<TableRowModel> ExtractTableWithMergeInfo(string filePath)
{
var rows = new List<TableRowModel>();
using (var doc = WordDocumentHelper.OpenForRead(filePath))
{
var body = doc.MainDocumentPart.Document.Body;
var table = body.Descendants<Table>().FirstOrDefault();
if (table == null) return rows;
foreach (var row in table.Elements<TableRow>())
{
var rowModel = new TableRowModel();
foreach (var cell in row.Elements<TableCell>())
{
var gridSpan = cell.TableCellProperties?.GridSpan?.Val?.Value ?? 1;
var vMerge = cell.TableCellProperties?.VerticalMerge;
string mergeState = "none";
if (vMerge != null)
{
mergeState = vMerge.Val == null ? "continue" : "restart";
}
var cellTexts = cell.Descendants<Paragraph>()
.Select(p => p.InnerText);
string text = string.Join(" ", cellTexts).Trim();
rowModel.Cells.Add(new CellModel
{
Text = text,
GridSpan = gridSpan,
MergeState = mergeState
});
}
rows.Add(rowModel);
}
}
return rows;
}
public class TableRowModel
{
public List<CellModel> Cells { get; set; } = new List<CellModel>();
}
public class CellModel
{
public string Text { get; set; }
public int GridSpan { get; set; }
public string MergeState { get; set; }
}
实际使用时,如果ReadOnly的提取工具遇到带合并行的表格,我通常会把Continue状态的单元格文本置为空,然后在数据清洗阶段回填上面的值,这样导出的Excel或CSV就和视觉上的行列对齐了。
5.4 把表格转成DataTable:衔接主流数据处理流程
既然要做数据提取,最终数据大概率要进Excel、SQLite或者内存Grid。把表格转为DataTable是最直接的中间格式,方便后续做筛选和导入。这里给出一个兼容合并信息的转换辅助方法:
csharp复制public static DataTable ToDataTable(List<List<string>> rows)
{
var dt = new DataTable();
if (rows == null || rows.Count == 0)
{
return dt;
}
int maxCols = rows.Max(r => r.Count);
for (int i = 0; i < maxCols; i++)
{
dt.Columns.Add($"Column{i + 1}");
}
foreach (var row in rows)
{
// 确保行列长度一致,避免添加DataRow时报错
var dr = dt.NewRow();
for (int i = 0; i < maxCols; i++)
{
dr[i] = i < row.Count ? row[i] : "";
}
dt.Rows.Add(dr);
}
return dt;
}
这一步的要点是处理行长度不一致的情况。很多Word表格因为合并单元格的关系,直接读取得到的行单元格数并不相同,如果直接往DataTable里塞数据就会抛异常,上面的代码统一补齐了缺失列的值。
6. 图片数据提取:文档里的每一张图都不放过
6.1 图片在包结构中的位置
docx里的图片通常存放在word/media路径下,但直接去目录里复制文件很不推荐,一是因为文件命名可能不直观,二是因为无法准确对应图片在文档中的出现顺序。
推荐的做法是通过MainDocumentPart的ImageParts属性拿到图片部件集合,再结合文档正文中Drawing元素的顺序,还原图片的正确出现顺序。这个处理方式才能保证提取出来的图片顺序和肉眼看到的文档顺序一致。
6.2 按文档顺序提取所有图片
下面这个方法会遍历正文段落,找到每个内嵌图片的RelationshipId,然后从ImageParts中按该ID取到图片数据并保存:
csharp复制public static List<ExtractedImage> ExtractImages(string filePath, string outputDir)
{
var images = new List<ExtractedImage>();
if (!Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
using (var doc = WordDocumentHelper.OpenForRead(filePath))
{
var mainPart = doc.MainDocumentPart;
// 建立 RelationshipId 到 ImagePart 的映射
var imagePartMap = mainPart.ImageParts.ToDictionary(
part => mainPart.GetIdOfPart(part),
part => part
);
// 遍历每个段落,按顺序找段落内所有图片
foreach (var paragraph in mainPart.Document.Body.Descendants<Paragraph>())
{
foreach (var drawing in paragraph.Descendants<Drawing>())
{
var blip = drawing.Descendants<Blip>().FirstOrDefault();
if (blip?.Embed != null)
{
string relId = blip.Embed.Value;
if (imagePartMap.TryGetValue(relId, out var imagePart))
{
string ext = GetExtensionFromContentType(imagePart.ContentType);
string fileName = $"image_{images.Count + 1}{ext}";
string savePath = Path.Combine(outputDir, fileName);
using (var stream = imagePart.GetStream())
using (var fileStream = new FileStream(savePath, FileMode.Create))
{
stream.CopyTo(fileStream);
}
images.Add(new ExtractedImage
{
FileName = fileName,
SavePath = savePath,
RelationshipId = relId
});
}
}
}
}
}
return images;
}
private static string GetExtensionFromContentType(string contentType)
{
return contentType switch
{
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/bmp" => ".bmp",
"image/tiff" => ".tiff",
_ => ".bin"
};
}
public class ExtractedImage
{
public string FileName { get; set; }
public string SavePath { get; set; }
public string RelationshipId { get; set; }
}
关于Blip.Embed要说一下:该属性保存的是图片在文档中的RelationshipId,通过GetIdOfPart可以把这个RelationshipId和对应的ImagePart关联起来。这一步是图片提取的关键,不少网上的教程直接遍历ImagePart,虽然也能拿到图片,但顺序完全是未知的,我这个方案保证顺序和内容都准确。
6.3 处理页眉页脚中的图片
很多公司模板会在页眉放Logo,在页脚放签名章,这些图片在正文的Body里是找不到的。如果你要提取整个文档的图片,必须把HeaderPart和FooterPart里的图片也纳入考虑。
处理方法和处理MainDocumentPart相同,只是目标部分变了。可以把HeaderPart.ImageParts和FooterPart.ImageParts也遍历一遍,如果重复的Logo出现了很多次,建议结合图片哈希去重,只保留一份。
6.4 对图片进行压缩或统一格式
批量提取出来的图片可能又大又多,业务上经常需要统一压缩。使用System.Drawing.Common或第三方库SixLabors.ImageSharp都行,我这里简单展示一下如何使用ImageSharp统一把图片转成JPG并缩放:
csharp复制// 需要安装包:SixLabors.ImageSharp
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
public static void ConvertToJpg(string inputPath, string outputPath, int maxWidth)
{
using (var image = Image.Load(inputPath))
{
if (image.Width > maxWidth)
{
float ratio = (float)maxWidth / image.Width;
int newHeight = (int)(image.Height * ratio);
image.Mutate(x => x.Resize(maxWidth, newHeight));
}
image.SaveAsJpeg(outputPath);
}
}
这个小工具看似简单,但在实际项目里非常有用,尤其是从Word里批量抽出几百张图片,需要统一尺寸存到素材库的时候。注意引用ImageSharp时,项目中可能需要额外处理本地化资源包的NuGet依赖。
7. 综合实战:做一个包含文本、表格、图片的完整提取器
7.1 需求定义和结构设计
到这里,三块能力都讲完了,实现可以组合成一个完整的提取器。我们做一个命令行工具,输入Word文档路径和输出目录,自动完成以下四件事:提取正文段落并输出到TXT、提取表格并输出为CSV、提取图片并保存到images目录、生成一份JSON格式的摘要文件。
这样设计的好处是分层清晰:提取器负责调用前面写的各个方法,摘要文件负责记录提取结果的整体情况,业务层拿到JSON后就知道数据来源和数量是否正常。
7.2 完整代码示例
csharp复制public class WordExtractorTool
{
public static void Run(string filePath, string outputDir)
{
if (!Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
// 1. 提取文本
var paragraphs = ExtractParagraphText(filePath);
string textPath = Path.Combine(outputDir, "content.txt");
File.WriteAllLines(textPath, paragraphs);
// 2. 提取表格
var tableRows = ExtractFirstTable(filePath);
var table = ToDataTable(tableRows);
string csvPath = Path.Combine(outputDir, "table.csv");
SaveDataTableToCsv(table, csvPath);
// 3. 提取图片
string imageDir = Path.Combine(outputDir, "images");
var images = ExtractImages(filePath, imageDir);
// 4. 生成摘要JSON
var summary = new
{
SourceFile = filePath,
ParagraphCount = paragraphs.Count,
TableRows = table.Rows.Count,
TableColumns = table.Columns.Count,
ImageCount = images.Count,
ExtractTime = DateTime.Now
};
string jsonPath = Path.Combine(outputDir, "summary.json");
File.WriteAllText(jsonPath, JsonSerializer.Serialize(summary, new JsonSerializerOptions
{
WriteIndented = true
}));
}
private static void SaveDataTableToCsv(DataTable table, string path)
{
var sb = new StringBuilder();
// 列头
sb.AppendLine(string.Join(",", table.Columns.Cast<DataColumn>().Select(c => c.ColumnName)));
// 每行数据
foreach (DataRow row in table.Rows)
{
var cells = row.ItemArray.Select(v => $"\"{Convert.ToString(v).Replace("\"", "\"\"")}\"");
sb.AppendLine(string.Join(",", cells));
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
}
CSV写入时,我给每个字段强制加了双引号,并做了双引号转义。这一步是为了防止字段里出现逗号和换行导致CSV结构错乱,特别是从Word表格读取的文本经常包含逗号,不处理就会被Excel拆成多列,数据就全乱了。
7.3 运行效果与结果解读
程序运行结束后,输出目录下会出现四个文件。content.txt按段落顺序记录了所有正文文本;table.csv以行列结构存储了表格内容;images目录下是按出现顺序命名的图片;summary.json则是本次提取任务的统计信息,方便程序日志记录和数据对账。
需要说明的是,这个综合版工具做了不少简化:表格只提取了第一个表格;文本只提取了正文段落,没有处理页眉页脚;图片去重和压缩也没有纳入。真正的生产级工具还需要按各自的文档规则去扩展,但核心骨架就是这么一套,扩展起来很容易。
8. 常见问题与排查技巧实录
8.1 表格读取顺序错乱或漏行
症状:明明文档里表格是整齐的三行四列,程序读出来却多了不少行,或者某些行内容对不上。
原因:绝大多数情况是没有隔离嵌套表格。单元格里再插一个表格,Descendants会把内层表格的行也当作外层表格的普通行读出来。解决方法是,在遍历行时明确使用Elements
这个坑我吃过一次大亏,当时解析一份带注解表格的文档,注解内容都是放在单元格内的小表格里,结果输出数据几乎全错,排查了半天才定位到是嵌套表格引起的。
8.2 读取docx时报"文件损坏"或"格式不正确"
症状:某些来源的Word文档用OpenXML SDK打开直接抛异常。
原因:这类文档通常是伪docx文件,后缀是.docx但实际是老版.doc格式,或者是被第三方工具截断过。最简单的处理方式是先用File.ReadAllBytes读文件头几个字节,docx的zip头是"PK"(0x50 0x4B),而老版.doc的文件头是"D0 CF 11 E0"。
实际项目中,我会在程序里做一个格式预检,如果是老版doc,就调用一个独立服务把所有doc转成docx后再继续处理,或者在入口处直接拒绝提示用户转格式。不要试图在OpenXML SDK里兼容doc,那是用错了工具。
8.3 图片提取后顺序与文档排版不一致
症状:按顺序导出的图片编号和实际文档里看到的顺序对不上。
原因:如果只用MainDocumentPart.ImageParts直接遍历,图片顺序是包结构中的顺序,而不是文档排版顺序。必须通过Drawing元素的Embed属性建立图片顺序映射后,才能得到与交互式视图一致的顺序。
换句话说,遍历ImageParts拿到的是"仓库里的清单目录",遍历Drawing拿到的才是"货架上的摆放顺序"。两者要结合起来才能保证提取准确。
8.4 提取文本乱码
症状:读取文本后中文变成乱码,或者特殊符号变成问号。
原因:大部分情况下是控制台编码或文件输出编码的问题。OpenXML SDK读取的字符串本身就是Unicode,不会出现解析层面的乱码,但如果你在Windows控制台打印中文,而控制台代码页不是UTF-8,就会显示乱码。
解决办法是输出文件时显式指定Encoding.UTF8,尤其是写CSV文件时还要注意加BOM,否则Excel打开CSV中文会变成乱码。我上面CSV的写法里用了UTF8无BOM编码,如果你要保证Excel双击打开不乱码,需要改用带BOM的UTF-8:
csharp复制File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
8.5 程序占用文件导致无法删除或替换
症状:程序运行结束后,原Word文件被锁定,无法移动或覆盖。
原因:如果你打开了WordprocessingDocument但忘记释放,或者把只读模式误写成可写模式,文件会被系统锁定。虽然using语句能自动释放,但如果你在方法返回前就把对象赋给了成员变量并且没有调用Dispose,就可能会锁住文件。
排查时我习惯在代码末尾调用GC.Collect()再尝试删除文件,但这只是临时手段,根本解决方案是确保每个WordprocessingDocument都使用using包裹,并且不要在回调里把文档对象传到别的类长期保存。
8.6 OpenXML SDK版本与.NET版本不匹配
症状:编译时出现版本冲突或找不到类。
原因:DocumentFormat.OpenXml 3.x同时支持.NET Framework和.NET Core,但如果你引用了旧版本,在某些较新的.NET环境下可能出现部分API过时或包依赖冲突。建议直接启用最新稳定版,并且在使用SDK时同时引用System.Memory等基础包依赖。
如果项目是.NET Framework 4.x,还需要手动添加System.IO.Packaging相关引用。具体的启用方法在NuGet安装时会有提示,跟着提示处理即可。
写在后面:一个小建议
我在做过几个Word提取项目之后,最大的感觉是:很多人卡住的地方其实不是C#代码本身,而是对Word文档结构理解不到位。拿到一个docx,先不要急着写代码,可以手动用解压工具打开它,翻一翻word/document.xml和word/media目录,亲眼看看数据是怎么组织的。一旦理解了"docx是结构化的XML包"这一本质,OpenXML SDK里的API就会变得非常好推测。
最后再分享一个我常用的扩展技巧:如果文档里的表格特别多,而且每个表格的表头不同,可以先用关键词定位法找到目标表格。也就是先遍历所有表格,检查某一行单元格文本是否包含预期表头关键词,再根据这个关键词锁定要提取的表格。这个方法在批量处理不同模板的文档时特别实用,基本可以替代一部分简单的模板识别工作。希望上面这些内容能帮你少走点弯路,如果你在实操中遇到不一样的坑,欢迎多交流。
