1. 整体设计与方案选型
1.1 标题背后到底在解决哪些问题
先说结论:这篇要做的不是把数据画成一个表格那么简单,而是要做出一份"能交付给客户、能打印出来不寒碜"的PDF文档。标题里写得很清楚——绘制PDF表格、模仿Excel表格样式、中文字体、水印、Logo、页眉、页码。拆开看,其实是三个层面的需求叠加在一起。
第一层是表格本身。PDF里的表格和Excel里的表格看起来像,但本质上完全不同。Excel表格是单元格组成的网格,天然有边框、有合并、有行高列宽。PDF里没有"单元格"这个概念,只有文本、线条和矩形。所以我们要用iText的PdfPTable把单元格"画"出来,再通过边框、背景色、对齐方式这些属性去模拟Excel的观感。
第二层是页面级的内容。页眉、页脚、页码、水印、Logo,这些不是表格的一部分,而是附着在整个页面上。iText里最常见的做法是用页面事件(PdfPageEventHelper)在每一页渲染完成后回调,往页面上补写这些固定元素。
第三层是中文环境的坑。iText老版本对中文支持有天然缺陷,不处理字体就是一堆方框。中文字体涉及字体文件加载、编码、嵌入、粗体斜体模拟,这套东西不搞清楚,后面写再多逻辑都白搭。
这三层拆完之后,整个任务就清晰了:先搞定字体,再画表格,再把页面事件挂上去,最后把所有零碎整合成一个可以复用的方法。
1.2 为什么选iText,以及版本选择的几个关键点
Java生态里生成PDF的库主要有那么几个,但到2024年再看,iText依然是做复杂表格场景的首选。它最大的优势是PdfPTable这套表格模型足够成熟,支持跨页、支持重复表头、支持合并单元格,这些刚好是标题里"Excel表格样式"需要的。其他库也能做,但在控制单元格边框、行距、嵌套布局上,iText的API设计更接近底层,反而更容易调出精细的效果。
但这里必须提一个版本选择的问题。iText有两个大版本在使用,旧的是5.x,新的是7.x。网上资料里5.x的代码量非常大,7.x的资料相对少一些,API也改了。我的建议是:如果你的项目是新的,直接上7.x;如果是维护老系统,5.x也不丢人。这篇代码示例用7.x语法,但后面常见问题里我会专门说5.x和7.x的几个典型差异,方便两拨人都能用。
另外一个问题是合规授权。iText早期是LGPL/AGPL双许可,AGPL版本如果用在内部系统不出问题,但对外提供服务就得小心授权风险。同类场景也可以关注开源方案OpenPDF,它fork自iText4时代的分支,API非常像5.x,但功能没有iText7新。简单说,项目允许折腾就用iText7,要完全规避协议问题就用OpenPDF。这篇的核心思路两边都适用,因为PdfPTable的模型是延续下来的。
1.3 前置准备:依赖与基础环境
我用的环境是Java 17 + iText 7.2.x,构建工具是Maven。引入依赖只需要在pom.xml里加两项:
<dependency> <groupId>com.itextpdf</groupId> <artifactId>itext7-core</artifactId> <version>7.2.5</version> <type>pom</type> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>io</artifactId> <version>7.2.5</version> </dependency>字体方面我建议准备一个开源中文字体文件,思源黑体或者文泉驿都可以。文件放在src/main/resources/fonts下面。为什么不用系统字体?因为生成PDF的那台机器不一定装了中文字体,就算装了,字体名在不同系统上也不一样,很容易在Windows上正常、Linux部署后乱码。直接把字体文件打包进项目是最稳的,没有之一。
基础准备其实就这些,接下来进入正题。
2. 中文字体与表格样式是实现PDF"Excel感"的核心
2.1 中文字体的正确打开方式
先说最容易踩坑的部分:中文乱码。PDF里的字体和Word不一样,Word可以把文字当成Unicode字符存储,查看方机器上没这个字体就换一个渲染;PDF更接近"快照",文字要么是嵌入的字体轮廓,要么就用系统里已有的字体去映射。iText如果不显式处理字体,碰到中文就用默认的Helvetica,结果就是方框。
我的做法是把字体文件放到classpath里,然后用PdfFontFactory.createFont()注册:
public static PdfFont getChineseFont() { try { return PdfFontFactory.createFont( "fonts/NotoSansSC-Regular.otf", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED ); } catch (IOException e) { throw new RuntimeException("中文字体加载失败", e); } }这里两个参数要解释清楚。
第一个参数是字体文件路径,因为放在了classpath下,直接写相对路径就行。如果你的字体文件在服务器某个绝对路径下,也可以传绝对路径。
第二个参数IDENTITY_H是编码方式。中文字体必须用Identity-H这种按Unicode字符映射的编码,不能用默认的WinAnsi。这一点和Excel不一样,Excel内部所有文本都是Unicode,但PDF的文字需要明确指定字体和编码,不然就会变成乱码。
第三个参数是嵌入策略。PREFER_EMBEDDED表示尽量把字体文件嵌入到PDF里,这样客户打开PDF不会因为没装这个字体而看到错位。嵌入的代价是文件体积变大,一个标准简体中文字体OTF可能有8~15MB,但真正嵌入时iText会做子集化,只嵌入用到的字符,最终可能只有几百KB,完全能接受。
还有一点容易被忽略:如果业务里要显示加粗效果,不要指望直接设一个BOLD参数就让中文字体变粗。iText对TrueType字体的粗体模拟,本质上是把同一个字体做描边加粗(render mode),效果并不理想。更稳妥的做法是再准备一个粗体字体文件,比如思源黑体的Bold字重,需要加粗的文字单独指定。这个坑我踩过,系统里全是细体,标题看起来完全没有层次感。
2.2 表格的Excel样式怎么调
字体问题解决之后,表格才是重头戏。标题里说的"Excel表格样式"具体到代码层面,就是三个维度:边框、单元格内边距、文本对齐。
先看一个完整的单元格创建方法:
public PdfPCell createCell(String text, int horizontalAlignment, int verticalAlignment, com.itextpdf.kernel.colors.Color backgroundColor) { PdfPCell cell = new PdfPCell(); // 设置文本内容 Paragraph p = new Paragraph(text); p.setFont(chineseFont); p.setFontSize(10f); cell.setPhrase(p); // 边框颜色、宽度 cell.setBorderColor(new DeviceRgb(180, 180, 180)); cell.setBorderWidth(0.5f); // 内边距 cell.setPaddingTop(6f); cell.setPaddingBottom(6f); cell.setPaddingLeft(8f); cell.setPaddingRight(8f); // 对齐 cell.setHorizontalAlignment(horizontalAlignment); cell.setVerticalAlignment(verticalAlignment); // 背景色(用于表头) if (backgroundColor != null) { cell.setBackgroundColor(backgroundColor); } return cell; }这里几个细节值得说一下。
边框颜色为什么是(180,180,180)而不是黑色?因为Excel默认的网格线就是浅灰色的,太黑的边框打印出来会显得特别愣。表头背景一般用(220, 230, 242)这种淡蓝色,这个色值取自Excel自带的"表头"样式。
内边距是很多新手容易忽略的地方。PdfPCell默认的内边距非常小,文字会贴边,看起来特别挤。给上下左右都留一点空间,观感立刻就不一样了,这也最能营造"Excel单元格"的感觉。
对齐方式有两个维度,HorizontalAlignment和VerticalAlignment。iText7里水平对齐可以直接传TextAlignment.CENTER这种枚举,但垂直对齐要传VerticalAlignment.MIDDLE。如果只设水平不设垂直,文字会贴在单元格顶部,和Excel里默认垂直居中的习惯不一样,需要特别注意。
有了这个createCell方法,表头和普通数据行就很好写了:
PdfTable table = new PdfTable(4); // 4列 table.setWidthUnit(UnitValue.createPercentArray(new float[]{10, 30, 25, 35})); // 表头 table.addCell(createCell("序号", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); table.addCell(createCell("项目名称", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); table.addCell(createCell("负责人", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); table.addCell(createCell("完成进度", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); // 数据行 table.addCell(createCell("1", TextAlignment.CENTER, VerticalAlignment.MIDDLE, null)); table.addCell(createCell("门户网站改版", TextAlignment.LEFT, VerticalAlignment.MIDDLE, null)); table.addCell(createCell("张三", TextAlignment.CENTER, VerticalAlignment.MIDDLE, null)); table.addCell(createCell("65%", TextAlignment.RIGHT, VerticalAlignment.MIDDLE, null));表格列宽我用的是百分比方式,好处是表格宽度能自适应页面,不会超出边界。%数组里各项之和是100,每个数值代表该列在表格总宽中的占比。实际操作中比较常见的坑是:列宽全部给了表格,但表格本身没有设置宽度,结果列宽设置不生效,表格直接按所有列等分的默认方式渲染。记得调用table.setWidthUnit()并传入百分比数组,这是关键。
2.3 合并单元格与单元格内多段文本
模拟Excel样式还有一个重要操作:合并单元格。iText7里合并单元格靠的是addCell时传Rowspan和Colspan。场景很典型,比如项目标题横跨整行的汇总单元格:
PdfPCell titleCell = createCell("2024年度项目进度总览", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor); titleCell.setColspan(4); titleCell.setPadding(12f); titleCell.setFontSize(14f); table.addCell(titleCell);再比如某个项目占了多行,可以让"项目名称"这一列合并对应行数:
PdfPCell projectCell = createCell("项目A", TextAlignment.CENTER, VerticalAlignment.MIDDLE, null); projectCell.setRowspan(3); table.addCell(projectCell);合并单元格这个能力是PdfPTable相对普通"画线条"方案的一个巨大优势。如果不用PdfPTable而直接在内容流上drawLine,合并单元格的逻辑你得自己算每一根线条的坐标,完全是自己给自己找麻烦。
单元格里可能出现多段文本,比如一个单元格里第一行是名称,第二行是备注。这时不要直接用一个Paragraph塞进去,而是要用多个Paragraph组合成一个大Paragraph,或者用addElement方法装载多个块:
Paragraph inner = new Paragraph(); inner.add(new Text("项目名称:门户网站改版").setFontSize(10f)); inner.add("\n"); inner.add(new Text("备注:包含前端重构").setFontSize(9f).setFontColor(new DeviceRgb(120, 120, 120))); PdfPCell multiTextCell = new PdfPCell(); multiTextCell.setPhrase(inner);这样单元格里就能呈现出两行不同样式的内容,很像Excel里换行后的观感。
3. 页眉页脚页码与Logo:用页面事件统一处理
3.1 页面事件的机制,以及为什么要用它
表格画完后,还需要在每一页的固定位置加上页眉文字、页脚页码、Logo图片,甚至水印。有些新手会在生成表格内容的过程中手动计算坐标然后setFixedPosition,这种方法的一个核心缺陷是:一旦表格跨多页,内容流会自动分配页面,你没办法确定"当前是第几页"这个信息,后加的内容容易重叠。
iText的PdfPageEventHelper就是为这种场景设计的。核心用法:写一个类继承PdfPageEventHelper,重写onPageEvent方法,这个方法在每一页内容写入完成后自动触发。逻辑是这样——PDF渲染过程中每一页元素发生变动时,事件回调都会告诉我们当前页的上下文,我们可以在这时补充页面级的元素,且不会影响正文内容流。
看一个具体的实现:
public class PdfPageEventHandler extends PdfPageEventHelper { private PdfFont chineseFont; private Image logoImage; public PdfPageEventHandler(PdfFont chineseFont, Image logoImage) { this.chineseFont = chineseFont; this.logoImage = logoImage; } @Override public void onEndPage(PdfDocument pdfDocument, PdfPage page) { // 在页面末尾书写页眉、页脚、水印、Logo addWatermark(page); addHeader(page); addFooter(pdfDocument, page); addLogo(page); } }这里要选对回调事件。onEndPage是所有内容写入完成后触发,适合写页眉页脚,因为此时我们知道总页数;onStartPage是页面刚开始时触发,适合写背景水印,避免水印压盖正文。这两个事件我都用到了,水印放onStartPage,页眉页脚放onEndPage。
回调方法里拿到PdfPage对象后,再加一层PdfCanvas,就可以在页面上定位画文本和图形了:
private void addWatermark(PdfPage page) { PdfCanvas canvas = new PdfCanvas(page); // 保存状态 canvas.saveState(); // 水印文字 Paragraph watermark = new Paragraph("样本数据") .setFont(chineseFont) .setFontSize(72f) .setFontColor(new DeviceRgb(230, 230, 230)); // 用CanvasRenderer把内容画到页面上并旋转45度 new Canvas(canvas, page.getPageSize()) .showTextAligned(watermark, page.getPageSize().getWidth() / 2, page.getPageSize().getHeight() / 2, TextAlignment.CENTER, 45f); // 恢复状态 canvas.restoreState(); }showTextAligned最后一个参数传45f,水印就旋转了45度。这个角度是行业里最常用的水印倾斜角度,既不至于影响阅读,又能全覆盖页面。水印颜色用(230,230,230)浅灰色,放在文字下层,不影响正文识别。字号用72f适合A4页面,如果是A3或者更大纸张可以往上调。
这里插一句为什么水印要在onStartPage里画:如果放在onEndPage里画,水印可能会盖在正文上方,观感太突兀。onStartPage画的内容,正文会接着覆盖上去,效果就是水印沉在底层,要好得多。
3.2 页眉页脚页码的坐标定位
页眉和页脚的位置在页面尺寸坐标体系里有固定坐标。A4纸的PageSize是595.0 x 842.0磅,PDF坐标原点在页面左下角,X轴向右,Y轴向上。也就是说,页脚在页面下方,Y值就在30~50磅之间;页眉在页面上方,Y值在页面高度减去30~50磅的位置。
直接看实现:
private void addHeader(PdfPage page) { PdfCanvas canvas = new PdfCanvas(page); Paragraph header = new Paragraph("某公司内部项目管理文档") .setFont(chineseFont) .setFontSize(10f) .setFontColor(new DeviceRgb(100, 100, 100)); new Canvas(canvas, page.getPageSize()) .showTextAligned(header, page.getPageSize().getWidth() / 2, page.getPageSize().getHeight() - 40, TextAlignment.CENTER, 0f); // 底部分割线 canvas.moveTo(50, page.getPageSize().getHeight() - 55); canvas.lineTo(page.getPageSize().getWidth() - 50, page.getPageSize().getHeight() - 55); canvas.setLineWidth(0.5f); canvas.setColor(new DeviceRgb(180, 180, 180), true); canvas.stroke(); }页眉的分割线是很多商务文档的标配。在内容层画一条浅灰色直线,既区分页眉区域和正文,又不抢内容的风头。这条线不用太粗,0.5磅足够。
页脚就要提到页码了。页码的关键在于:当前页码从1开始显示,总页数从pdfDocument.getNumberOfPages()里拿:
private void addFooter(PdfDocument pdfDocument, PdfPage page) { int pageNum = pdfDocument.getNumberOfPages(); Paragraph footer = new Paragraph("第 " + pageNum + " 页") .setFont(chineseFont) .setFontSize(9f) .setFontColor(new DeviceRgb(140, 140, 140)); new Canvas(new PdfCanvas(page), page.getPageSize()) .showTextAligned(footer, page.getPageSize().getWidth() / 2, 30, TextAlignment.CENTER, 0f); }注意这里用了pdfDocument.getNumberOfPages(),而不是一个自增计数器。两者有细微差别:文档事件在页面创建时就会触发,如果用计数器,第一页触发了两次后页数可能错乱。getNumberOfPages永远是当前文档对象里的真实页数,更可靠。
页眉页脚和页码的核心坑,在于事件回调触发的时机和数据读取方式。我见过不少同事在回调里想拿"最后一页的页码",结果因为触发时机太早,getNumberOfPages返回的数字偏小。遇到这种问题,建议在文档关闭前再遍历页面手动补写一次,或者把页码信息保存在缓存里最后统一刷新,绕开时机问题。
3.3 Logo的添加与位置控制
Logo图片和表格文本走的是两条路。图片要放到页面上,需要先读入成Image对象,再确定位置坐标。我的经验是Logo通常放在页眉的右上角,和页眉文字同一水平线,但靠右排。
private void addLogo(PdfPage page) { if (logoImage == null) { return; } // 控制Logo显示宽度,高度按比例自动计算 float maxWidth = 70f; if (logoImage.getImageWidth() > maxWidth) { float scale = maxWidth / logoImage.getImageWidth(); logoImage.scaleToFit(logoImage.getImageWidth() * scale, logoImage.getImageHeight() * scale); } float x = page.getPageSize().getWidth() - logoImage.getImageWidth() - 50; float y = page.getPageSize().getHeight() - logoImage.getImageHeight() - 30; logoImage.setFixedPosition(x, y); new PdfCanvas(page).addImageFitted(logoImage, x, y, x + logoImage.getImageWidth(), y + logoImage.getImageHeight()); }这段代码需要注意一个问题:不要把logoImage做成静态变量共享,否则多个文档同时生成时会出现位置错乱。最好每个新文档都重新用ImageDataFactory.create()加载一份新的图片对象。图片本身放classpath下,加载方式如下:
ImageData imageData = ImageDataFactory.create("logo.png"); Image logoImage = new Image(imageData);如果发现Logo不显示,很大的可能是图片路径不对。尤其是打包成Jar运行后,classpath内的图片不能直接按"文件路径"打开,要通过getResourceAsStream读取字节流再转成ImageData。用文件路径开发时正常,一部署就出问题,这是部署场景里最常见的坑。
4. 完整实现:从数据到PDF的整合流程
4.1 组装文档与内容流
现在把所有零件组装起来。整个流程大概是这么几步:创建PdfWriter和PdfReader(如果需要模板的话,这里只做新文档,所以只创建writer),创建PdfDocument,创建Document,注册页面事件处理器,写表格内容,最后关闭资源。
看一个完整的调用示例:
public void generateProjectReport(String outputPath, List<ProjectData> dataList) throws IOException { // 1. 创建PdfWriter和PdfDocument PdfWriter writer = new PdfWriter(outputPath); PdfDocument pdfDocument = new PdfDocument(writer); // 2. 创建Document,设置页面边距 Document document = new Document(pdfDocument); document.setMargins(70f, 50f, 60f, 50f); // 上边距70磅,给页眉留空间;下边距60磅给页脚留空间 // 3. 加载字体和Logo PdfFont chineseFont = getChineseFont(); ImageData imageData = ImageDataFactory.create("logo.png"); Image logoImage = new Image(imageData); // 4. 注册页面事件处理器 PdfPageEventHandler eventHandler = new PdfPageEventHandler(chineseFont, logoImage); pdfDocument.addEventHandler(PdfDocumentEvent.END_PAGE, eventHandler); // 5. 开始写内容 PdfTable table = new PdfTable(4); table.setWidthUnit(UnitValue.createPercentArray(new float[]{8, 32, 22, 38})); // 表头 table.addCell(createCell("序号", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); table.addCell(createCell("项目名称", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); table.addCell(createCell("负责人", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); table.addCell(createCell("完成进度", TextAlignment.CENTER, VerticalAlignment.MIDDLE, headerBgColor)); // 数据 int index = 1; for (ProjectData data : dataList) { table.addCell(createCell(String.valueOf(index++), TextAlignment.CENTER, VerticalAlignment.MIDDLE, null)); table.addCell(createCell(data.getName(), TextAlignment.LEFT, VerticalAlignment.MIDDLE, null)); table.addCell(createCell(data.getOwner(), TextAlignment.CENTER, VerticalAlignment.MIDDLE, null)); table.addCell(createCell(data.getProgress(), TextAlignment.RIGHT, VerticalAlignment.MIDDLE, null)); } // 6. 把表格添加到文档 document.add(table); // 7. 关闭 document.close(); }这里面要注意一个细节:Document和PdfDocument的关闭顺序。PdfDocument的关闭会触发所有挂载事件的最后处理,而Document.close()内部会调用PdfDocument.close(),所以只要手动调一次document.close()就好,不要写完再手动调pdfDocument.close(),会造成事件处理器被调用两遍,页面重复叠加页脚水印。
4.2 表头跨页重复:一个被问爆的问题
数据多的时候表格会自动跨页,但默认情况下,第一页的表头到了第二页不会重复出现,整张表读起来非常费劲。iText早就考虑到了这一点,只提供一行setHeaderRows配置:
table.setHeaderRows(1);这一行的意思是:表格前1行会被标记为"表头",在跨页时自动在每一页顶部重复输出。这段话放在addCell之前设置,或者放在表格创建之后、add进文档之前设置都行。
如果表头是合并单元格定义的,或者表头有多行(比如一个总标题行加一个字段名行),就传2。这个数值表示表格前N行为表头,跨页时都会重复。
实际操作中这个功能还有个小彩蛋:如果设置了表头行,iText7里跨页的那一页顶部,不会自动添加任何标记说明"表格来自上一页"。如果你的业务需要在跨页区域加一个"续上页"提示,当前事件回调里就需要自己判断当前页是否有表格内容,这个判断逻辑要结合浮动窗口位置做,复杂度会明显上升。这种场景出现了再单独处理,没有需求就不用提前加。
4.3 分页与内容溢出的边界处理
表格跨页还牵扯到一个容易忽略的问题:行分裂。默认情况下,PdfPCell在跨页时会从页面底部被截断,剩余部分到下一页继续。这样看起来就是单元格内容被"腰斩"了,观感很差。
要禁止单元格跨行分裂,在iText7里可以这样设置:
cell.setKeepTogether(true);如果一整行都不希望被拆散,设置单元格后可以在表格上做统一处理:
table.setKeepTogether(true);不过这个特性有性能影响,表格特别大的时候建议只对特殊行设置keepTogether,不要对整张表设置。
页面边距和表格宽度的关系也要协调。如果页面边距左右都设了50磅,A4宽度595磅,可用宽度就是495磅。列宽百分比按照那个宽度来分配,不会溢出。但如果页面边距和列宽比例算得不好,容易出现表格整体太宽被裁切的现象。检查方法很简单:第一次生成PDF时肉眼确认一下最右侧列的内容是否完整,尤其是设置了右对齐的金额、百分比列。
5. 常见问题与排查技巧实录
5.1 中文全部变成方框
出现这种问题,几乎可以锁定在字体编码或字体嵌入上。最常见的原因是createFont时用了PdfEncodings.WINANSI或者干脆没写编码参数,iText就会用默认的编码解释中文字符,结果全是乱码。编码参数写PdfEncodings.IDENTITY_H基本可以解决。
还有一个场景是字体文件本身包含的字形有问题。有些从网上下载的字体文件只包含西文字形,或者简繁字形不全,这时即使编码对了也会显示成方框。解决方法是换一个完整的开源中文字体(思源黑体、文泉驿正黑),这类字体字符集覆盖全,遇到生僻字也能正常显示。
排查时可以用一个最简单的方法测试:写一行"中文测试,ABC123"直接输出到PDF,如果只有中文是方框而字母数字正常,就说明字体文件或编码有问题;如果全部是方框,可能是文字根本没渲染到内容流里。
5.2 设置列宽不生效,表格总是等宽分布
这是PdfPTable使用频率极高的一个陷阱。表格创建后只addCell不setWidthUnit,iText会把所有列按等宽处理。设了setWidthUnit但值为null,或者传入的数组长度和列数不一致,同样会导致不生效。
正确的写法是这样的:
PdfTable table = new PdfTable(4); table.setWidthUnit(UnitValue.createPercentArray(new float[]{10, 30, 25, 35}));三个参数值得注意:一是列数必须和数组长度一致,二是百分比总和最好是100,三是设置时机最好在addCell之前。如果把表格已经添加进文档再改列宽,就晚了。
还有一种情况是设置了表格宽度为某个固定磅值,但列宽百分比和表格宽度冲突。我建议新建表格后不要反复设置宽度,要么用百分比的方式设置列宽并把表格宽度交给系统算,要么就手动指定总宽度并配固定列宽,混用两种方式容易得到奇怪的结果。
5.3 图片加载报IOException: Cannot read JPEG
常见的原因是JDK自带imageio对某些JPEG格式兼容性不足,特别是CMYK色彩空间的JPEG,在老版本JDK上会出现读取失败。改成PNG格式基本能绕过去,这是最快的解决方法。
如果业务硬性要求用JPEG,可以在服务启动时检查图片格式趋向,提前做转换。推荐的做法是统一使用PNG,至少PDF里用的Logo、水印图片都转成PNG,省得后续遇到各种编码兼容问题。
5.4 页眉页脚重复出现多份
现象是每一页上页眉和页脚重叠了两份。原因通常是同一个事件处理器实例添加了多次,比如在循环里反复new出来了PdfPageEventHandler注册到了同一个PdfDocument,或者手动调用了document.close()后又调用了pdfDocument.close(),事件处理器在关闭时又被触发了一遍。
排查时看代码结构:addEventHandler只该出现一次;关闭时只调用document.close()。如果用了模板块,还要检查模板是否内部有自己的事件队列。
注意:iText7的PdfDocumentEvent.END_PAGE对应5.x的onEndPage,如果你在5.x里用onEndPage写事件处理器,7.x里记得改。7.x的事件模型是addEventHandler + PdfDocumentEvent枚举,5.x是直接继承PdfPageEventHelper重写onEndPage方法。两套写法别混用,否则事件不会触发或者重复触发。
5.5 中文字体文件太大,导致PDF体积暴涨
字体内嵌之后PDF体积暴涨到十几MB,一般是因为字体子集化没有生效。iText7默认是按需嵌入字符子集,但如果代码里加载的是同一个字体对象并在多处使用,有时候iText会保守地把整个字体全嵌入。这种情况可以尝试每次创建不同字体对象,或者显式调用字体的subset化方法。
更实际的做法是:确认自己用的字体文件是否真的包含了数千个字符。如果用一个全字库的宋体字体文件,嵌入的子集也会偏大。对绝大多数业务文档来说,思源黑体Regular + Bold两个字重足够,子集化后PDF体积一般在300KB~1MB之间,属于正常范围。
5.6 表格跨页后表头下方出现了一条多余的空行
这种问题有个隐蔽来源:表头单元格里设置了过大的paddingBottom,跨页后iText把表头渲染出来后,padding区域被算成了"高度延伸到下一页",于是下一页顶部出现空白。
排查方法是把表头单元格的padding调小,比如上下都设为4f~6f。如果表头文字本身篇幅较大,也尽量控制在单行高度内,这样跨页表头就不会带来额外的间距问题。
6. 我在实际项目中的两个经验补充
写到这里,把整套方案串联起来,基本可以交付一个能用的报表导出了。最后补充两个我自己的实操体会,这两个都是常规资料里不太会写、但实际问题中很常见的情况。
第一,关于字体资源的复用。我原来是每个文档都加载一次字体文件,后来发现高并发场景下,每次创建PdfFont都要重新解析字体文件,浪费IO和CPU。现在的做法是在服务启动时把字体字节流缓存到内存里,用的时候直接PdfFontFactory.createFont(byte[])创建。这样单个文档生成速度有明显提升,尤其是导出量大的接口,实测下来性能提升很明显。
第二,关于"Excel表格样式"的还原度。纯粹靠PdfPTable做出来的表格,和Excel原生的网状表格还是有细节差异的,比如Excel单元格之间的边框是共享的,而PdfPTable每个单元格独立画边框,两条相邻边框叠加起来会比预期稍宽。如果对还原度有执念,可以设置表格的horizontalBorderSpacing和verticalBorderSpacing为负数来收窄边框间距,简单说一句:iText7里通过table.setBorderSpacing(-0.5f)可以得到更紧凑的网格效果,具体值取决于你的边框宽度,需要自己微调。
这篇做的是一个能处理中文字体、页眉页脚、水印Logo、跨页表头的完整PDF表格方案,代码可以直接抄了改着用。如果你在实际项目里遇到这篇没覆盖到的问题,欢迎在评论区把你的具体报错发出来,我结合自己经验帮你排查。