上周刚处理完一个让我印象挺深的需求业务方要求在 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[]。这里建议设置连接超时和读取超时避免某张图片挂了导致整个导出请求卡死。base64Base64.getDecoder().decode(base64Str)注意前端传来的 base64 可能带data:image/png;base64,前缀要先按逗号切掉。本地文件Files.readAllBytes(Paths.get(path))。二进制就是byte[]直接返回。拿到byte[]以后用ByteArrayInputStream包一层传给addPicture()不需要落盘。这也是最容易踩坑的地方很多人习惯先把图片写到临时目录再读临时文件插入逻辑上没问题但并发高了以后临时文件清理是麻烦事哪天忘了删就把磁盘塞满了。2.2 POI 中图片尺寸的换算原理POI 里addPicture()在设置图片宽高时用的单位是EMUEnglish Metric Unit不是像素也不是厘米。这个单位体系是 OOXML 文档的标准1 英寸 914400 EMU1 像素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)); // 单位是 twips1 厘米约 567 twips // 每个单元格设置宽度 cell.setWidth(3000); // twips9000 twips约等于 15.87 厘米这大概是一页 A4 纸正文区域的宽度。如果你在模板里做好了表格列宽用 POI 往单元格里插图片时不要把addPicture()的图片宽度设置得超过单元格宽度否则图片会把单元格撑破整个表格布局就乱了。图片宽度建议比单元格宽度略小 10-20 像素留出单元格的 padding。3. 实操过程与核心环节实现3.1 工程依赖与目录结构先看 Maven 依赖版本是配套好的不要各用各的否则容易出现类冲突dependencies !-- POI 系列用于操作 docx -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-scratchpad/artifactId version5.2.5/version /dependency !-- Spring Boot Web 基础 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependenciesPOI 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(ListWordImage 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; filenamereport.docx); document.write(response.getOutputStream()); } }关键是replacePlaceholderWithImage方法它要完成“合并不完整 Run → 判断占位符 → 插入图片”这几件事private void replacePlaceholderWithImage(XWPFParagraph paragraph, ListWordImage images) { // 先拼接段落所有 Run 的文本判断是否包含图片占位符 StringBuilder sb new StringBuilder(); ListXWPFRun 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_JPEGWord 虽然不至于打不开但显示可能异常。问题现象图片显示但文档打开提示“部分内容有问题是否尝试恢复”。这种情况多半是 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用段落级文本拼接判断而不是单个 Run4.4 对 POST 请求超时的处理建议再补充一个容易被忽略的点如果导出操作耗时较长比如要插入 20 张以上的远程图片用户的浏览器会一直等接口响应。这时候前端如果用的 axios默认超时时间可能是 60 秒图片下载稍慢的话请求就断了。我的做法是导出接口改成两步。第一步提交导出任务返回一个任务 ID第二步前端轮询“任务状态接口”任务完成后返回文件下载链接。这种方式虽然实现起来麻烦一点但体验最好而且大数据量导出时不会因为一个请求超时导致整个任务失败。这个方案对需要“大文件导出”的场景基本是标配。最后再分享一个小技巧我实际操作中发现POI 对图片的支持虽然完整但模板里如果存在多个相同前缀的占位符替换逻辑会变得混乱。比如${image_1}和${image_1_2}同时存在contains(${image_1})会把两处都命中。所以占位符命名必须唯一而且替换时要做精确匹配建议用占位符前后加空格或者用特殊字符包住尽量避免前缀重叠。另一个小技巧是在模板的表格里预置一行“图片文字”的示例行导出时通过 POI 复制这行并替换数据这样不需要从零创建表格结构样式完全一致还能应对行数动态变化的场景。复制行的核心代码是table.insertNewTableRow(rowIndex)然后把模板行的单元格内容和样式复制过去再替换占位符。这个操作比从零创建表格省心得多。如果你后续想把这项能力扩展成“批量生成几百份带图片的报告”可以考虑在模板方案的基础上引入并发用固定线程池并行处理每份文档的图片下载和插入IO 密集型的线程数建议在 CPU 核心数的 2 倍左右经过我测试图片下载是延迟大头并发后整体导出时间能缩短到原来的三分之一。
企业数字化 ERP 产品动态
相关推荐
XSS跨站脚本攻击原理与防御:从基础到SpringBoot实战 XSS(跨站脚本攻击)是前端安全领域最容易被忽视、但实际破坏力极强的威胁之一。很多开发者把XSS简单理解成“弹个alert”,直到用户Cookie被窃取、后台会话被劫持、整站页面被挂马,才意识到它真正能造成的影响。这篇文章我会从攻击原… · 2026/9/24 18:38:20
621张实拍番茄图像+双格式标签YOLO数据集 简介:本资源是面向计算机视觉初学者与YOLO系列算法实践者的番茄目标检测专用数据集,适用于YOLOv5/v7/v8/v9/v10/v11等主流版本的模型训练、验证与测试,可直接用于农业场景下的果实识别、智能采摘系统开发或课程设计项目。压缩包共含1864个文件… · 2026/9/24 18:38:20
从脚本小子到白帽黑客:2026年网络安全入门路线指南 我是在一个很偶然的时刻意识到“脚本小子”和“白帽黑客”之间那道鸿沟有多宽的——当时群里一个新人下载了一键扫描工具,对着某个公网IP跑了一通,然后兴奋地贴出截图问“这个漏洞是不是能拿shell”。他没意识到的是,那个IP可能属于某家医院&… · 2026/9/24 18:38:20
AI数字人直播平台选型实战指南:稳定性、延迟与运维成本深度对比 1. 这不是“换脸直播”,而是实时驱动的数字人生产流水线最近三个月,我连续跑了六家做AI数字人直播的客户现场,从本地MCN机构的直播间,到长三角制造业企业的展会大屏,再到教育科技公司的在线课堂后台——所有场景里&… · 2026/9/24 19:42:16
Claude Code为何坚持CLI:AI编程工具的交互范式取舍 1. 反差现象的起点:当整个行业都在做 GUI,Anthropic 却往回走1.1 一个“倒退感”的产品凭什么刷屏2025 年做 AI 编程工具,几乎所有团队的第一反应都是先把界面做漂亮:网页版聊天窗口、桌面客户端、IDE 插件、项目管理面板… · 2026/9/24 19:42:16
AI数字人直播选型避坑指南:音画同步与OBS兼容性实测 1. 这不是“换张脸播个货”,而是整套直播工业化流水线的重构最近三个月,我帮六家不同行业的客户落地AI数字人直播项目——从教培机构的课程预告、本地生活商家的团购讲解,到制造业企业的产线介绍、跨境电商的多语种产品演示。过程中最常被问到… · 2026/9/24 19:42:16
20款家长管控APP定位实测:精度、围栏与后台存活谁最靠谱? “定位准不准,更新快不快,能不能在娃到家那一刻就弹通知”——这是我在家长群里被问到最多的三个问题,也是这次决定把20款家长管控APP全部实测一遍的直接原因。市面上的儿童手表、学生手机、手机管控软件、家校沟通工具里全塞了定位功能&… · 2026/9/24 19:42:03
UniApp封装高德地图UTS原生插件:从环境搭建到生产部署 在移动端开发里,地图功能几乎是绕不开的硬需求。UniApp 虽然提供了内置地图组件,但遇到复杂业务场景——比如自定义定位样式、后台持续定位、多边形绘制、POI 搜索联动——光靠<map>组件和 JS API 就不太够用了。这时候就得考虑把高德地图原生 SDK… · 2026/9/24 19:42:03
基于YOLOv8的渔船作业监控系统:从环境搭建到边缘部署全流程 简介:这是一套面向计算机、人工智能、自动化等专业学生与教师的毕业设计级项目资源,围绕YOLOv8实现渔船作业监控系统,可用于毕设、课程设计、大作业或项目立项演示。压缩包共97个文件,约24.21MB,以70个Python源码文件为… · 2026/9/24 0:00:13
1D-CNN时间序列建模实战:从Conv1d原理到工业落地 简介:面向时间序列数据建模的一维卷积神经网络完整实现,适合深度学习入门者及需要快速验证时序模型的研究者,能够从音频、文本、传感器或股价等序列中挖掘局部特征与时间依赖。压缩包体积很小,只有3KB,内含3个Python脚… · 2026/9/24 0:00:26
柔软的L:汉语语流中被忽视的舌肌张力控制 1. 这个“L”不是字母表里的L,而是舌尖上的L最近在几个方言群和语音教学社群里,反复看到有人发一句:“也说字母L:柔软的长舌”。初看以为是英语发音课笔记,点开才发现全是方言爱好者、播音系学生、语言康复师甚至戏曲演… · 2026/9/24 0:00:44