拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+POI实现Word带图片导出:模板替换与踩坑全解

SpringBoot+POI实现Word带图片导出:模板替换与踩坑全解

1. 一次图片导出的需求复盘:为什么Word里的图片这么难搞

先说个真实的背景。前阵子公司接到一个需求,要把工单详情导出成Word报告,里面不但有文字信息,还要把现场照片、设备铭牌照片按顺序嵌到文档里。听起来就是个“导出”功能,应该很快,结果我一做发现,SpringBoot里用POI导Word带图片这件事,比想象中容易翻车得多。

文字导出一句XWPFParagraph加一个XWPFRun搞定,图片就得走**XWPFRun.addPicture()**或者模板替换,中间涉及字节流转换、图片尺寸单位换算、文档部件结构,稍有不慎就会出现:要么图片不显示,要么整个文件打不开,要么图片裂了。

这篇文章就围绕“SpringBoot导出带图片的Word”这个需求,把我实测过的方案、踩过的坑、以及最终沉淀下来的套路完整梳理一遍。适合这几类人看:

  • 正在用springboot + poi做Word导出,卡在图片插入环节的开发者
  • 需要实现“模板填充 + 图片替换”双能力的场景
  • 图片来源不是本地,而是URL或Minio对象存储的兄弟

先说结论:用POI操作docx,图片不是“贴进去”就完了,它有一套独立的部件注册流程。理解了这个流程,后面所有的问题都变得有理可循。

2. 方案选型:模板流替换,还是纯代码生成?

2.1 两种主流方案的适用边界

我见过很多人在做带图片的Word导出时,一上来就写一堆代码,从零构建段落、添加图片、设置样式。这种纯代码方式的问题是:Word文档的排版复杂性一旦上去,代码量会爆炸式增长,而且可维护性极差。

比如你的模板里有标题、表格、页眉页脚、编号列表,纯代码生成要写几百行;用模板,让实施人员把Word版式调好,程序员只做占位符替换,工作量直接少一半。

这两条路各自的适用场景是:

方案优点缺点适合场景
纯代码生成灵活,不依赖外部文件代码量大,样式维护困难结构简单的报告,临时生成的文档
模板流替换样式完全可控,改版方便需要维护模板文件,替换逻辑要稳格式固定、批量导出的业务文档

我这个需求是工单报告,版式固定,字段固定,所以选了模板+占位符替换的路线。图片不是直接用addPicture插入,而是先在模板里做一个“假图片”当占位符,导出时把图片字节流替换进去。这个思路很重要,后面细说。

2.2 为什么我最终选了模板流替换

理由有三点:

第一,图片位置可控。模板里把图片占位符放在哪个段落,替换完成后图片就在哪个位置,不需要花费大量精力去计算段落坐标。这个对于“照片夹在文字中间”的工单报告来说,太省事了。

第二,图片尺寸统一。在模板里直接设置好占位图的长宽,替换时指定替换图片也按固定宽度输出,这样出来的文档不会出现“一张图撑爆整页”的问题。

第三,代码只处理数据。模板流的代码核心就三件事:读模板、替换文本占位符、替换图片占位符。业务人员调整版式的时候,开发人员不背锅。

3. 核心实现:手把手把图片“塞”进Word

3.1 环境的准备

这部分没什么花活,就是依赖版本必须统一。我用的组合是:

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.3</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency>

注意一定把poi和poi-ooxml的版本锁成同一个,否则会出现org.apache.poi.openxml4j.exceptions.InvalidFormatException那种莫名其妙的问题。另外Word模板文件必须存为**.docx格式,POI对老版.doc的支持约等于没有,硬要用的话得借助HWPF**模块,但是HWPF对图片替换的支持非常弱,我建议整个项目直接统一用docx。

3.2 占位图在模板里怎么做

打开Word,在你想放图片的位置,插入一张任意图片(哪怕是红叉图片都行),然后把这张图片压缩到你要的最终尺寸。比如我希望导出后图片宽度是14厘米,就在Word里把这张图缩放到14厘米。

为什么要这么做?因为POI替换图片时,新图片的显示尺寸默认沿用原占位图在文档中的扩展数据。如果你直接用XWPFDocument从头新建一个段落再addPicture,图片尺寸的默认单位是EMU,不设置好就容易出现图片被拉伸到整页宽度的惨案。

3.3 替换图片的完整代码

直接上我测过的工具方法。核心思路是:遍历文档所有段落,找到包含图片的段落,然后通过XWPFPictureData拿到图片字节流,再用新图片数据覆盖掉它,最后通过addPicture重新插入新的图片关系。

