Spring Boot导出Excel加文字水印:POI实现方案与踩坑指南

发布时间:2026/10/7 10:41:06
Spring Boot导出Excel加文字水印:POI实现方案与踩坑指南 前段时间给公司内部的报表系统做导出功能业务方提了一个很“朴素”的需求导出的Excel文件要带水印而且水印得是文字不是Logo图片——类似“内部资料请勿外传”最好还能把导出人的姓名和工号打上去。乍一听不难结果翻遍Apache POI的API发现事情没那么简单。Excel和Word不一样xlsx里没有一个现成的“水印层”可以用来放文字。盲目在网上搜现成方案搜到的多是给图片加个半透明水印再插入或者直接把水印文字画成PNG贴到工作表上。这些方案能用但距离“文字水印”的预期还是差了口气。这次就把我在Spring Boot项目里落地这个功能的过程、代码和踩过的坑完整整理出来。里面会覆盖两种主流做法一是“文字渲染成图片再插入”的快速方案适合赶工期、不追求文件体积的场景二是“直接操作xlsx底层XML注入文本框实现的文字水印”效果更接近Office原生的水印体验文件也小但代码细节更多。想直接抄作业的重点看第四章的封装工具类和第五章的坑位清单。1. 需求拆解与技术选型为什么xlsx天生不支持水印1.1 水印需求的两种形态在接这个需求之前我习惯先跟业务方确认一件事你说“带水印”到底是想要哪种形态是数据表上面浮着一层半透明的文字斜铺满屏还是打印出来每一页纸张背景上有淡淡的一行字这俩在Excel里的实现路径完全不同。实际做下来业务方要的绝大多数是“打印时每页可见”的斜向水印。也就是说看Excel在屏幕上要有存在感打印出来也不能丢。少数场景还要水印跟随人员动态变化比如“张三 2025-06-12 14:30导出”这就意味着水印文字不可能提前用模板固死必须在导出接口里动态生成。理清这层之后才能确定技术方案的取舍。如果只是屏幕上展示浮动文本框和图片都能凑合如果要打印也稳定可见就必须把水印对象放到“每个打印页都能命中的位置”要么铺满整个工作表区域要么走页眉图片机制。1.2 xlsx文件结构水印到底该改哪里很多第一次做这个需求的人会下意识去翻POI的Sheet类有没有类似setWatermark的方法结果没有。因为xlsx压根没有单独的水印层。xlsx本质是一个ZIP压缩包里面全是XML描述文件xlsx文件结构 ├── xl/workbook.xml ├── xl/worksheets/sheet1.xml ├── xl/drawings/drawing1.xml ├── xl/worksheets/_rels/sheet1.xml.rels工作表的单元格数据在sheet1.xml里而浮动图形、图片、文本框在drawing1.xml里。所谓“文字水印”在Excel里其实就是一个或者一组带旋转角度的xdr:sp形状节点里面包着a:txBody文本内容。渲染时这些文本框悬浮在单元格上方视觉上形成水印效果。理解了这一点思路就通了要么用POI的Drawing API创建文本框让POI帮我们写drawing1.xml要么干脆不碰绘图对象把文字画到一张透明背景的PNG上再以图片形式插入工作表或页眉。两种方案殊途同归都是在“没有原生水印”的前提下曲线救国。1.3 方案对比图片水印 vs 底层XML文字水印我把两种主流方案的优缺点列成表格方便不同场景直接对号入座对比项文字渲染成图片水印底层XML文字水印实现难度低Java2D POI常规API即可中需要操作XSSFShape和底层XML属性生成速度中等需做一次图片编码快纯XML文本节点文件体积偏大PNG图片会膨胀小几乎不增加体积水印文字可编辑不可是图片可双击文本框可修改打印效果取决于插入位置浮动图片需铺满才能保证每页可见好文本框铺满区域后打印稳定可见兼容性WPS、Excel均较好依赖DrawingML新版Office和WPS都支持可控性旋转、透明度需在图片生成时控制旋转、透明度、字体、颜色均可单独控制结论并不复杂如果只是临时用一下、处理几十个文件图片水印方案十分钟能搞定如果是做公司级的报表导出涉及文件量多、对文件体积有要求、还需要水印文字跟着导出人动态变化我更推荐底层XML文字水印方案也是下文重点展开的。2. 图片水印方案5分钟快速落地2.1 用Java2D把水印文字画成PNG图片水印方案最核心的一点是先用java.awt.Graphics2D把水印文字画到一张透明背景的图片上然后把这图片插进Excel。生成水印图片时有几个参数要提前想好否则后面导出效果一言难尽字体中文字体建议用“微软雅黑”或“宋体”服务端如果没有这些字体可能回退到默认字体轻则换字重则乱码。颜色用Color.GRAY再配合AlphaComposite.SRC_OVER实现半透明实际透明度建议0.1到0.2之间。太深会盖住表格数据太浅打印出来看不清楚。旋转水印经典角度是逆时针45度或顺时针315度直接用g2d.rotate(Math.toRadians(-45), 中心点)。多行文字如果水印包含多行信息比如公司名称、导出人、时间需要在图片上分别绘制多行。别在一行里拼命塞显示效果很差。private static BufferedImage createWatermarkImage(ListString textLines, int width, int height) { BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d image.createGraphics(); g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.15f)); g2d.setFont(new Font(Microsoft YaHei, Font.BOLD, 42)); g2d.setColor(Color.GRAY); g2d.rotate(Math.toRadians(-45), width / 2.0, height / 2.0); FontMetrics metrics g2d.getFontMetrics(); int lineHeight metrics.getHeight(); int startY height / 2 - ((textLines.size() - 1) * lineHeight) / 2; for (int i 0; i textLines.size(); i) { String line textLines.get(i); int x (width - metrics.stringWidth(line)) / 2; int y startY i * lineHeight; g22d.drawString(line, x, y); } g2d.dispose(); return image; }这段代码有几个细节值得注意。绘制透明背景图片必须用TYPE_INT_ARGB不能直接代替生成的png旋转角度要绕图片中心点旋转否则边框不对多行文字要先算好总高度保证居中不然水印会偏到角落。2.2 用POI把图片贴到工作表上图片生成后接下来的事情就简单了。先把BufferedImage转成字节数组加入工作簿的图片列表拿到pictureIdx再通过createDrawingPatriarch创建画布把图片放在一个足够大的XSSFClientAnchor上。public static void addImageWatermark(XSSFSheet sheet, String watermarkText) throws IOException { ListString lines Arrays.asList(内部资料, 请勿外传, watermarkText); BufferedImage image createWatermarkImage(lines, 1000, 800); ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, png, baos); int pictureIdx sheet.getWorkbook().addPicture(baos.toByteArray(), Workbook.PICTURE_TYPE_PNG); XSSFDrawing drawing sheet.createDrawingPatriarch(); XSSFClientAnchor anchor new XSSFClientAnchor(0, 0, 0, 0, 0, 0, 100, 80); anchor.setAnchorType(ClientAnchor.AnchorType.DONT_MOVE_AND_RESIZE); XSSFPicture picture drawing.createPicture(anchor, pictureIdx); picture.setLineWidth(0); }注意anchor里的100和80不是像素是单元格列/行的索引跨度。把跨度设大图片就会覆盖工作表的很大区域。DONT_MOVE_AND_RESIZE的目的是让水印图片不会因为用户调整单元格宽高而乱跑这是必须设置的一项。2.3 图片水印的几个坑这方案看着简单实际用起来坑不少。最明显的是文件体积膨胀。一张1000x800的PNG图片被整张塞进xlsx一个原本几十KB的报表文件可能直接涨到几百KB甚至上MB。如果一次导出多个sheet且每个sheet都加水印体积膨胀更离谱。第二个问题是水印图片默认可以被鼠标选中并拖动。虽然设置了DONT_MOVE_AND_RESIZE但用户依然可以拖走它。如果要彻底防拖还需要给图片加锁定或者干脆把这层保护放到后面做工作簿保护时一起处理。业务方如果要求“水印不可删”图片方案其实不太够用。第三个问题出在打印。如果图片只覆盖了工作表的一部分区域打印多页时水印往往只出现在前几页。所以用图片方案时图片的跨度区域必须包住整个可能使用的单元格范围否则就会出现“第一页有水印后面全是干净页”的尴尬情况。3. 底层XML文字水印方案让水印可编辑且文件更小3.1 核心思路在drawing1.xml里注入文本框文字水印的本质就是在drawing1.xml里创建一组文本框节点。每个文本框包含文字内容、旋转角度、字体颜色、填充和边框属性。如果手动拼XML哪怕只是一个文本框代码也有好几行而且容易拼错命名空间。好在POI提供了XSSFDrawing.createTextbox可以让我们用Java对象直接创建文本框。关键代码如下XSSFDrawing drawing sheet.createDrawingPatriarch(); XSSFClientAnchor anchor new XSSFClientAnchor(padding, padding, padding, padding, startCol, startRow, endCol, endRow); XSSFTextBox textBox drawing.createTextbox(anchor); textBox.setNoFill(true); textBox.setLineNoFill(true); textBox.setText(watermarkText);createDrawingPatriarch在第一次调用时会自动创建画布如果已经存在画布再次调用会返回同一个对象所以在循环创建多个水印文本框时不要反复调用只创建一次。3.2 设置旋转角度和文字颜色文字水印和普通文本框的区别主要在三个属性旋转角度、无填充、浅色文字。旋转角度在DrawingML里以1/60000度为单位。45度角对应的像素值是45 * 60000 2700000。但方向有讲究正负值在不同版本的Excel渲染里表现不完全一样我的经验是先用2700000实测如果发现水印向右下倾斜且不符合要求改成-2700000或者8100000135度。// 旋转45度 textBox.getCTShape().getSpPr().getXfrm().setRot(2700000); // 设置文字颜色为浅灰 D9D9D9 CTTextParagraph p textBox.getCTShape().getTxBody().getPArray(0); CTRegularTextRun r p.getRArray(0); CTTextCharacterProperties rPr r.isSetRPr() ? r.getRPr() : r.addNewRPr(); rPr.setSz(2000); // 20pt根据水印文字长度调整 CTSolidColorFillProperties fill rPr.addNewSolidFill(); fill.setSrgbClr(new byte[]{(byte) 0xD9, (byte) 0xD9, (byte) 0xD9});字体大小也需要拿捏。太小没存在感太大糊成一团。我通常用1800到2400之间也就是18pt到24pt。颜色用D9D9D9这种浅灰视觉上既不干扰数据阅读又能明显感受到水印存在。3.3 多行多列平铺水印的实现细节单个文本框只能覆盖一小块区域。真正实用的是“多行多列文字水印”即在工作表的数据范围内每隔几行几列放一个水印文本框形成网格状的斜向水印。先要估算水印覆盖范围。如果sheet里已经有数据可以用sheet.getLastRowNum()拿到最大行号和列号如果数据还没填充就往大了估int totalRows sheet.getLastRowNum() 1; if (totalRows 50) { totalRows 50; } int totalCols 20;然后按步长生成网格public static void addTextWatermark(XSSFSheet sheet, String watermarkText, int rowStep, int colStep) { XSSFDrawing drawing sheet.createDrawingPatriarch(); int totalRows Math.max(sheet.getLastRowNum() 1, 50); int totalCols 20; for (int row 0; row totalRows; row rowStep) { for (int col 0; col totalCols; col colStep) { XSSFClientAnchor anchor new XSSFClientAnchor(0, 0, 0, 0, col, row, col colStep, row rowStep); XSSFTextBox textBox drawing.createTextbox(anchor); configWatermarkTextBox(textBox, watermarkText); } } }rowStep和colStep就是每个水印块占用的行列跨度。比如行步长填10意思就是每10行放一个水印列步长填6就是每6列放一个。具体数值要根据实际数据密度微调数据稀疏的表格步长可以大一点密度高的要小一点。3.4 一个完整的configWatermarkTextBox方法把前面提到的设置项集中封装private static void configWatermarkTextBox(XSSFTextBox textBox, String watermarkText) { // 去掉填充和边框 textBox.setNoFill(true); textBox.setLineNoFill(true); // 设置显示文字 textBox.setText(watermarkText); // 旋转45度方向视版本调整正负 textBox.getCTShape().getSpPr().getXfrm().setRot(2700000); // 设置文字颜色和大小 CTTextParagraph p textBox.getCTShape().getTxBody().getPArray(0); CTRegularTextRun r p.getRArray(0); CTTextCharacterProperties rPr r.isSetRPr() ? r.getRPr() : r.addNewRPr(); rPr.setSz(2000); rPr.setSolidFill(); rPr.getSolidFill().setSrgbClr(new byte[]{(byte) 0xD9, (byte) 0xD9, (byte) 0xD9}); // 关闭换行避免文字被挤出边框 textBox.getCTShape().getTxBody().setWrap(square); }这个封装试过多次注意几点setText之后txBody里一定会有一个paragraph和run所以不需要手动创建paragraph如果没有运行节点才用addNewR补充。setWrap(square)的目的是文字在文本框内允许换行如果水印文字很长不设置的话可能被截断显示不全。3.5 页眉图片水印最接近官方水印的方案除了上面的浮动文本框方案还有一个相对小众但原理上更接近Excel内置水印的做法把水印文字渲染成图片然后放在页眉区域用G占位符告诉Excel这是页眉图片。页眉图片的优点是天然跟页面绑定每一页打印都会带上而且不会出现在工作表的单元格上方不会跟数据产生视觉重叠。缺点是国内很多报表是用WPS打开页眉图片偶尔会被WPS处理为页眉横线的一部分样式不稳定。另外页眉里的图片没法做到跨越整个A4面积通常只会在页面顶端或底端出现一小块。我自己的结论是如果公司统一用微软Office可以考虑用页眉图片方案如果用户群里大量存在WPS用户还是老老实实铺满工作表区域的文本框方案更稳。4. Spring Boot导出接口完整封装4.1 Controller层设计把水印逻辑放进Spring Boot项目时建议单独抽一个ExcelExportUtil工具类不要在Controller里堆一把梭代码。Controller只需要负责接收请求参数和写出文件流。RestController RequestMapping(/report) public class ReportController { GetMapping(/export) public ResponseEntitybyte[] export(RequestParam String operatorName) throws IOException { // 1. 创建workbook并填充业务数据 XSSFWorkbook workbook new XSSFWorkbook(); XSSFSheet sheet workbook.createSheet(月度经营报表); // 省略填充业务数据表格内容 // 2. 添加文字水印 String watermark 内部资料 | operatorName | LocalDateTime.now(); ExcelWatermarkUtil.addTextWatermark(sheet, watermark, 10, 6); // 3. 写为字节数组并返回 ByteArrayOutputStream out new ByteArrayOutputStream(); workbook.write(out); workbook.close(); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename*UTF-8 URLEncoder.encode(月度经营报表.xlsx, UTF-8)) .contentType(MediaType.parseMediaType( application/vnd.openxmlformats-officedocument.spreadsheetml.sheet)) .body(out.toByteArray()); } }文件名的中文编码容易踩坑。直接用filenamefile.xlsx会遇到中文文件名在某些浏览器里乱码这里用RFC 5987的filename*UTF-8 URLEncoder在测试过的chrome、Edge和Safari下都正常。4.2 工具类完整代码public class ExcelWatermarkUtil { private static final int ROTATION_45_DEGREES 2700000; private static final byte[] WATERMARK_COLOR new byte[]{(byte) 0xD9, (byte) 0xD9, (byte) 0xD9}; public static void addTextWatermark(XSSFSheet sheet, String watermarkText, int rowStep, int colStep) { if (sheet null || watermarkText null || watermarkText.isEmpty()) { return; } XSSFDrawing drawing sheet.createDrawingPatriarch(); int totalRows Math.max(sheet.getLastRowNum() 1, 50); int totalCols 20; for (int row 0; row totalRows; row rowStep) { for (int col 0; col totalCols; col colStep) { XSSFClientAnchor anchor new XSSFClientAnchor( 0, 0, 0, 0, col, row, col colStep, row rowStep); XSSFTextBox textBox drawing.createTextbox(anchor); configTextBox(textBox, watermarkText); } } } private static void configTextBox(XSSFTextBox textBox, String text) { textBox.setNoFill(true); textBox.setLineNoFill(true); textBox.setText(text); textBox.getCTShape().getSpPr().getXfrm().setRot(ROTATION_45_DEGREES); CTTextParagraph paragraph textBox.getCTShape().getTxBody().getPArray(0); CTRegularTextRun run paragraph.getRArray(0); CTTextCharacterProperties rPr run.isSetRPr() ? run.getRPr() : run.addNewRPr(); rPr.setSz(2000); rPr.addNewSolidFill().setSrgbClr(WATERMARK_COLOR); textBox.getCTShape().getTxBody().setWrap(square); } }代码里两个参数rowStep和colStep建议从外部传入不要写死在工具类里。因为不同的报表数据密度差异很大有的表就5行数据铺50个水印完全是浪费有的表300行10行步长又太稀。工具类保持“只传参数不猜业务”的设计后续扩展也更灵活。4.3 大数据量导出时的取舍如果报表本身数据量很大需要用到SXSSFWorkbook流式写入来降低内存占用时问题就来了SXSSFWorkbook对XSSFSheet底层XML的访问支持有限直接往下转XSSFSheet容易报类型转换异常。我的处理习惯是大数据量场景下先用SXSSFWorkbook把数据流式写入临时文件写完后再用XSSFWorkbook重新打开这个临时文件追加文字水印最后输出给前端。坏处是多了两轮文件IO好处是两个逻辑完全解耦各自都能稳定工作。如果数据量还在XSSFWorkbook可接受范围内通常几万行以内就尽量别用SXSSFWorkbook直接一个XSSFWorkbook到底最省心。给桌面端导出的报表大部分都是几千行级别真没必要为这点内存数据去引入SXSSFWorkbook。5. 常见问题与排查实录5.1 水印打印不出来怎么办这是最高频的问题。文字水印打印不出来的原因基本是文本框没有覆盖到打印区域。比如工作表的实际数据在A1:H50但水印只铺到了前20行打印第3页之后就看不到水印了。排查思路也很简单打开Excel看水印对象在哪个范围对比打印预览范围。如果是Coverage不够把addTextWatermark里的totalRows和totalCols调大或者直接把rowStep和colStep调小让水印密集一些。还有一种可能是打印设置里勾选了“草稿模式”或“不打印图形”。Excel的页面设置里如果勾选“草稿品质”许多图形对象在打印时会被忽略。这点跟业务方沟通一下就能解决或者直接在导出时把工作表的打印设置改成“打印对象”。5.2 WPS打开正常、Excel打不开或者反过来文本框水印依赖的DrawingML对象在WPS和Office里的容错机制不完全一样。最常见的异常是文件损坏打开时提示“发现无法读取的内容”。这种问题十有八-九是setRot角度值写成了负数或者textBox.getCTShape内部的spPr结构在某些POI版本下生成得不完整。我的经验是优先用POI 4.1.2以上版本xssf相关类库在5.x里已经很稳定。如果线上还有老项目用3.x的POI建议一次性升到5.2.x。老版本XSSFTextBox连setLineNoFill可能都没有硬写代码很容易在生产环境炸一遍。5.3 水印文字叠在数据上无法点击/选中水印文字本质上就是浮动文本框用户点它就会选中甚至可以编辑删除。如果业务方要求“水印不能被乱动”有两个基本手段。第一步是把水印文本框的anchor类型设为DONT_MOVE_AND_RESIZE保证它不会随着单元格拖拽漂移。第二步是开启工作表保护sheet.protectSheet(password);但要注意保护工作表是全表生效的锁定之后用户也没法编辑单元格了。如果只是禁止拖动水印但允许编辑数据需要在单元格里设置取消锁定配合保护的lock属性来做。这个会根据具体需求权衡不能一刀切。5.4 xlsx文件体积莫名其妙膨胀文字水印的正常情况下不会让文件体积明显变大但是如果你用了图片水印方案又插入了一张非压缩的PNG文件膨胀几乎是必然的。还有一种隐蔽情况创建文本框时POI会自动生成默认字体和样式如果每个文本框都带一份完整的字体定义水印数量到50个以上时drawing1.xml本身就会占不小的空间。优化空间是有的在configTextBox里把不需要的样式节点手动清掉比如effectLst、scene3d这些默认节点文件体积能再压缩一点。但说实话这个优化收益有限除非你的报表sheet特别多或者水印数量上百个否则不用太纠结。5.5 水印文字乱码或字体不一致服务端环境没有安装中文字体时Java2D绘制的图片水印会出现乱码或“豆腐块”。这个问题在使用图片水印方案时尤其常见。解决方式是部署字体文件到服务器或者在代码里指定一个可靠的中文字体路径比如Linux服务器上的/usr/share/fonts/目录。XML文字水印方案里字体是在rPr节点中指定的。如果你不指定中文字体Excel打开时可能用默认字体替代导致不同机器上水印显示效果不一样。最好显式加上字体名rPr.addNewLatin().setTypeface(Microsoft YaHei); rPr.addNewCs().setTypeface(Microsoft YaHei);这样在Windows上表现稳定Mac上如果没装微软雅黑会自动回退到系统中文字体一般也不会乱码。5.6 Spring Boot版本太高导致依赖冲突Spring Boot 3.x默认走的jakarta.servlet路径Apache POI本身不依赖Servlet API一般不会因为Spring Boot版本升级而直接冲突。真正容易出问题的是项目里同时存在多个版本的xmlbeans或commons-compress这是POI的底层依赖。排除低版本、统一到POI 5.2.x对应的依赖版本基本能解决大部分启动异常。如果遇到NoSuchMethodError、ClassNotFound这类问题优先检查是否反复引入了不同版本的poi-ooxml和poi-ooxml-schemas。我实际遇过maven里因传递依赖把xmlbeans降到3.x结果POI 5.x直接跑不起来的案例最后在dependencyManagement里强制固定版本才解决。最后再分享一个小经验文字水印的rowStep和colStep不要追求一个值走天下。我吃过亏的是把步长写死成“6行10列”结果导出5行的表水印文字重叠得没法看后来改成根据sheet.getLastRowNum()动态计算步长才彻底解决。你有类似需求的话建议在工具类里加一个根据数据量自动估算步长的逻辑后面维护起来会省很多事。