资讯详情

Apache POI 解析 docx:文字与图片提取实战

📅 2026/10/22 10:29:29 | 华诺云谱 👁 阅读
Apache POI 解析 docx:文字与图片提取实战
1. 为什么选择用 Apache POI 处理 docx 文档做过企业级文档处理的人多半都遇到过这种需求用户上传一份 Word 文档后台需要把里面的文字提取出来做全文检索同时把插图单独抽出来存到对象存储或者做 OCR 识别。这类需求在合同归档、简历解析、试卷录入、报告生成等场景里反复出现几乎成了 Java 后端工程师绕不开的一道坎。我最早接触这个需求是在一个内容管理系统的项目里当时产品经理给的需求很朴素——“把用户上传的 Word 里的文字和图片都拿出来”。听起来简单真动手才发现坑不少docx 不是纯文本它本质是一个 zip 压缩包里面塞满了 XML 和二进制资源图片不是以文件形式存在而是以关系relationship的方式被引用文字也不全在段落里表格、页眉、页脚、文本框、批注里都可能有内容。如果只是简单读一下XWPFDocument的 paragraphs大概率会漏掉一大半信息。Apache POI 是目前 Java 生态里处理 Office 文档最成熟的开源库其中XWPF模块专门负责 docx也就是 OOXML 格式。它把 docx 内部的 XML 结构映射成了 Java 对象让我们可以用面向对象的方式去遍历段落、表格、图片。相比自己解压 zip 再解析 XMLPOI 帮我们处理了命名空间、关系映射、编码这些琐碎问题省下来的时间足够喝好几杯咖啡。这篇文章适合谁看如果你正在做文档解析、内容抽取、附件处理相关的功能或者单纯想搞清楚 docx 内部到底长什么样那接下来的内容应该能帮你少走一些弯路。我会从整体设计思路讲起把图片和文字两条线的处理逻辑拆开揉碎再给出一套可以直接抄作业的代码最后把我在实际项目里踩过的坑整理成排查表。全程不玩虚的都是能落地的干货。2. 整体设计思路与方案选型2.1 docx 的内部结构决定了处理方式要理解 POI 的 API 设计得先知道 docx 到底是个什么东西。把一个.docx文件的后缀改成.zip再解压你会看到这样的目录结构word/ document.xml # 正文内容 styles.xml # 样式定义 media/ # 图片等媒体文件 image1.png image2.jpeg _rels/ document.xml.rels # 关系映射表 header1.xml # 页眉 footer1.xml # 页脚 [Content_Types].xml正文里的文字和图片都写在document.xml里但图片本身并不在这个 XML 中XML 里只放了一个引用 ID真正的二进制数据躺在word/media/目录下。这个引用关系记录在_rels/document.xml.rels里。POI 做的事情就是把这套关系在内存里重建出来让我们通过XWPFParagraph、XWPFRun、XWPFPictureData这些对象去访问。理解这一点非常关键因为它直接决定了我们的遍历策略文字要顺着段落和 run 走图片要顺着 run 里的嵌入对象走两者其实是同一条遍历路径上的不同分支。2.2 为什么不用其他方案市面上处理 docx 的方案不止 POI 一家我简单对比一下常见的几种说明为什么最后还是选了 POI。方案优点缺点适用场景Apache POI功能全、社区活跃、支持读写API 略繁琐、大文件吃内存企业级文档处理首选docx4j基于 JAXB对 OOXML 支持细学习曲线陡、依赖较重需要精细操作 XML 时纯解压 XML 解析无依赖、可控性强要自己处理关系映射、易漏内容只提取纯文本的轻量场景第三方云服务 API接入快、识别准有成本、数据出境风险对准确率要求极高的场景对于绝大多数后端项目来说POI 是性价比最高的选择。它的XWPFDocument已经把文档模型抽象得比较完整图片、表格、页眉页脚都有对应的 API而且 Maven 依赖干净不牵扯额外的商业授权。docx4j 虽然在某些细节上更强但引入的依赖链比较长对于只是“提取文字和图片”这种需求来说有点杀鸡用牛刀。2.3 核心处理流程设计整个处理流程我把它拆成四步逻辑上是一条直线加载文档用XWPFDocument包装输入流POI 会自动解析 zip 和 XML。遍历正文按顺序处理段落paragraph和表格table段落里再细分到 run。分流处理遇到文字就追加到缓冲区遇到图片就抽取二进制数据。资源释放关闭文档和流避免文件句柄泄漏。这里有个设计上的取舍需要说明图片的提取有两种思路。一种是在遍历 run 的时候顺手把图片抠出来另一种是遍历结束后统一从document.getAllPictures()拿。我倾向于前者因为这样能保留图片在文档中的位置信息——比如它出现在第几段、前后是什么文字。对于需要做图文对应比如试卷题目和配图的场景位置信息比图片本身还重要。如果只是单纯要图片文件那用getAllPictures()更省事。3. 核心细节解析与实操要点3.1 文字提取段落、Run 与表格的层级关系POI 里文字的组织结构是三层XWPFDocument→XWPFParagraph→XWPFRun。一个段落是一段逻辑文字一个 run 是段落里格式相同的一小段。为什么要有 run 这一层因为 Word 允许同一段落里不同文字有不同的字体、颜色、加粗这些格式变化就切分出了多个 run。提取文字时最直接的做法是遍历段落对每个段落调用getText()。但这里有个坑getText()只返回段落里所有 run 的纯文本拼接会丢掉换行和制表符的语义。更稳妥的方式是遍历 run对每个 run 调text()然后根据 run 的类型决定是否补换行。for (XWPFParagraph paragraph : document.getParagraphs()) { StringBuilder line new StringBuilder(); for (XWPFRun run : paragraph.getRuns()) { String text run.text(); if (text ! null) { line.append(text); } // 处理换行符 if (run.getCTR().isSetBrArray()) { line.append(\n); } } // 段落结束补换行 result.append(line).append(\n); }表格是另一个容易漏的地方。document.getParagraphs()只返回正文段落表格里的文字不在其中。表格要通过document.getTables()单独遍历每个表格由若干行XWPFTableRow组成每行有若干单元格XWPFTableCell单元格里又嵌套着段落。所以完整的文字提取需要递归处理正文段落 表格单元格段落 页眉页脚段落。提示页眉页脚的内容在document.getHeaderList()和document.getFooterList()里如果业务需要提取完整文字这两个列表不能漏。3.2 图片提取从 Run 到二进制数据图片的处理比文字绕一些。在 POI 里图片是通过XWPFRun.getEmbeddedPictures()拿到的返回的是XWPFPicture列表。每个XWPFPicture里有一个XWPFPictureData真正的二进制数据就在这里面。for (XWPFRun run : paragraph.getRuns()) { ListXWPFPicture pictures run.getEmbeddedPictures(); for (XWPFPicture picture : pictures) { XWPFPictureData data picture.getPictureData(); byte[] bytes data.getData(); String ext data.suggestFileExtension(); String fileName data.getFileName(); // 保存 bytes 到文件或对象存储 } }这里有几个细节值得展开说。第一getEmbeddedPictures()只返回嵌入型图片也就是那种像文字一样嵌在行内的图片。如果图片是浮动型比如设置了“四周型环绕”它可能不在 run 里而是挂在段落或文档的绘图对象上。这种情况需要用document.getAllPictures()兜底虽然会丢失位置信息但至少不会漏图。第二suggestFileExtension()返回的是不带点的扩展名比如png、jpeg用它来拼文件名比较靠谱。getFileName()返回的是原始文件名但有时候会是空字符串不能完全依赖。第三图片数据是byte[]如果文档很大、图片很多全部读进内存会有 OOM 风险。生产环境里建议边读边写或者设置一个大小阈值超过阈值的图片走流式处理。3.3 处理顺序与位置信息的保留前面提到我倾向于在遍历 run 的时候顺手抽图片目的就是保留位置信息。具体做法是维护一个计数器记录当前处理到第几个段落、第几个 run把图片和它所在的上下文一起存下来。int paragraphIndex 0; for (XWPFParagraph paragraph : document.getParagraphs()) { String paragraphText paragraph.getText(); int runIndex 0; for (XWPFRun run : paragraph.getRuns()) { for (XWPFPicture picture : run.getEmbeddedPictures()) { // 记录paragraphIndex, runIndex, paragraphText // 这样后续可以知道图片出现在哪段文字附近 } runIndex; } paragraphIndex; }这个位置信息在什么场景下有用举个例子做试卷解析时一道题的题干是文字配图是图片如果能把它们按顺序对应起来就能还原出完整的题目结构。再比如做合同解析某个条款旁边的印章图片位置信息能帮我们判断它盖在哪一条上。注意段落索引和 run 索引都是基于 POI 的遍历顺序和 Word 里肉眼看到的顺序基本一致但如果文档里有复杂的文本框或浮动对象顺序可能会有偏差。对顺序要求极高的场景建议结合 XML 里的实际位置做二次校验。4. 完整实操过程与代码实现4.1 环境准备与依赖引入先上 Maven 依赖。POI 的版本建议用 5.x4.x 虽然也能用但 5.x 对 OOXML 的支持更完善而且修复了不少内存相关的 bug。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependencypoi-ooxml会自动带上poi核心包和poi-ooxml-liteschema 定义。如果你用的是 Gradle对应写法是implementation org.apache.poi:poi-ooxml:5.2.3。版本号不用死记去 Maven Central 搜一下最新的稳定版即可但要注意 5.x 和 4.x 的 API 有少量不兼容升级时留意编译报错。4.2 文字与图片提取的完整实现下面这份代码是我在实际项目里打磨过的版本把正文段落、表格、页眉页脚都覆盖到了图片也带了位置信息。你可以直接拿去改改用。import org.apache.poi.xwpf.usermodel.*; import java.io.*; import java.util.*; public class DocxExtractor { public static class ExtractResult { public StringBuilder text new StringBuilder(); public ListImageInfo images new ArrayList(); } public static class ImageInfo { public byte[] data; public String extension; public int paragraphIndex; public String contextText; } public static ExtractResult extract(InputStream inputStream) throws IOException { ExtractResult result new ExtractResult(); try (XWPFDocument document new XWPFDocument(inputStream)) { int[] paraIndex {0}; // 1. 正文段落 for (XWPFParagraph paragraph : document.getParagraphs()) { processParagraph(paragraph, result, paraIndex[0]); paraIndex[0]; } // 2. 表格 for (XWPFTable table : document.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { processParagraph(paragraph, result, paraIndex[0]); paraIndex[0]; } } } } // 3. 页眉页脚 for (XWPFHeader header : document.getHeaderList()) { for (XWPFParagraph paragraph : header.getParagraphs()) { processParagraph(paragraph, result, paraIndex[0]); paraIndex[0]; } } for (XWPFFooter footer : document.getFooterList()) { for (XWPFParagraph paragraph : footer.getParagraphs()) { processParagraph(paragraph, result, paraIndex[0]); paraIndex[0]; } } } return result; } private static void processParagraph(XWPFParagraph paragraph, ExtractResult result, int paragraphIndex) { StringBuilder line new StringBuilder(); for (XWPFRun run : paragraph.getRuns()) { String text run.text(); if (text ! null !text.isEmpty()) { line.append(text); } // 处理换行 if (run.getCTR().isSetBrArray()) { line.append(\n); } // 处理图片 for (XWPFPicture picture : run.getEmbeddedPictures()) { XWPFPictureData data picture.getPictureData(); ImageInfo info new ImageInfo(); info.data data.getData(); info.extension data.suggestFileExtension(); info.paragraphIndex paragraphIndex; info.contextText paragraph.getText(); result.images.add(info); } } if (line.length() 0) { result.text.append(line).append(\n); } } }这段代码有几个设计点值得说明。ExtractResult把文字和图片打包在一起返回调用方一次就能拿到全部结果。ImageInfo里带了paragraphIndex和contextText方便后续做图文关联。processParagraph被抽成独立方法正文、表格、页眉页脚三处复用避免代码重复。4.3 图片落盘与命名策略拿到byte[]之后通常要存到文件系统或对象存储。命名策略我建议用“时间戳 随机串 扩展名”的组合避免不同文档里的同名图片互相覆盖。public static String saveImage(ImageInfo info, String outputDir) throws IOException { String fileName System.currentTimeMillis() _ UUID.randomUUID().toString().substring(0, 8) . info.extension; File target new File(outputDir, fileName); try (FileOutputStream fos new FileOutputStream(target)) { fos.write(info.data); } return target.getAbsolutePath(); }如果图片要上传到对象存储把FileOutputStream换成对应的 SDK 输出流即可逻辑是一样的。这里要注意info.extension可能是空字符串稳妥起见加个判断为空时默认用png。4.4 大文档的内存控制POI 默认会把整个文档加载到内存里一个几十兆的 docx 可能吃掉几百兆堆内存。如果业务里经常处理大文档有两个方向可以优化。一是用XWPFDocument的流式读取模式POI 提供了XWPFDocument(InputStream)之外的一些底层 API但说实话 docx 的流式处理支持不如 xlsx 成熟收益有限。二是控制图片的处理方式不要一次性把所有图片的byte[]都攒在内存里而是边遍历边写盘ExtractResult里只存图片的元信息路径、位置不存二进制数据。// 边遍历边写盘的思路 for (XWPFPicture picture : run.getEmbeddedPictures()) { XWPFPictureData data picture.getPictureData(); String path saveImage(data.getData(), data.suggestFileExtension(), outputDir); ImageInfo info new ImageInfo(); info.path path; // 只存路径不存 byte[] info.paragraphIndex paragraphIndex; result.images.add(info); }这个改动看起来小但对内存的影响很大。我实测过一个 50MB、内含 200 多张图的文档全量攒内存的方式峰值堆占用接近 800MB改成边读边写之后降到了 200MB 以内。5. 常见问题与排查技巧实录5.1 图片提取不到或提取不全这是反馈最多的问题。按经验原因通常有这几类现象可能原因排查方法解决方式完全拿不到图片图片是浮动型不在 run 里用document.getAllPictures()验证改用全量图片接口兜底部分图片丢失图片在页眉页脚或表格里检查是否遍历了 header/footer/table补全遍历范围图片数据为空图片是链接型非嵌入检查getPictureData()是否为 null链接型图片需单独处理扩展名不对suggestFileExtension()返回空打印实际返回值根据二进制头判断真实格式链接型图片是个特殊情况它的二进制数据不在文档里而是指向一个外部文件或 URL。这种图片 POI 拿不到数据只能拿到链接地址。如果业务需要得根据链接去下载但要注意外链可能失效或存在安全风险。5.2 中文乱码与特殊字符POI 读取 docx 时中文一般不会乱码因为 OOXML 内部用 UTF-8 编码。但如果文档是从老版本.doc转换过来的或者用了特殊的符号字体可能会出现方框或问号。这种情况通常是字体缺失导致的不是编码问题。提取文字时拿到的是 Unicode 码点显示不出来是渲染层的事存储和检索不受影响。真正需要注意的是特殊字符的转义。比如文档里有、、这些字符如果提取后要拼成 XML 或 HTML必须做转义否则会破坏结构。我一般会在提取完成后统一做一次清洗public static String escapeXml(String text) { return text.replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(\, quot;); }5.3 文件句柄泄漏与流关闭XWPFDocument实现了Closeable用完必须关闭否则底层的 zip 文件句柄不会释放。在 Windows 上这会表现为文件被占用、无法删除在 Linux 上则是句柄数慢慢涨上去最终触发Too many open files。我习惯用 try-with-resources 包住XWPFDocument这样即使中途抛异常也能保证关闭。注意XWPFDocument关闭时不会自动关闭传入的InputStream如果这个流是你自己开的也要一并关掉。try (InputStream is new FileInputStream(file); XWPFDocument document new XWPFDocument(is)) { // 处理逻辑 }5.4 版本兼容性踩坑记录POI 4.x 升 5.x 的时候我遇到过两个编译错误。一个是XWPFPictureData.getData()的返回类型没变但getPictureData()在某些子类里的签名有调整另一个是CTR相关的底层 API 有重命名。解决办法就是对着官方迁移文档逐个改量不大但要有心理准备。还有一个坑是poi-ooxml和poi-ooxml-schemas的版本必须一致如果项目里其他依赖间接引入了不同版本的 schemas 包会出现NoSuchMethodError。用mvn dependency:tree查一下把冲突的版本排除掉就好。提示如果项目里同时有读写 xlsx 的需求poi-ooxml一个依赖就够了不用额外引poi-ooxml-schemas5.x 已经把常用 schema 打包进去了。5.5 性能优化的一点经验处理大量文档时单篇的耗时主要花在 XML 解析上。我做过一个粗略的测试一篇 20 页、含 30 张图的文档完整提取文字和图片大约需要 300 到 500 毫秒。如果 QPS 要求高可以考虑这几个方向把文档处理放到独立的线程池里避免阻塞主请求对同一文档的重复解析做缓存图片落盘用异步 IO。另外document.getParagraphs()返回的是一个新构建的 List如果文档段落特别多上万段这个构建过程本身也有开销。极端情况下可以直接操作底层的CTBody但那样代码可读性会差很多除非真的遇到性能瓶颈否则不建议。6. 几个容易被忽略的边界场景6.1 文本框与艺术字Word 里的文本框text box内容不在getParagraphs()里而是藏在 run 的绘图对象中。POI 对文本框的支持比较有限需要通过底层的CTDrawing去挖。如果业务文档里大量使用文本框排版提取文字时会漏内容。我的建议是先用document.getAllPictures()和段落遍历覆盖大部分场景文本框作为已知限制记录在文档里真有需求再针对性处理。6.2 批注与修订批注comment和修订revision的内容也不在正文段落里。批注在document.getComments()里修订则需要检查 run 的getCTR().getRPr()相关属性。这两个场景在合同审核类业务里比较常见如果需求涉及记得单独处理。6.3 嵌入的 OLE 对象有些文档里嵌入了 Excel 表格或 PDF 作为 OLE 对象这些对象的数据在word/embeddings/目录下。POI 可以通过document.getAllEmbeddedParts()拿到但解析它们需要对应的库比如嵌入的 xlsx 要用XSSFWorkbook再解析一遍。这个场景相对小众遇到了再查文档即可。7. 我在实际项目中的几点体会这套方案我在两个项目里用过一个是内容管理系统的附件解析一个是试卷录入工具的题干提取。前者对图片位置要求不高用getAllPictures()就够了后者需要把图片和题目对应起来才用上了带位置信息的遍历方式。所以方案没有绝对的好坏关键看业务对位置信息的需求程度。如果让我给刚接触这块的人一句建议那就是先把 docx 解压看一眼内部结构再动手写代码。很多人上来就查 API结果被 run、paragraph、picture 这几个概念绕晕。花十分钟看看document.xml和_rels目录POI 的 API 设计一下子就通了。最后分享一个小技巧调试阶段可以把提取结果直接打印成 JSON文字和图片元信息都带上这样一眼就能看出哪里漏了、哪里顺序不对。等逻辑稳定了再去掉调试输出。这个习惯帮我省下了大量对着日志猜问题的时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