上周刚处理完一个让我印象挺深的需求:业务方要求在 Spring Boot 系统里导出一份带产品实拍图的 Word 报价单,图片还得按规格插到表格里,不能偏,不能变形。折腾下来发现,这个需求的难点并不在“导出 Word”,而在“图片怎么进 Word、怎么控制位置和尺寸、怎么保证文件不损坏”。这次我把整套思路和踩过的坑整理出来,做这个功能或者遇到“Spring Boot导出带图片的Word”这类需求的朋友可以少走不少弯路。
先说清楚它能解决什么问题:如果你的项目里需要把数据库里的记录、远程图片地址、本地文件等动态数据,导成一个带图片的 .docx 文档(比如报价单、体检报告、工单凭证、商品详情页快照),这篇文章可以直接给你一套能落地的方案。
适合谁来参考:已经会 Spring Boot 基本开发、想在导出功能上做图片扩展的 Java 工程师;或者被 POI 的图片 API 折磨过、想看完整示例的初学者。全文不涉及花哨的架构,核心就是 Apache POI 的 XWPF 操作,外加一些模板设计的思路。
1. 整体设计与思路拆解
1.1 生成带图片 Word 的 N 种技术路线
Spring Boot 里导出 Word,主流方案有三条路,每条我都实际用过,先说结论再讲细节。
第一条是Apache POI 纯编码生成。用XWPFDocument从零创建段落、表格、Run,一步步把内容和图片写进去。优点是完全可控,想插哪里插哪里;缺点是代码量巨大,Word 排版稍微复杂一点,写出来的调整逻辑能让人崩溃,尤其是表格列宽、图片锚点这些细节,调起来特别费劲。
第二条是模板占位符 + POI 替换。先在 Word 里做好一个带样式、带表格的模板文件,把需要动态替换的位置用${title}、${content}这样的纯文本占位符标出来。代码只需要负责打开模板、遍历段落和表格、把占位符替换成真实内容。图片则通过定位到目标 Run,再调用XWPFRun.addPicture()插入。这条路线兼顾了灵活性和开发效率,是我最终选用的方案。
第三条是XML 模板 + Freemarker 渲染。docx 本质上是一堆 XML 文件的压缩包,word/document.xml 里存放正文内容,所以可以用 Freemarker 直接渲染 XML 模板。这种方式对文本和简单图片的插入非常高效,但图片需要手动处理成 base64 并嵌入 XML,公文那种复杂结构的文档很难维护。
三者的对比,我用一个表格总结下:
| 方案 | 开发效率 | 排版灵活度 | 图片处理 | 维护成本 |
|---|---|---|---|---|
| POI 纯编码 | 低,代码量大 | 高 | 直接 API,比较简单 | 中高,排版调整要改代码 |
| 模板占位符 + POI | 高,模板负责排版 | 高,样式在 Word 里调 | 定位 Run 插入,推荐 | 低,模板可单独维护 |
| XML + Freemarker | 中,需要懂 XML 结构 | 中,复杂排版容易乱 | 需转 base64 嵌入,较麻烦 | 中,XML 结构隐性风险多 |
为什么不用纯编码?因为带图片的 Word 有个天然痛点:图片不是普通文本,它需要锚定在某个位置,并且尺寸受文档页面和表格单元格约束。如果纯编码,你得手动计算每个图片的坐标、宽高、段落属性,工作量直接翻好几倍。模板方案把 90% 的排版工作交给了 Word 本身,代码只需关心“在哪个位置插什么图”。
1.2 选型背后的关键考量
我选择“模板占位符 + POI”还有一个重要原因:它能保持原生的 Word 样式。业务方给的报价单模板是设计过的,页眉页脚、字体颜色、表格边框都是精心调的。用纯编码方式做等价还原,几乎不可能;用 XML 模板方式接页眉页脚会比较痛苦。而模板占位符 + POI 是在原文件基础上做替换,不会破坏 Word 原有的样式。
另外一个容易被忽略的点是图片存储场景。实际项目中,图片可能来自三个地方:本地上传的文件、数据库里存的 base64 字符串、远程 URL。好的方案必须同时兼容这三种来源。POI 的addPicture()接收的是InputStream,所以无论图片最初是什么形式,只要最终能转成InputStream就能统一插入。我会在后面的实操部分详细展示如何封装这个转换逻辑。
提示:模板文件建议用 Word 2016+ 另存为 .docx 格式,不要用老旧的 .doc。POI 的 XWPF 只支持 docx,如果你上传一个 .doc,它解析会直接报错或者拿到空内容。
还有一个要提前想清楚的设计:图片占位符不能和普通文本占位符一样处理。文本替换可以直接改 Run 的文本内容,但图片是把一个二进制流插入到 Run 里,本质是run.addPicture()操作。所以模板里需要给图片单独设计一个标志性的占位符,比如${image_product},方便代码识别哪些需要走图片分支。
2. 核心细节解析与实操要点
2.1 图片数据源统一与临时文件处理
这一步是整个功能的“地基”:把你手里的图片数据源(远程 URL、base64、本地文件、二进制 byte[])统一成byte[]。不用InputStream作为接口返回值,是因为addPicture()需要图片的格式类型(PNG、JPEG 等),而byte[]可以通过魔数判断格式,比流更可控。
我封装了一个工具方法,核心逻辑是这样的:
- 远程 URL:用
java.net.URL.openStream()读取,或者用RestTemplate/OkHttp拿 byte[]。这里建议设置连接超时和读取超时,避免某张图片挂了导致整个导出请求卡死。 - base64:
Base64.getDecoder().decode(base64Str),注意前端传来的 base64 可能带data:image/png;base64,前缀,要先按逗号切掉。 - 本地文件:
Files.readAllBytes(Paths.get(path))。 - 二进制:就是
byte[],直接返回。
拿到byte[]以后,用ByteArrayInputStream包一层传给addPicture(),不需要落盘。这也是最容易踩坑的地方:很多人习惯先把图片写到临时目录,再读临时文件插入,逻辑上没问题,但并发高了以后临时文件清理是麻烦事,哪天忘了删就把磁盘塞满了。
2.2 POI 中图片尺寸的换算原理
POI 里addPicture()在设置图片宽高时,用的单位是EMU(English Metric Unit),不是像素,也不是厘米。这个单位体系是 OOXML 文档的标准:
1 英寸 = 914400 EMU,1 像素(96 DPI 下)= 9525 EMU。
但我们在业务里拿到的图片宽高通常是像素。所以代码里需要做换算:
int widthPx = 300; // 数据库或请求传入的图片宽度 int heightPx = 200; double widthEMU = widthPx * 9525; double heightEMU = heightPx * 9525; run.addPicture(inputStream, XWPFDocument.PICTURE_TYPE_JPEG, "image.jpg", widthEMU, heightEMU);如果你不清楚原始图片的实际像素,可以用ImageIO先读一下宽高,再按比例缩放。比如模板中预留的图片展示区域是 400x300 像素,但你从远程 URL 拉回来的图是 800x600,直接插入虽然也能看但不规范,最好等比缩放。
这里有个函数很关键:Units.toEMU(int pixels),它是 POI 自带的方法,源码就是把像素乘以 9525。所以实际写法可以更简洁:
run.addPicture(in, XWPFDocument.PICTURE_TYPE_JPEG, "img.jpg", Units.toEMU(400), Units.toEMU(300));2.3 表格内插入图片的定位技巧
模板占位符的定位有个麻烦:Word 里的占位符文本可能被拆散到多个 Run 里。比如你写的是${image_product},Word 可能把它拆成${i、mage_pro、duct}三个 Run。如果代码简单地按 Run 匹配文本,会匹配不到,或者只匹配到一半。
处理这个问题的思路有两种:
第一种是段落级拼接匹配。把某个段落的所有 Run 文本拼起来,看是否包含目标占位符。如果包含,就清空该段落的所有 Run 文本,在第一个 Run 上插入图片,其他 Run 置为空。这样代码简单,但会破坏原有的 Run 格式(大部分情况下格式是一致的,影响不大)。
第二种是针对表格单元格单独定位。因为XWPFTableCell里也有一套段落和 Run 的结构,需要单独遍历。模板里如果图片在表格内,就按cell.getParagraphs()处理。
我的代码里把这两者统一封装成了一个方法:不管图片在普通段落里还是在表格里,只要传入“占位符文本”,就能找到对应位置并插入图片。判断的方法是:先找出文档里所有涉及图片的正文抽样,如果是表格的 cell 段落,就转换视角再走一次定位逻辑。
注意:模板里不要把图片占位符放在页眉或页脚里。POI 对页眉页脚的支持虽然存在,但定位逻辑和正文不同,处理起来比较麻烦。图片放正文或表格里就够用,页眉页脚保持静态内容即可。
2.4 正确设置 Word 表格单元格宽度
很多人在导出带图 Word 时会发现:表格列宽怎么调都不对。这其实是 POI 的一个老坑:每个 XWPFTableCell 有独立的宽度设置,且单元格宽度由tcW标签控制,而表格的总体布局模式也会影响最终效果。
如果你只是设置了cell.setWidth("3000"),往往不生效。因为我遇到过好几次,导出后打开 Word,表格自动变成“自动调整窗口”模式。正确做法是同时设置表格的布局模式和每个单元格的宽度:
// 设置表格总宽度和布局模式 CTTblWidth tblWidth = table.getCTTbl().getTblPr().addNewTblW(); tblWidth.setType(STTblWidth.DXA); tblWidth.setW(BigInteger.valueOf(9000)); // 单位是 twips,1 厘米约 567 twips // 每个单元格设置宽度 cell.setWidth("3000"); // twips9000 twips约等于 15.87 厘米,这大概是一页 A4 纸正文区域的宽度。如果你在模板里做好了表格列宽,用 POI 往单元格里插图片时,不要把addPicture()的图片宽度设置得超过单元格宽度,否则图片会把单元格撑破,整个表格布局就乱了。图片宽度建议比单元格宽度略小 10-20 像素,留出单元格的 padding。
3. 实操过程与核心环节实现
3.1 工程依赖与目录结构
先看 Maven 依赖,版本是配套好的,不要各用各的,否则容易出现类冲突:
<dependencies> <!-- POI 系列,用于操作 docx --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>5.2.5</version> </dependency> <!-- Spring Boot Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>POI 5.2.x 是个比较稳的版本,兼容 JDK 8 和 JDK 11,也支持 poi-ooxml-full 里的全部图片类型。如果你的项目用的是 Spring Boot 2.7 或 3.x,依赖管理一般不会冲突。建议模板文件放在src/main/resources/templates/下,这样打 jar 包以后还能通过 classpath 读取,不会出现文件路径找不到的问题。
3.2 图片占位符定位与插入核心代码
先定义一个WordImage的内部类,或者用 Map 也行,重点是参数清晰:
public class WordImage { private String placeholder; // 占位符,例如 ${image_product} private byte[] imageBytes; private String imageType; // 例如 "png"、"jpg" private int widthPx; private int heightPx; // 构造函数和 getter/setter 省略 }然后是核心的导出方法:
public void exportWordWithImages(List<WordImage> images, HttpServletResponse response) throws Exception { // 1. 从 classpath 加载模板 ClassPathResource resource = new ClassPathResource("templates/report_template.docx"); try (XWPFDocument document = new XWPFDocument(resource.getInputStream())) { // 2. 处理正文段落中的图片占位符 for (XWPFParagraph paragraph : document.getParagraphs()) { replacePlaceholderWithImage(paragraph, images); } // 3. 处理表格中的图片占位符 for (XWPFTable table : document.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { replacePlaceholderWithImage(paragraph, images); } } } } // 4. 输出响应 response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); response.setHeader("Content-Disposition", "attachment; filename=report.docx"); document.write(response.getOutputStream()); } }关键是replacePlaceholderWithImage方法,它要完成“合并不完整 Run → 判断占位符 → 插入图片”这几件事:
private void replacePlaceholderWithImage(XWPFParagraph paragraph, List<WordImage> images) { // 先拼接段落所有 Run 的文本,判断是否包含图片占位符 StringBuilder sb = new StringBuilder(); List<XWPFRun> runs = paragraph.getRuns(); for (XWPFRun run : runs) { sb.append(run.text()); } String fullText = sb.toString(); for (WordImage image : images) { if (fullText.contains(image.getPlaceholder())) { // 找到第一个 run 作为插入点 XWPFRun firstRun = runs.get(0); firstRun.setText("", 0); // 清空第一个 Run 的文本 try (ByteArrayInputStream in = new ByteArrayInputStream(image.getImageBytes())) { int pictureType = image.getImageType().equalsIgnoreCase("png") ? XWPFDocument.PICTURE_TYPE_PNG : XWPFDocument.PICTURE_TYPE_JPEG; firstRun.addPicture(in, pictureType, "img." + image.getImageType(), Units.toEMU(image.getWidthPx()), Units.toEMU(image.getHeightPx())); } catch (Exception e) { throw new RuntimeException("插入图片失败: " + image.getPlaceholder(), e); } // 清空其他 Run 的文本 for (int i = 1; i < runs.size(); i++) { runs.get(i).setText("", 0); } // 移除占位符的文本部分,注意图片的 Run 只有图片,没有文本 break; } } }这段代码有一个隐性的性能开销,StringBuilder拼接和contains()是线性扫描。如果文档段落多、图片多,建议把images列表改成 Map,以placeholder为 key,这样查找复杂度降到 O(1)。
3.3 参数与流程决策说明
图片尺寸参数怎么定?我建议不要直接使用原图尺寸,而是从模板维护人那里要“预留区域”的像素尺寸。如果拿不到,就用页面可用宽度的 1/2 作为图片宽度基准,高度按原图比例计算。例如 A4 页面正文区约 16cm 宽,如果插入一张通栏图片,设置像素宽度为 600px(约 15.8 厘米)就比较合适。
在输出响应时,Content-Type 必须是application/vnd.openxmlformats-officedocument.wordprocessingml.document,而不是application/msword。前者才是 docx 的正确 MIME 类型,用后者的话浏览器打开时会提示文件格式不匹配。
还有一点需要注意:导出的最终产物是完整替换后的 document,不是往模板里追加内容。所以模板里如果留了示例段落或示例图片,导出前一定要预先在模板里删干净,否则会跟着文档一起输出。
3.4 远程图片下载与失效兜底
业务里图片来自远程 URL 是常态,比如商品主图存在 OSS 上。这时候在插入前要下载图片。我处理的方式是:
public byte[] downloadRemoteImage(String url) { try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setConnectTimeout(3000); conn.setReadTimeout(5000); try (InputStream in = conn.getInputStream()) { return in.readAllBytes(); } } catch (Exception e) { // 记录日志,返回默认占位图 return defaultPlaceholderImage(); } }注意下载失败不要直接抛异常中断整个导出。一张图挂了导致整份文档导出失败,用户会感觉很莫名其妙。更好的做法是:返回一张内置的“图片加载失败”占位图,并在日志里记录 URL 和错误原因。这样用户的文档还是完整生成的,只是某张图不显示,问题定位也方便。
4. 常见问题与排查技巧实录
4.1 图片不显示的经典原因
问题现象:代码不报错,Word 文件也能打开,但图片的位置是空白,或者显示一个红叉。
排查思路:先看图片字节流有没有真正读到。addPicture()如果传入的InputStream已经读完,也就是ByteArrayInputStream内部的 pos 到了尾部,那么插入的图片数据就是空的。这种现象常见于你把同一个InputStream复用了两次——第一次用于判断图片格式,第二次用于addPicture(),第二次其实已经没数据可读了。
解决办法:判断格式时不要用流,直接用byte[]的魔数判断。或者每次插入前重新new ByteArrayInputStream(bytes)。另外检查图片类型对不对,PNG 图片被声明成PICTURE_TYPE_JPEG,Word 虽然不至于打不开,但显示可能异常。
问题现象:图片显示,但文档打开提示“部分内容有问题,是否尝试恢复”。
这种情况多半是 POI 生成的 XML 里混入了外部的非法标记,或者模板本身是 WPS 创建的,里面夹带了 WPS 特有的自定义属性。解决办法是用 Word 另存一份干净的 docx,不要直接用 WPS 生成的文件做模板。
4.2 导出大文件时内存溢出的排查方向
带图片的 Word 天然就是吃内存大户。一张 2MB 的图片解压到内存,再加上 POI 的 DOM 模型,10 张图可能就有 100MB 以上的堆占用。如果你的接口是批量导出,比如一次导 100 份报告,那内存直接爆炸。
我的经验是分三步处理:
第一,压缩图片。在插入前,用ImageIO把图片缩放到目标尺寸,而不是直接插入原图。一张 1920x1080 的图缩到 400x300 后,体积可能从 1MB 降到 80KB,对 Word 展示效果没有明显影响。
第二,逐份导出。批量生成时,每写一个文档完成就关闭这个XWPFDocument并清空引用,然后System.gc()不一定要调,但释放引用是必须的。批量接口最好用任务队列异步处理,前端轮询任务状态,而不是同步等待 100 份文档全部生成。
第三,设置合理的 JVM 堆内存。Spring Boot 应用跑在容器里,-Xmx至少给到 1G 以上。如果导出只是一个边缘功能,为了避免影响主业务,可以单独用一个导出微服务部署,这样内存问题不会波及主链路。
4.3 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 图片完全不显示 | InputStream 被提前消费 | 每次插入前 new ByteArrayInputStream |
| 图片显示为红叉 | 图片格式声明错误 | 用魔数判断真实格式,并传入对应 PICTURE_TYPE |
| 图片太大撑破表格 | 图片宽度 > 单元格宽度 | 缩放到单元格宽度的 80% 后再插入 |
| 文档提示需要修复 | 模板含有兼容性标记 | 用 Word 另存为干净的 docx |
| 导出内容偏慢 | 远程图片逐个下载 | 启用连接复用,或批量下载改为并行 |
| 占位符没替换成功 | Word 把文本拆到了多个 Run | 用段落级文本拼接判断,而不是单个 Run |
4.4 对 POST 请求超时的处理建议
再补充一个容易被忽略的点:如果导出操作耗时较长,比如要插入 20 张以上的远程图片,用户的浏览器会一直等接口响应。这时候前端如果用的 axios,默认超时时间可能是 60 秒,图片下载稍慢的话请求就断了。
我的做法是:导出接口改成两步。第一步提交导出任务,返回一个任务 ID;第二步前端轮询“任务状态接口”,任务完成后返回文件下载链接。这种方式虽然实现起来麻烦一点,但体验最好,而且大数据量导出时,不会因为一个请求超时导致整个任务失败。这个方案对需要“大文件导出”的场景基本是标配。
最后再分享一个小技巧
我实际操作中发现,POI 对图片的支持虽然完整,但模板里如果存在多个相同前缀的占位符,替换逻辑会变得混乱。比如${image_1}和${image_1_2}同时存在,contains("${image_1}")会把两处都命中。所以占位符命名必须唯一,而且替换时要做精确匹配,建议用占位符前后加空格或者用特殊字符包住,尽量避免前缀重叠。
另一个小技巧是:在模板的表格里预置一行“图片+文字”的示例行,导出时通过 POI 复制这行并替换数据,这样不需要从零创建表格结构,样式完全一致,还能应对行数动态变化的场景。复制行的核心代码是table.insertNewTableRow(rowIndex),然后把模板行的单元格内容和样式复制过去,再替换占位符。这个操作比从零创建表格省心得多。
如果你后续想把这项能力扩展成“批量生成几百份带图片的报告”,可以考虑在模板方案的基础上引入并发:用固定线程池并行处理每份文档的图片下载和插入,IO 密集型的线程数建议在 CPU 核心数的 2 倍左右,经过我测试,图片下载是延迟大头,并发后整体导出时间能缩短到原来的三分之一。