资讯动态

SpringBoot 解析 docx 实战:文字与图片提取的完整链路

发布时间:2026/10/23 19:10:31 来源:尧图企业网站定制
1. 为什么在 SpringBoot 里解析 docx 是个“看起来简单、做起来全是坑”的活很多做企业级应用的朋友都遇到过这种需求用户上传一份 Word 文档系统需要自动把里面的文字提取出来做全文检索或者把文档里的图片抠出来存到对象存储再或者把内容结构化后写入数据库。乍一听这不就是调个库读文件的事吗我一开始也这么想结果真正动手才发现Word 文档的解析远没有想象中那么“老实”。先说一个最容易被忽略的事实.doc和.docx完全是两种东西。.doc是微软早期的二进制复合文档格式结构封闭、解析困难而.docx本质上是一个 ZIP 压缩包里面装着一堆 XML 文件和资源文件。你随便把一个.docx后缀改成.zip解压就能看到word/document.xml、word/media/这些目录。这个认知非常关键因为它决定了我们后面所有的解析思路——解析 docx本质上是在解析一套约定好的 XML 结构外加从压缩包里提取媒体资源。Apache POI 就是干这件事的成熟工具。它提供了XWPFDocument这个类专门处理 docx能读段落、读表格、读图片、读页眉页脚。但问题在于POI 的 API 设计偏向“底层暴露”很多细节需要你自己处理图片不是直接给你一个文件而是给你一个关系 ID你得自己去package里找对应的数据文字也不是简单一个getText()就完事段落、表格、文本框里的文字分散在不同对象里漏掉一个就丢内容。这篇内容适合谁看如果你正在用 SpringBoot 做文档管理系统、合同解析、简历筛选、内容审核这类功能需要从 Word 里稳定地抽取文字和图片那这篇就是写给你的。我会把 POI 解析 docx 的完整链路拆开讲包括依赖怎么选、核心 API 怎么用、图片怎么提取、表格和文本框怎么处理以及我在实际项目里踩过的那些坑。全程基于真实可复现的代码你照着抄就能跑起来。2. 依赖选型与 docx 文件结构的底层认知2.1 POI 依赖到底该引哪几个很多人一上来就poi一把梭结果发现处理 docx 的类根本找不到。Apache POI 是分模块的处理不同格式需要不同的 artifact。对于 docx核心是这几个dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependencypoi-ooxml会自动带上poi和poi-ooxml-lite或者完整的ooxml-schemas。这里有个关键选择poi-ooxml默认依赖的是精简版的 schema 包体积小但如果你要处理一些冷门特性比如某些复杂的图表、SmartArt可能会报ClassNotFoundException或者 schema 相关的错。我一般的做法是如果只是解析文字和图片精简版完全够用如果遇到解析异常再手动引入完整的ooxml-schemas。版本上POI 5.x 相比 4.x 在内存占用和安全性上有明显改进尤其是处理大文档时。但要注意POI 5.x 要求 JDK 8 以上如果你项目还在 JDK 7那只能停在 4.x。另外POI 依赖xmlbeans有时候会和项目里其他 XML 处理库冲突出现NoSuchMethodError这时候用mvn dependency:tree排查一下版本仲裁就行。还有一个坑如果你同时要处理.doc老格式需要额外引poi-scratchpad并且用HWPFDocument而不是XWPFDocument。但老格式解析出来的东西经常乱码图片提取也麻烦我的建议是能要求用户上传 docx 就尽量要求实在不行再考虑转换。2.2 把 docx 当 ZIP 看一切就通了前面说了 docx 是 ZIP 包我们解压一个典型的 docx 会看到这样的结构路径作用[Content_Types].xml声明包内各部分的 MIME 类型word/document.xml正文内容段落、表格、文字都在这里word/media/存放图片等媒体文件如image1.pngword/_rels/document.xml.rels关系文件记录 document.xml 里引用的资源 ID 到实际文件的映射word/header1.xml/footer1.xml页眉页脚内容word/styles.xml样式定义理解这个结构后POI 的很多行为就说得通了。比如你调用paragraph.getRuns()拿到一个 runrun 里可能有一个图片引用这个引用其实是一个rId关系 IDPOI 通过document.getPackage().getPart()之类的方法结合.rels文件才能找到word/media/下真正的图片数据。所以图片提取的核心就是拿到 rId去关系表里查目标路径再从包中读出字节流。这个认知还能帮你排查问题。比如图片提取出来是空的你可以直接把 docx 解压看看word/media/里到底有没有文件document.xml.rels里有没有对应的映射。如果解压后 media 目录是空的那说明文档本身就没嵌入图片可能是链接图片POI 自然也拿不到。3. 文字内容提取段落、表格、文本框一个都不能漏3.1 最基础的段落文字读取先看最简单的场景读正文段落。XWPFDocument的getParagraphs()返回所有顶层段落每个XWPFParagraph又包含若干XWPFRun。文字实际存在于 run 里。try (InputStream is new FileInputStream(file); XWPFDocument doc new XWPFDocument(is)) { StringBuilder sb new StringBuilder(); for (XWPFParagraph paragraph : doc.getParagraphs()) { String text paragraph.getText(); if (text ! null !text.trim().isEmpty()) { sb.append(text).append(\n); } } System.out.println(sb.toString()); }这里有个细节paragraph.getText()会把该段落所有 run 的文字拼起来但它不会自动加换行也不会处理 run 之间的格式差异。如果你需要保留加粗、斜体这些信息就得遍历 run用run.isBold()、run.getFontSize()等去判断。另外getText()对空段落返回空字符串但有些文档里用空段落做间距这些你按需过滤就行。我踩过的一个坑是有些文档的文字在 run 里被拆得很碎。比如“SpringBoot”这个词可能被拆成“Spring”和“Boot”两个 run中间还夹着格式标记。如果你按 run 逐个处理很容易把词切断。所以做全文检索时直接用paragraph.getText()拼段落是最稳的不要自己按 run 拼。3.2 表格里的文字怎么完整拿出来getParagraphs()只返回正文顶层段落表格里的文字它是不管的。这是新手最容易丢内容的地方。表格要用getTables()for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { // 单元格里可能有多个段落 for (XWPFParagraph p : cell.getParagraphs()) { sb.append(p.getText()).append( ); } sb.append(|); // 用竖线分隔单元格 } sb.append(\n); } }注意单元格里是getParagraphs()不是直接getText()。一个单元格可能包含多个段落甚至嵌套表格。嵌套表格需要递归处理cell.getTables()能拿到单元格内的子表格。我一般会写一个递归方法把段落和表格统一处理避免漏掉深层内容。还有一个隐蔽的点表格的遍历顺序和文档视觉顺序可能不一致。POI 的getTables()返回的是文档流里的表格但如果你文档里表格和段落是交错的单独遍历getParagraphs()再遍历getTables()会丢失它们的相对位置。要保留顺序得用doc.getBodyElements()它返回一个IBodyElement列表按文档顺序排列你可以判断每个元素是XWPFParagraph还是XWPFTable依次处理。这个 API 在做“按原文顺序输出”时非常有用。3.3 文本框和页眉页脚最容易被遗忘的角落文本框Text Box在 docx 里是放在 drawing 里的POI 的getParagraphs()默认拿不到。很多合同、海报类文档喜欢把关键信息放文本框里如果你只读段落就会漏掉。处理文本框需要遍历 run 里的CTR对象判断是否有CTTextbox然后从里面取段落。代码相对繁琐for (XWPFParagraph paragraph : doc.getParagraphs()) { for (XWPFRun run : paragraph.getRuns()) { for (CTR ctr : run.getCTR().getRList()) { // 处理 drawing 里的 textbox // 具体通过 ctr.getDrawingList() 等逐层深入 } } }说实话文本框的解析用 POI 原生 API 写起来很痛苦因为要操作底层 XMLBeans。我的经验是如果业务对文本框内容有强需求可以考虑用docx4j作为补充它对文本框的支持更友好。但如果只是偶尔处理用 POI 硬啃也能出来只是代码丑一点。页眉页脚同理doc.getHeaderList()和doc.getFooterList()能拿到但要注意一个文档可能有多种页眉首页不同、奇偶页不同需要遍历所有 header/footer 的段落和表格。我一般会把页眉页脚的文字单独存一个字段不和正文混在一起因为它们的语义不同。4. 图片提取从 rId 到字节流的完整链路4.1 图片在 docx 里到底是怎么存的再强调一遍图片不是以二进制形式直接嵌在document.xml里的而是存在word/media/目录下document.xml里只放一个引用。这个引用通过关系 IDrId关联到document.xml.relsrels 文件里写着rId对应media/image1.png。所以提取图片分三步找到 run 里的图片引用拿到 rId通过 rId 找到图片数据。POI 里图片引用藏在 run 的getEmbeddedPictures()里。每个XWPFPicture有一个getPictureData()返回XWPFPictureData这个对象直接提供了getData()字节数组和getPictureType()类型。所以其实 POI 已经帮你把 rId 到数据的链路封装好了你不需要手动解析 rels。for (XWPFParagraph paragraph : doc.getParagraphs()) { for (XWPFRun run : paragraph.getRuns()) { for (XWPFPicture picture : run.getEmbeddedPictures()) { XWPFPictureData data picture.getPictureData(); byte[] bytes data.getData(); String ext data.suggestFileExtension(); // png, jpeg 等 // 保存 bytes 到文件或上传对象存储 } } }这段代码看起来简单但有几个坑必须说。4.2 图片提取的三个实战坑第一个坑图片可能不在段落里而在表格单元格或页眉里。上面的代码只遍历了doc.getParagraphs()如果图片在表格单元格里就漏了。正确做法是把遍历逻辑抽成一个方法对段落、表格单元格、页眉页脚都调用一遍。我一般写一个extractImagesFromParagraphs(ListXWPFParagraph)然后各处复用。第二个坑同一张图片可能被引用多次。比如文档里同一个 logo 出现三次word/media/里其实只有一份image1.png但三个 run 都引用它。如果你按 run 提取会得到三份相同的字节。做去重的话可以用XWPFPictureData的getChecksum()或者直接对字节做 MD5相同就跳过。这个在批量处理时能省不少存储。第三个坑图片格式的扩展名不一定准。suggestFileExtension()返回的是根据内容推断的扩展名但有些文档里图片实际是 EMF、WMF 这种矢量格式POI 可能返回emf你保存成.emf后前端不一定能显示。我的做法是保存时用getPictureType()拿到 POI 的枚举值再映射到常见扩展名同时记录原始 MIME 类型方便后续转换。另外如果图片是“链接式”插入的不是嵌入getEmbeddedPictures()是拿不到的因为数据根本不在文档包里。这种情况只能拿到链接地址需要额外处理。判断方法就是解压 docx 看 media 目录是否为空。4.3 大文档图片提取的内存控制如果文档很大图片很多一次性把所有字节读进内存再处理容易 OOM。我的建议是边遍历边处理拿到一个XWPFPictureData就立刻写盘或上传不要攒在 List 里。如果确实需要先收集元信息再统一处理那也只存 rId 和类型字节流用的时候再读。POI 本身在解析大文档时也吃内存XWPFDocument会把整个文档模型加载到内存。对于几十兆的文档可以调大 JVM 堆或者考虑用 POI 的 event modelXSSF 那套是给 Excel 的Word 没有对应的流式 API所以 Word 解析基本只能全量加载。实际项目里我会限制上传文档大小比如 20MB 以内超过就提示用户拆分。5. 把解析逻辑封装成 SpringBoot 可复用的服务5.1 设计一个解析结果对象在 SpringBoot 里我们不会把解析代码散落在 Controller 里而是封装成一个 Service。首先定义一个结果对象把文字和图片分开存public class DocxParseResult { private String fullText; // 全文文字 private ListImageInfo images; // 图片列表 private MapString, String metadata; // 可选页眉页脚等 public static class ImageInfo { private String rId; private String extension; private byte[] data; private String checksum; // getter/setter 省略 } }fullText里我会按文档顺序拼接段落和表格文字表格用|分隔单元格用换行分隔行。这样前端展示或全文检索都方便。图片单独存因为字节流不适合塞进大字符串。5.2 Service 层的完整实现思路Service 的核心方法接收MultipartFile返回DocxParseResult。流程是打开流、创建XWPFDocument、遍历 body elements 提取文字、遍历所有位置提取图片、关闭资源。用 try-with-resources 确保流关闭。Service public class DocxParseService { public DocxParseResult parse(MultipartFile file) throws IOException { DocxParseResult result new DocxParseResult(); StringBuilder textBuilder new StringBuilder(); ListDocxParseResult.ImageInfo images new ArrayList(); SetString seenChecksums new HashSet(); try (InputStream is file.getInputStream(); XWPFDocument doc new XWPFDocument(is)) { // 按顺序处理正文元素 for (IBodyElement element : doc.getBodyElements()) { if (element instanceof XWPFParagraph) { appendParagraphText((XWPFParagraph) element, textBuilder); extractImagesFromParagraph((XWPFParagraph) element, images, seenChecksums); } else if (element instanceof XWPFTable) { appendTableText((XWPFTable) element, textBuilder); extractImagesFromTable((XWPFTable) element, images, seenChecksums); } } // 处理页眉页脚 for (XWPFHeader header : doc.getHeaderList()) { for (XWPFParagraph p : header.getParagraphs()) { appendParagraphText(p, textBuilder); extractImagesFromParagraph(p, images, seenChecksums); } } // footer 同理 } result.setFullText(textBuilder.toString()); result.setImages(images); return result; } }这里用getBodyElements()而不是分别getParagraphs()和getTables()就是为了保留文档顺序。appendParagraphText里我会判断段落是否为空空段落跳过避免全文里出现大量空行。5.3 图片去重与存储策略去重用 checksum。XWPFPictureData没有直接的 checksum 方法我用DigestUtils.md5Hex(data.getData())算 MD5。如果 checksum 已存在就跳过不重复添加。这样同一张图在文档里出现多次最终只存一份。存储上实际项目里我不会把字节流放在返回结果里传给前端而是解析时直接上传到对象存储如 MinIO、OSS返回 URL 列表。这样 Service 的职责更清晰解析 上传。如果只是本地测试写到一个临时目录也行。关键是不要让大字节流在内存里长时间驻留上传完就释放引用。还有一个细节图片的命名。用UUID . extension避免重名同时把原始 rId 和 checksum 存到数据库方便追溯。如果业务需要按文档内顺序展示图片那在提取时记录一个序号上传后按序号排序。6. 那些让我加班到深夜的异常与排查过程6.1OfficeXmlFileException文件根本不是 docx这个异常信息是“The supplied data appears to be in the OLE2 Format”意思是 POI 认为你给的是老式.doc二进制格式但你用的是XWPFDocument。原因通常是用户把.doc文件改了后缀成.docx上传或者文件本身损坏。排查方法很简单读文件头几个字节docx 是PKZIP 标志doc 是D0 CF 11 E0。如果文件头是后者就得用HWPFDocument处理或者提示用户另存为 docx。我在 Controller 层加了一个校验读取文件前 4 个字节不是PK开头就直接返回友好错误避免异常堆栈暴露给前端。这个校验成本极低但能挡掉大部分脏数据。6.2 图片提取为空解压文档一看便知有次测试反馈说某文档图片提不出来我第一反应是代码问题排查半天没结果。后来把 docx 解压发现word/media/目录是空的图片全是链接式的。这种文档在 Word 里显示正常但数据不在包里。解决办法只能是拿到链接地址后单独下载或者要求用户重新插入为嵌入图片。所以遇到图片为空先解压看 media 目录这一步能省你半小时。6.3 中文乱码编码问题的几种可能POI 读 docx 一般不会乱码因为 XML 里是 UTF-8。但如果文档里嵌入了老式字体或者特殊符号可能出现方块。还有一种情况是你把提取的文字写到文件时用了平台默认编码比如 Windows 的 GBK而内容里有生僻字。我的做法是所有输出统一用 UTF-8写文件时显式指定StandardCharsets.UTF_8。另外paragraph.getText()返回的字符串本身是没问题的乱码往往出在后续的 IO 环节。6.4 内存溢出大文档的应对前面提过XWPFDocument全量加载。我遇到过一个 50MB 的文档里面几百张高清图解析时直接 OOM。后来做了两件事一是限制上传大小到 20MB二是解析时用-Xmx调大堆并且解析完立刻doc.close()释放。如果业务必须处理超大文档可以考虑拆分或者用异步任务 独立 JVM 进程处理避免拖垮主服务。还有一个隐蔽的内存问题XWPFPictureData.getData()每次调用都会返回字节数组的副本吗实际上 POI 内部有缓存但如果你反复调用可能产生多份。我的做法是拿到一次就存下来不要重复调。7. 几个让解析更稳的工程化习惯7.1 解析前先做文件类型和大小校验不要信任前端传来的文件。在 Service 入口做三件事检查文件扩展名是否为.docx检查文件头是否为PK检查文件大小是否超限。这三步能挡掉 90% 的异常。校验失败返回明确的错误码而不是抛异常。7.2 把解析做成异步任务文档解析是 CPU 和 IO 密集型操作同步接口容易超时。我的做法是上传后立即返回一个任务 ID后台用线程池异步解析前端轮询进度。解析结果存数据库或缓存图片上传对象存储。这样用户体验好服务也稳定。线程池大小根据文档平均大小调整一般 4 到 8 个线程够用。7.3 记录解析日志方便回溯每次解析记录文件名、大小、解析耗时、提取文字长度、图片数量、是否成功、失败原因。这些日志在排查问题时非常有用。比如某类文档总是图片为 0你一看日志就能发现是链接式图片的问题。日志里不要打全文内容避免敏感信息泄露打长度和摘要即可。7.4 单元测试要覆盖边界情况我写测试时会准备几类文档纯文字、纯图片、文字加表格、带页眉页脚、带文本框、空文档、损坏文档。每类都跑一遍断言文字长度和图片数量。这样重构时心里有底。测试资源放在src/test/resources下用ClassPathResource加载。8. 关于 docx 解析这件事我最后想说的POI 解析 docx 不是什么高深技术但它的坑都藏在细节里表格里的文字、文本框里的内容、图片的 rId 链路、页眉页脚的遍历、大文档的内存。这些细节文档里不会重点讲只有真正做过一遍、被测试提过 bug、被线上报警叫醒过才会刻在脑子里。我的建议是第一次实现时不要追求大而全先把正文文字和嵌入图片跑通再逐步加表格、页眉、文本框。每加一个特性就用对应的测试文档验证。遇到解析异常先解压 docx 看结构大部分问题都能定位。代码里做好资源关闭和异常兜底别让一个坏文档拖垮整个服务。如果你正在做类似的功能希望这篇内容能帮你少走点弯路。解析出来的文字和图片只是第一步后面怎么做检索、怎么做结构化、怎么和业务结合那又是另一个话题了。先把数据稳稳当当地拿出来后面的路才好走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