public void replacePictureInParagraph(XWPFParagraph paragraph, byte[] newImageBytes, int imageType) throws Exception { List<XWPFRun> runs = paragraph.getRuns(); for (XWPFRun run : runs) { List<XWPFPicture> pictures = run.getEmbeddedPictures(); if (!pictures.isEmpty()) { for (XWPFPicture picture : pictures) { // 拿到图片在文档中的位置信息 String relationId = picture.getPictureData().getPackageRelation().getId(); // 移除旧图片对应的关系 paragraph.getDocument().getPackagePart().removeRelationship(relationId); } // 用新图片重新插入 try (ByteArrayInputStream bais = new ByteArrayInputStream(newImageBytes)) { run.addPicture(bais, imageType, "pic-" + System.currentTimeMillis() + ".png", Units.toEMU(14), Units.toEMU(9)); } } } }

这里有几个点要特别解释。

第一,imageType的取值。POI里定义在XWPFDocument接口上:

  • XWPFDocument.PICTURE_TYPE_PNG代表PNG
  • XWPFDocument.PICTURE_TYPE_JPEG代表JPG/JPEG
  • XWPFDocument.PICTURE_TYPE_GIF代表GIF

如果图片本来可能是PNG也可能是JPG,建议在调用前根据文件后缀做一次映射。我惯用一个工具:

public static int guessPictureType(String fileName) { String lower = fileName.toLowerCase(); if (lower.endsWith(".png")) { return XWPFDocument.PICTURE_TYPE_PNG; } else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { return XWPFDocument.PICTURE_TYPE_JPEG; } return XWPFDocument.PICTURE_TYPE_PNG; }

第二,Units.toEMU(14)这个参数。POI里的图片宽度是用EMU(English Metric Units)来计算的,不是像素,也不是厘米。Units.toEMU()就是专门把厘米转成EMU的方法。14和9分别代表14厘米宽、9厘米高。这里需要根据前面模板占位图的比例调整,比如占位图是4比3,这里也写4比3,不然图片会被拉伸变形。

第三,为什么用new ByteArrayInputStream包一层,而不是直接传byte[]。因为addPicture内部会把输入流读一遍,如果你传入的是同一个InputStream,第二次调用时会发现流已经读到末尾了。但这里我们是新创建一个流,就没这个问题。这是一个很小的细节,但是能避免很多人在循环里插入多张图片时出现“第二张图片是坏的”的尴尬。

3.4 文本占位符一起替换

既然是模板流,文字部分自然也是占位符替换。我用的是${fieldName}这种约定,替换的时候遍历段落里的所有runs:

public void replaceTextInParagraph(XWPFParagraph paragraph, Map<String, String> dataMap) { String paragraphText = paragraph.getText(); if (paragraphText == null || !paragraphText.contains("${")) { return; } for (Map.Entry<String, String> entry : dataMap.entrySet()) { String key = "${" + entry.getKey() + "}"; if (paragraphText.contains(key)) { // 这里不能直接操作整个段落,因为跑遍所有run才拿得到完整字符串 // 太长的字符串可能被Word拆成多个runs,需要合并处理 mergeAndReplaceInRuns(paragraph, key, entry.getValue()); } } }

有一段期间,我在替换文字时直接用一个run.setText()搞定,发现导出文档出现了“只替换了半个字”的情况。排查后发现Word在保存文档时会把一个段落文本拆成多个run,如果${field}这个字符串跨了两个run,就必须先合并runs,再替换。这也是POI操作Word最容易碰到的隐藏问题之一,下一篇我再单独展开。

4. 图片来自天南海北:本地、URL、Minio三种源的实战处理

4.1 图片源统一转成byte[]再进模板

不管是本地文件、网络URL还是Minio对象存储,到了替换图片那一步,本质都是拿到一个byte[]数组。所以我的代码里面没有一个方法叫replacePictureFromLocal,而是一个统一入口:

public void exportWordWithPictures(XWPFDocument doc, List<PictureSource> pictureSources) throws Exception { for (PictureSource source : pictureSources) { byte[] imageBytes = source.loadAsBytes(); replacePictureInParagraph(findTargetParagraph(doc, source.getPlaceHolderId()), imageBytes, guessPictureType(source.getFileName())); } }

PictureSource是一个抽象接口,不同来源实现各自的loadAsBytes()。

4.2 本地文件读取:最基础的case

本地文件的读取最简单,但有个坑是路径里的反斜杠。Windows下路径是C:\photos\001.jpg,写的时候不留神就变成转义符了。我在代码里强制要求外部传参时把\统一换成/,或者在读取前做一次replace:

byte[] bytes = Files.readAllBytes(Paths.get(path.replace("\\", "/")));

顺带说一句,如果图片文件不在同一个目录,先做存在性校验再读。读取期间最好用try-with-resources,不要把文件句柄一直拽着不放,因为这是Word导出应用里常见的“文件被占用,无法删除”报错来源。

4.3 URL下载:不是简单的new URL().openStream()

这个坑最多。很多人直接:

byte[] bytes = IOUtils.toByteArray(new URL(imageUrl).openStream());

线上环境跑几次就出问题。本质原因是,很多图片服务器做了防盗链,或者返回了302跳转到CDN地址。直接openStream拿到的可能是HTML重定向页面,不是图片本身。更恶心的是,某些服务端会检查User-Agent,不带上就给你403。

我现在的做法是用HttpClient,并设置重定向支持和UA头:

public byte[] downloadImageAsBytes(String imageUrl) throws IOException { CloseableHttpClient httpClient = HttpClients.custom() .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(10000) .build()) .build(); HttpGet get = new HttpGet(imageUrl); get.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); get.setHeader("Referer", ""); try (CloseableHttpResponse response = httpClient.execute(get)) { if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { throw new IOException("download image failed, status: " + response.getStatusLine().getStatusCode()); } return IOUtils.toByteArray(response.getEntity().getContent()); } }

这里有几个细节:

  • setConnectTimeout和setSocketTimeout必须设,否则碰到一个不响应的图片地址,接口会一直挂着,前端等30秒直接超时
  • Referer设成空字符串,有时候反而是最安全的
  • 下载完后校验一下返回的字节长度。图片服务器如果把404的错误页面返回200(有些网关会这么干),你需要检查字节流的魔数。PNG图片前8个字节固定是89 50 4E 47 0D 0A 1A 0A,JPG前3个字节是FF D8 FF。我在工具里加了一个轻量校验:
public static boolean isImage(byte[] bytes) { if (bytes == null || bytes.length < 8) { return false; } return (bytes[0] & 0xFF) == 0x89 || ((bytes[0] & 0xFF) == 0xFF && (bytes[1] & 0xFF) == 0xD8); }

4.4 Minio源:Presigned URL的正确使用姿势

热搜里出现了“minio加入到springboot”这个关键词,说明很多人已经在用对象存储管理图片了。Minio的sdk下载图片字节流,我个人最惯用的是PresignedGetObjectUrl生成一个临时链接,然后走HTTP下载。为什么不用getObject()直接读流?因为Minio的getObject拿到的InputStream如果处理不完,很容易把连接池占满,而且trace起来麻烦。

public byte[] loadBytes() throws Exception { String presignedUrl = minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucketName) .object(objectName) .expiry(60) .build()); return downloadImageAsBytes(presignedUrl); }

expiry(60)代表60秒有效,生成链接后立即下载,基本不会过期。另外Minio的bucket如果设置了私有权限,通过这个临时链接下载是完全没问题的,不需要把bucket的公网读权限打开——这一点对于安全要求高的项目是底线。

5. 图片能显但被拉伸、大小不对:尺寸换算和图片容器分析

5.1 POI里图片尺寸的单位不是厘米,也不是像素

刚接触POI图片导出的人,十有八九会在尺寸问题上懵一圈。POI内部图片的宽度高度默认走的是EMU单位体系,1英寸等于914400 EMU,1英寸等于2.54厘米。所以:

  • 14厘米宽 = 14 / 2.54 * 914400 EMU = 5036220 EMU
  • Units.toEMU(14)就是帮你干这个换算活的

但有一个更隐蔽的点:XWPFParagraph内部的图片还可能被run的属性影响。比如如果run的文字字号设得特别大,图片会跟着往下沉,或者和文字垂直对齐发生变化。我在实际中发现,图片按照EMU设置了宽度后,Word真实渲染时还会参考图片所在行的行高。如果行高是固定值20磅,你的图片高度设成9厘米约等于27磅,那图片会被“压扁”显示。

解决这个问题的办法有两个:

  1. 模板里图片所在段落不要用固定行高,尽量用“单倍行距”或者“自动”
  2. 替换图片后,把图片所在run的行高调大,比如设成auto:
CTPPr ppr = paragraph.getCTP().getPPr(); if (ppr == null) { ppr = paragraph.getCTP().addNewPPr(); } CTSpacing spacing = ppr.isSetSpacing() ? ppr.getSpacing() : ppr.addNewSpacing(); spacing.setBefore(Units.toDxa("200")); spacing.setAfter(Units.toDxa("200"));

这里的Units.toDxa是把磅值转成Word内部使用的DXA单位。简单说,就是在图片周围加一点段前段后的空间,免得图片上下被裁切。

5.2 图片在表格单元格里的处理方式

工单报告里经常出现“图片放在表格单元格内”的版式。在这个场景下,直接遍历doc.getParagraphs()是找不到那些图片的,因为图片在XWPFTableCell里。

遍历方式要加一层:

for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph para : cell.getParagraphs()) { replacePictureInParagraph(para, newImageBytes, imageType); } } } }

