
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. 核心实现手把手把图片“塞”进Word3.1 环境的准备这部分没什么花活就是依赖版本必须统一。我用的组合是dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.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 { ListXWPFRun runs paragraph.getRuns(); for (XWPFRun run : runs) { ListXWPFPicture 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代表PNGXWPFDocument.PICTURE_TYPE_JPEG代表JPG/JPEGXWPFDocument.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里的图片宽度是用EMUEnglish Metric Units来计算的不是像素也不是厘米。Units.toEMU()就是专门把厘米转成EMU的方法。14和9分别代表14厘米宽、9厘米高。这里需要根据前面模板占位图的比例调整比如占位图是4比3这里也写4比3不然图片会被拉伸变形。第三为什么用new ByteArrayInputStream包一层而不是直接传byte[]。因为addPicture内部会把输入流读一遍如果你传入的是同一个InputStream第二次调用时会发现流已经读到末尾了。但这里我们是新创建一个流就没这个问题。这是一个很小的细节但是能避免很多人在循环里插入多张图片时出现“第二张图片是坏的”的尴尬。3.4 文本占位符一起替换既然是模板流文字部分自然也是占位符替换。我用的是${fieldName}这种约定替换的时候遍历段落里的所有runspublic void replaceTextInParagraph(XWPFParagraph paragraph, MapString, String dataMap) { String paragraphText paragraph.getText(); if (paragraphText null || !paragraphText.contains(${)) { return; } for (Map.EntryString, 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, ListPictureSource 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写的时候不留神就变成转义符了。我在代码里强制要求外部传参时把\统一换成/或者在读取前做一次replacebyte[] 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 0AJPG前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 EMU1英寸等于2.54厘米。所以14厘米宽 14 / 2.54 * 914400 EMU 5036220 EMUUnits.toEMU(14)就是帮你干这个换算活的但有一个更隐蔽的点XWPFParagraph内部的图片还可能被run的属性影响。比如如果run的文字字号设得特别大图片会跟着往下沉或者和文字垂直对齐发生变化。我在实际中发现图片按照EMU设置了宽度后Word真实渲染时还会参考图片所在行的行高。如果行高是固定值20磅你的图片高度设成9厘米约等于27磅那图片会被“压扁”显示。解决这个问题的办法有两个模板里图片所在段落不要用固定行高尽量用“单倍行距”或者“自动”替换图片后把图片所在run的行高调大比如设成autoCTPPr 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_PNGPOI内部写入图片时仍然会把字节流填进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带图片问题按这个顺序排查能省下大半天的痛苦先确认模板是docx且能用Word正常打开排除模板本身损坏单独测试图片字节流写一个controller直接把某张图片的byte[]打到浏览器里看能不能显示。这一步排除了上游图片数据源问题再检查图片type参数和文件名后缀是否匹配输出docx后不要用WPS打开验证用MS Word打开。WPS对docx的标准解析有一定宽容度有些坏的文档WPS能开但Word开不了最后检查目标目录有没有写权限有没有杀毒软件正在锁定生成的docx文件7. 现代工程里的一些补充思路公式图片、多图片批量与性能7.1 公式图片转Word的思路热搜里有一个“word公式图片转word”顺带提一句。POI本身不支持直接渲染公式但如果是公式的图片比如LaTeX渲染出的PNG它本质上就是一张普通图片完全可以用这个模板替换思路塞到Word里。区别只在于图片的锚定方式尽量用“嵌入型”而不是“浮动型”否则公式会和正文错位。锚定方式如果是用addPicture默认插入通常是嵌入型如果是浮动型你需要额外设置Drawing层的anchor属性。我在实际中偏好嵌入型因为浮动型图片在不同Word版本的渲染效果差异大很容易出现位置漂移。7.2 多图片批量导出时的内存控制一个工单报告里可能有十几张照片每张2MB就意味着一次导出需要吞掉20多MB的byte[]这还没算上POI内部对XML树的内存占用。如果这个接口被并发调用内存压力会比较大。我的优化思路有这几条图片下载采用流式处理下载完一张替换一张不要让所有图片byte[]都堆积在List里输出Word后用ZipInputStream对docx结构做一次“瘦身”——主要是对图片进行压缩。如果图片超过1MB先用Thumbnails库压缩到指定宽度再进模板比如byte[] compressed Thumbnails.of(new ByteArrayInputStream(originalBytes)) .width(1000) .outputFormat(jpg) .outputQuality(0.8) .asByteArray();这样导出的docx体积小很多用户也更容易通过IM工具传送。而且图片宽1000像素打印出来也足够清晰。用完的byte[]手动置null方便GC回收。虽然Java的GC会自己判断但在大并发下主动释放引用仍是有效的。7.3 配合SpringBoot的Controller设计接口设计上建议把导出Word做成同步接口返回文件流而不是生成文件后返回一个路径给前端去下载。原因是文件如果生成在服务器本地磁盘需要定时清理操作不当还会让磁盘被临时文件撑满。我的Controller长这样PostMapping(/export/workorder) public ResponseEntitybyte[] 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的时候少走点弯路。