前段时间给公司内部的报表系统做导出功能,业务方提了一个很“朴素”的需求:导出的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(List<String> 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 { List<String> 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或者8100000(135度)。
// 旋转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 ResponseEntity<byte[]> 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()); } }文件名的中文编码容易踩坑。直接用filename=file.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()动态计算步长,才彻底解决。你有类似需求的话,建议在工具类里加一个根据数据量自动估算步长的逻辑,后面维护起来会省很多事。