我最初写了一个只遍历doc.getParagraphs()的版本,结果有个照片导不出来。查了半天,原因就是图片放在表格里,而表格内部的段落不属于文档顶层段落列表。记住这个层级关系:文档段落和表格是平级的,表格内部的段落只能从Table往下钻。

而且表格里的图片替换还有一个注意点:替换前先看单元格里有没有多个段落,如果只有一个段落但包含多个图片,替换时最好指定一个图片序号。比如“现场照片1”和“现场照片2”在两个不同的单元格,问题不大;如果它们在同一个段落里连续出现,就要根据占位顺序区分,我一般用paragraph.getRuns()的索引做定位。

6. 踩坑实录:文件损坏、图片空白、模板跑偏的排查链路

6.1 “导出的Word打不开”这一种情况

POI生成docx文件打不开,最常见的原因有两个:没关闭输出流和模板文件本身被损坏。

没关闭输出流这个问题,很多新手容易犯。他们写:

FileOutputStream out = new FileOutputStream("output.docx"); doc.write(out);

然后就结束了。没有out.close(),也没有doc.close()。在本地Windows小文件时可能没事,但部署到Linux服务器后,文件系统缓存机制不同,大概率会出现文件头不对、zip格式不完整的问题。标准做法是:

try (FileOutputStream out = new FileOutputStream(outputPath)) { doc.write(out); } finally { doc.close(); }

POI的XWPFDocument实现了Closeable,它的close会负责释放底层的一些资源。有人会问,我不关会不会泄露?在导出接口场景下,如果每次请求都生成一个新doc,不关闭必然导致内存里的临时文件资源积累,请求多了JVM的老年代直接被撑爆。

6.2 图片不显示但文件能打开

如果是图片空白,优先怀疑图片的type参数传错了。比如你把一张JPG图片传成了PICTURE_TYPE_PNG,POI内部写入图片时仍然会把字节流填进docx的media目录,但Word识别不了,就会显示“无法显示图片”。这也是为什么我在前面强调guessPictureType()必须做得严谨。

第二个可能原因是图片字节流为空。下载URL图片时服务器返回了一个空壳响应(比如0字节),替换后Word一样能打开但图片裂了。解决方案是写导入前校验:读出的byte[]不能为空,而且前几位魔数要匹配。

第三个可能原因是中文文件名。模板占位图在run里插入时,POI会给图片文件生成一个内部名称,如果原文件名包含中文,某些POI版本写入docx的media目录时文件名编码处理不当,会导致图片关系链断裂。稳妥做法是内部统一改名为ASCII文件名,比如:

String internalImageName = "pic_" + UUID.randomUUID().toString().replace("-", "") + ".png";

6.3 模板跑偏:替换了文本却丢失了图片占位

这个坑特别隐蔽。我在一个版本里写完替换逻辑后发现:文档文字都替换成功,图片一个没有。排查后发现,我用XWPFDocument.getParagraphs()遍历时,明明看到了那个包含图片的段落,但执行到run.getEmbeddedPictures()时返回的是空集合。

原因是模板里的图片无法作为“纯run”存在。Word在加载模板时,可能会把图片拆成一个独立的XWPFPicture对象挂在paragraph上,但不是挂在run的embeddedPictures上。换句话说,XWPFRun.getEmbeddedPictures()不是唯一的图片容器,还需要检查XWPFParagraph内部的CTDrawing节点。

这个场景下,我的规避方式比较土但非常有效:模板里不直接放图片,而是放一个明确的文字占位符,比如{{IMG:现场照片1}},代码遍历文本时发现这个标记,就在该段落上用addPicture方法插入一张新图,然后清除占位符文本。这样绕开了POI对模板已有图片的兼容性解析,结构完全由我们掌控。代码长一点,但至少不会出现“图片藏得找不着”的问题。

如果你用的是带图模板,不想改成文字占位符,那就做好心理准备:图片替换操作涉及对OWML的CTDrawing节点做遍历和关系替换,代码复杂度和踩坑概率都会明显上升。我会建议,业务稳定之后还是一劳永逸地改成文字占位符模式。

6.4 完整的排查链路

如果你现在也遇到了导出Word带图片问题,按这个顺序排查,能省下大半天的痛苦:

  1. 先确认模板是docx,且能用Word正常打开,排除模板本身损坏
  2. 单独测试图片字节流:写一个controller,直接把某张图片的byte[]打到浏览器里看能不能显示。这一步排除了上游图片数据源问题
  3. 再检查图片type参数和文件名后缀是否匹配
  4. 输出docx后,不要用WPS打开验证,用MS Word打开。WPS对docx的标准解析有一定宽容度,有些坏的文档WPS能开但Word开不了
  5. 最后检查目标目录有没有写权限,有没有杀毒软件正在锁定生成的docx文件

7. 现代工程里的一些补充思路:公式图片、多图片批量与性能

7.1 公式图片转Word的思路

热搜里有一个“word公式图片转word”,顺带提一句。POI本身不支持直接渲染公式,但如果是公式的图片(比如LaTeX渲染出的PNG),它本质上就是一张普通图片,完全可以用这个模板替换思路塞到Word里。区别只在于图片的锚定方式尽量用“嵌入型”而不是“浮动型”,否则公式会和正文错位。

锚定方式如果是用addPicture默认插入,通常是嵌入型;如果是浮动型,你需要额外设置Drawing层的anchor属性。我在实际中偏好嵌入型,因为浮动型图片在不同Word版本的渲染效果差异大,很容易出现位置漂移。

7.2 多图片批量导出时的内存控制

一个工单报告里可能有十几张照片,每张2MB,就意味着一次导出需要吞掉20多MB的byte[],这还没算上POI内部对XML树的内存占用。如果这个接口被并发调用,内存压力会比较大。

我的优化思路有这几条:

  1. 图片下载采用流式处理,下载完一张替换一张,不要让所有图片byte[]都堆积在List里
  2. 输出Word后用ZipInputStream对docx结构做一次“瘦身”——主要是对图片进行压缩。如果图片超过1MB,先用Thumbnails库压缩到指定宽度再进模板,比如:
byte[] compressed = Thumbnails.of(new ByteArrayInputStream(originalBytes)) .width(1000) .outputFormat("jpg") .outputQuality(0.8) .asByteArray();

这样导出的docx体积小很多,用户也更容易通过IM工具传送。而且图片宽1000像素打印出来也足够清晰。

  1. 用完的byte[]手动置null,方便GC回收。虽然Java的GC会自己判断,但在大并发下主动释放引用仍是有效的。

7.3 配合SpringBoot的Controller设计

接口设计上建议把导出Word做成同步接口返回文件流,而不是生成文件后返回一个路径给前端去下载。原因是文件如果生成在服务器本地磁盘,需要定时清理,操作不当还会让磁盘被临时文件撑满。

我的Controller长这样:

@PostMapping("/export/workorder") public ResponseEntity<byte[]> exportWorkOrder(@RequestBody ExportRequest request) throws Exception { byte[] content = workOrderExportService.exportWithImages(request); String fileName = URLEncoder.encode("工单详情_" + request.getOrderId(), "UTF-8") .replace("+", "%20"); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + fileName + ".docx") .contentType(MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.wordprocessingml.document")) .body(content); }

返回byte[]有个好处是前端拿到后可以直接用Blob大法触发浏览器下载,不需要走临时文件路径。

8. 最后再分享一个提升模板替换稳定性的技巧

做模板替换做多了,我越来越觉得“占位符书写规范”这件事值回票价。强烈建议团队内部约定所有模板占位符都带统一前缀,比如{{IMG:xxx}}、{{TXT:xxx}},不要使用${xxx}这种和很多模板引擎撞车的写法。这样在代码里一个正则就能区分文本占位符和图片占位符,还能避免用户在文本中误输入特殊符号导致替换异常。

另外一个小技巧:所有替换逻辑执行完后,自己再做一次“占位符残留检测”。就是遍历文档所有段落,用paragraph.getText()检查是否还有{{或}}残留。如果模板字段更新了但代码里忘了同步新增字段,导出的文档会出现一排{{xxx}}丑字,客户看到了第一反应就是程序bug。这个检测虽然看起来多此一举,但我靠它拦下了不下三次线上导出事故。

用我自己的话说:带图片的Word导出,核心不在于“会调addPicture”,而在于“把图片数据流的边界、尺寸单位和文档结构都搞清楚”。搞清楚了,一次导出十几张图都不慌;搞不清楚,最简单的单图模板也能让你折腾半天。希望这篇实战记录能帮你在做SpringBoot导出带图片Word的时候少走点弯路。

返回列表