做后端开发的朋友早晚会遇到一个需求:把数据报表、统计图表生成PDF,发给客户或者存档。我用iText做过几次这类功能,踩了不少坑,今天把完整的思路、代码和避坑经验整理出来。iText是目前Java生态里最成熟的PDF生成库,配合图表库(比如JFreeChart、XChart)或者用Flying Saucer把HTML/CSS渲染成PDF,能做出非常漂亮的图表型PDF。这篇文章适合需要快速实现报表导出、可视化大屏数据落地的同学,也适合想搞清楚iText底层绘制逻辑的开发者。
图表类PDF和普通文字PDF最大的区别在于:你需要把数据映射成视觉元素,再控制这些元素在PDF页面上的位置和样式。这个过程涉及坐标系、字体、图片清晰度、表格布局等一系列细节,任何一个环节处理不好,生成出来的PDF就会东倒西歪。下面我按照实际项目的推进顺序,从方案选型到代码实现,再到问题排查,完整梳理一遍。
1. 方案选型:为什么是iText,图表该用哪种姿势
1.1 主流Java PDF生成方案对比
先说结论:如果你在Java生态里找PDF生成库,iText是绕不开的选项。目前市面上主流的有iText 5/7(商业版)、Apache PDFBox、OpenPDF,还有基于HTML转PDF的Flying Saucer、wkhtmltopdf、xhtmlrenderer等。很多人一开始纠结选哪个,我的经验是从三个维度判断:对PDF布局的控制粒度、图表的呈现复杂度、以及团队的维护成本。
PDFBox更偏底层,可以读取和编辑PDF,但想要画一个像样的柱状图,你需要自己写一堆坐标计算和绘制代码,工作量很大。OpenPDF是iText 4的社区分支,API老,新特性少,适合纯文本场景。iText的优势在于它有完整的文档对象模型,支持段落、表格、图像、绝对定位绘制,还能做表单填充和数字签名,图表类PDF通常需要这些能力组合。
如果你和我一样,需要快速把业务数据变成带图表、带表格、带复杂排版的PDF,iText 7是当前最推荐的选择。iText 5已经停止功能更新,只维护安全补丁;iText 7重构了架构,API更统一,而且支持SVG、适合与前端图表联动。不过iText 7默认是AGPL协议,商用需要购买商业授权,这点要在项目初期确认好,避免后面惹麻烦。
1.2 图表呈现的三种方式与选型逻辑
做图表型PDF,图表本身怎么画,有三种主流做法,我分别说下适用的场景。
第一种,用Java图表库先生成图片,再插入PDF。最常见的是JFreeChart,生态成熟,柱状图、折线图、饼图都能画,导出成PNG或JPEG后,用iText的Image对象插入。优点是代码简单,图表库帮你处理了坐标轴、图例、数据点,开发速度快;缺点是图片放大了会模糊,而且图表和PDF内的文字、表格风格可能不统一,需要额外调色。
第二种,用iText自带的Canvas API直接绘制矢量图形。iText 7里有PdfCanvas,可以画线、画矩形、画贝塞尔曲线,这意味着你可以自己实现柱状图、折线图、散点图。优点是矢量输出,放大不模糊,且图表样式由代码控制,能和PDF整体风格统一;缺点是代码量大,特别是折线图的自适应坐标、刻度计算、数据标签避让,都要自己写。
第三种,用Flying Saucer(org.xhtmlrenderer)把HTML+CSS渲染成PDF。这种方式特别适合团队里前端资源富余的场景,你只需要写一个HTML模板,用CSS控制图表样式,甚至可以用SVG画图表,然后通过Flying Saucer渲染成PDF。优点是样式控制能力最强,支持丰富的CSS,字体、颜色、圆角、阴影都能用,还能复用Web端的图表设计;缺点是Flying Saucer对CSS标准支持不完整,比如Flex布局、Grid布局基本不支持,只能用Table和绝对定位,SVG的支持也需要额外配置,而且渲染引擎偏老,遇到生僻字、特殊符号时容易出问题。
我的建议是:如果图表简单(柱状图、折线图),用JFreeChart + iText最省事;如果图表复杂且需要高度定制,用iText直接绘制或Flying Saucer渲染HTML;如果已有前端图表组件,且页面上图表特别多,可以考虑Flying Saucer。后面我会把三种方式都给出可运行的示例。
2. 核心细节:字体、坐标、数据映射一个都不能少
2.1 中文字体与生僻字处理
生成PDF最典型的坑就是中文乱码,尤其是生僻字。iText内置的字体(比如Helvetica、Times)不支持中文,必须注册系统字体或字体文件。最稳妥的做法是用思源黑体或者Noto Sans CJK,覆盖字符集广,生僻字基本不丢。如果项目里不允许放太重的字体文件,也可以用Java系统字体,但不同服务器的字体环境不一样,容易在测试环境正常、生产环境乱码。
我处理生僻字的方式是:把字体文件放到classpath下,用PdfFontFactory.createFont注册,同时设置缓存。iText 7里代码类似这样:
PdfFont font = PdfFontFactory.createFont("fonts/NotoSansCJKsc-Regular.otf", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED);这里有个关键点:字体编码必须用IDENTITY_H,支持Unicode全集,否则生僻字会变成问号。如果字体文件太大(思源黑体一个OTF有十几兆),担心PDF体积膨胀,可以用子集化嵌入,iText默认会在保存时只嵌入用到的字形,所以最终PDF体积不会夸张。
还有一类常见问题:flying saucer渲染HTML时,如果HTML里没有指定字体,默认使用iText的字体,很容易丢字。建议在Flying Saucer中注册字体时,强制设置为默认字体,并放在最前面,避免CSS里font-family不匹配。
2.2 图表绘制的基本坐标逻辑
画图表本质上是在二维坐标系里放元素。PDF坐标系的原点在页面左下角,x轴向右,y轴向上,单位是用户空间单位(默认1单位=1/72英寸)。这和我们平时画图用左上角为原点的习惯不同,新手最容易在这里翻车。
比如在A4页面(595 x 842)上画一个柱状图,如果想让图表右下角距离页面右边距50、底边距50,那么绘制的原点就是(545, 50)。柱子的宽度、高度都要在这个坐标系统下计算。如果直接用JFreeChart生成图片,则不需要操心这些,图片插入时可以用scaleToFit控制大小。
如果你打算用PdfCanvas直接绘制,一定要先画一个坐标网格或者在纸上草图,把每个元素的坐标算好。我踩过的坑是:绘制折线图时,数据点坐标计算用了像素值(比如0到800),PDF用户空间单位只有595宽,结果图直接超出页面范围。解决方式很简单:先归一化数据,再映射到绘图区域的宽度和高度。
比如绘图区域宽度为400,纵轴数据范围是0到500,某个数据值为250,对应的像素纵坐标就是250/500*300=150。这样无论数据多大,图都能缩放进指定区域。
2.3 图片清晰度与PDF体积的平衡
用JFreeChart生成图片时,默认生成的PNG分辨率可能只有96 DPI,插入PDF后放大就模糊。要解决清晰度,得在生成图片时设置DPI和实际尺寸。比如目标在PDF里显示宽度为400像素(约5.5英寸),想要300 DPI的清晰度,那图片像素宽度应设为1650,而不是400。
JFreeChart里可以用BufferedImage指定像素尺寸,我一般先计算DPI缩放系数:scale = dpi / 72,显示宽度200,scale=300/72≈4.17,图片宽度就是200*4.17≈834。这样插入PDF后用scaleToFit缩放到200宽度时,实际像素密度为300 DPI,打印也不糊。
但清晰度越高,图片越大,PDF体积也越大。我的经验是:一般报表用150 DPI足够在电脑上清晰显示,如果客户要打印或印刷,再提到300 DPI。JFreeChart生成的图片很大时,考虑用PNG无损格式,不要用JPEG,因为JPEG压缩会在图表文字边缘产生毛刺。iText插入图片时,还可以使用image.setCompressionLevel(9)来控制压缩强度,这是数值越大压缩越高,图片质量会有轻微损失,但PDF体积能下降不少。
3. 实操过程:从数据到图表的完整实现
3.1 环境准备与依赖配置
我用的是Maven项目,JDK 8或者11都行,iText 7.2.x,JFreeChart 1.5.3,Flying Saucer 9.1.20。先放一下POM依赖:
<properties> <itext.version>7.2.5</itext.version> </properties> <dependencies> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext7-core</artifactId> <version>${itext.version}</version> <type>pom</type> </dependency> <dependency> <groupId>org.jfree</groupId> <artifactId>jfreechart</artifactId> <version>1.5.3</version> </dependency> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-pdf-openpdf</artifactId> <version>9.1.20</version> </dependency> </dependencies>这里有个坑:iText 7的itext7-core是个POM依赖,会自动拉取kernel、layout、pdfa等模块,不需要一个个引。flying-saucer-pdf-openpdf依赖的是OpenPDF,它和iText不能同时使用相同的底层库,因为它内部实现不依赖iText。如果你既想用iText生成PDF,又用Flying Saucer渲染HTML,建议把Flying Saucer单独用在独立场景,避免同一进程内两个PDF库冲突。
3.2 使用iText + JFreeChart生成柱状图PDF
最常见的需求是:给出一组月度销售数据,生成柱状图PDF。我用JFreeChart生成图片,再插入iText文档,步骤如下:
创建JFreeChart对象,设置数据源、颜色、字体。注意JFreeChart默认字体不支持中文,必须做全局设置:
// 设置JFreeChart全局字体,避免中文乱码 java.awt.Font font = new java.awt.Font("Noto Sans CJK SC", java.awt.Font.PLAIN, 14); TextTitle title = chart.getTitle(); title.setFont(font); chart.getLegend().setItemFont(font); CategoryPlot plot = chart.getCategoryPlot(); plot.getDomainAxis().setTickLabelFont(font); plot.getRangeAxis().setTickLabelFont(font);然后把Chart对象保存为PNG图片:
// 计算目标DPI和尺寸,widthUnit是PDF上显示的宽度(单位点) int widthPx = (int) (widthUnit * dpi / 72); int heightPx = (int) (heightUnit * dpi / 72); BufferedImage bufferedImage = chart.createBufferedImage(widthPx, heightPx, BufferedImage.TYPE_INT_RGB, null); ImageIO.write(bufferedImage, "png", new File("chart.png"));再在iText中插入图片:
PdfWriter writer = new PdfWriter(new FileOutputStream("report.pdf")); PdfDocument pdfDoc = new PdfDocument(writer); Document document = new Document(pdfDoc, PageSize.A4); document.setMargins(36, 36, 36, 36); Paragraph title = new Paragraph("2025年销售统计报表") .setFont(font) .setFontSize(16) .setTextAlignment(TextAlignment.CENTER); document.add(title); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(bufferedImage, "png", baos); Image chartImage = new Image(ImageDataFactory.create(baos.toByteArray())); chartImage.scaleToFit(widthUnit, heightUnit); document.add(chartImage); document.close();这段代码里,我用ByteArrayOutputStream直接写出图片数据而不是保存临时文件,避免并发生成时文件名冲突。image.setCompressionLevel可以在插入前调用,我用的是默认,如果PDF太大再调高压缩级别。
3.3 使用iText直接绘制折线图
有些场景图表比较简单,为了保持矢量输出和统一风格,我会用PdfCanvas直接画折线图。以下是一个绘制简易折线图的示例,数据为最近7天的访问量,绘图区域从(50, 50)到(350, 250)。
先计算坐标映射:
float plotX0 = 50, plotY0 = 50; float plotWidth = 300, plotHeight = 200; double[] data = {120, 180, 150, 220, 260, 210, 290}; double minValue = 100, maxValue = 300; // 第i个点X坐标均匀分布 for (int i = 0; i < data.length; i++) { float x = plotX0 + (i / (float)(data.length - 1)) * plotWidth; float normalized = (float) ((data[i] - minValue) / (maxValue - minValue)); float y = plotY0 + normalized * plotHeight; if (i == 0) { canvas.moveTo(x, y); } else { canvas.lineTo(x, y); } } canvas.stroke();这里我用了固定的minValue和maxValue,实际项目中要通过数据动态计算,并且留出10%左右的上边距,防止最大值的点贴到边框上。用PdfCanvas画线之前,必须先设置线条颜色和宽度,否则默认是黑色,还看不见。
PdfCanvas canvas = new PdfCanvas(pdfDoc.addNewPage()); canvas.setLineWidth(1.5f); canvas.setStrokeColor(new DeviceRgb(52, 152, 219)); canvas.moveTo(plotX0, plotY0); canvas.lineTo(plotX0 + plotWidth, plotY0); canvas.stroke(); // 画X轴画完坐标轴后,还可以在每个数据点上画一个小圆圈:
canvas.setFillColor(new DeviceRgb(231, 76, 60)); canvas.circle(x, y, 3); canvas.fill();这种方式做出来的图表是真正的矢量,无论PDF放大多少倍都清晰。但相应的,图例、刻度标签、网格线全部要自己画,工作量确实不小。如果图表中包含多个数据系列,我建议还是优先用JFreeChart,别为难自己。
3.4 使用Flying Saucer渲染HTML图表
当团队里前端功底不错,或者图表布局非常复杂时,Flying Saucer方案非常香。我实际项目里就遇到过把前端一个仪表盘页面转成PDF的需求,页面里有SVG折线图、CSS圆角卡片、多列布局,用JFreeChart很难模拟,用Flying Saucer却能比较接近地还原。
基本思路是:把数据渲染成HTML字符串,然后在Java里用ITextRenderer生成PDF。代码骨架如下:
ITextRenderer renderer = new ITextRenderer(); renderer.setDocumentFromString(htmlContent, baseUrl); // 注册字体,防止生僻字显示为方块 renderer.getFontResolver().addFont("fonts/NotoSansCJKsc-Regular.otf", IDENTITY_H, BaseFont.NOT_EMBEDDED); renderer.layout(); renderer.createPDF(outputStream);这里setDocumentFromString的第二个参数baseUrl非常重要,它用来解析HTML中的相对路径图片、CSS引用。如果HTML里有本地图片,需要传入一个合法的文件路径前缀,比如"file:///opt/resources/",否则会报找不到资源。SVG图表在Flying Saucer中默认不直接支持,需要先用Batik将SVG转换成BufferedImage,然后再用base64或临时文件嵌入HTML。或者直接使用JFreeChart生成的PNG图片,用 标签插入。
用Flying Saucer还需要注意CSS支持度。我之前写过一张卡片式布局,用了display:flex,结果渲染出来完全错乱,后来改用float和width: calc(...)才搞定。所以用这个方案前,先检查一下CSS布局是否超出渲染引擎能力,尽量用Table布局和绝对定位。还有,Flying Saucer对@page规则支持不错,可以设置页边距、页眉页脚。
4. 常见问题与排查技巧实录
4.1 中文乱码与字体缺失
生成PDF后,打开看中文全是方块或问号,是最常见的问题。排查步骤:先确认字体是否注册,再看编码是否正确。iText里必须用PdfFontFactory.createFont注册字体,且编码用PdfEncodings.IDENTITY_H。如果用的是JFreeChart,还需要单独设置图表的字体,否则图表上的中文字会变成小方块。生僻字如果字体不支持,最好的办法是更换覆盖范围广的字体,比如Noto Sans CJK,而不是手动拼接字形。
如果用的是Flying Saucer,要注意HTML中的meta标签和字体声明。<meta charset="UTF-8">必须有,否则中文会乱码。字体声明时尽量在CSS里用font-family: 'Noto Sans CJK SC',并且确保在getFontResolver().addFont时使用的字体路径和CSS引用名字一致。
4.2 图片模糊、变形与错位
图片模糊的核心是DPI不够,解决办法是提高图片分辨率,然后缩放显示。变形通常是因为设置图片尺寸时只写了宽度,高度不按比例压缩,或者反过来。iText的scaleToFit可以保持比例缩放,但如果图片本身宽高比和预留空间不一致,还是要先计算好目标尺寸。错位问题大多出在坐标计算上,尤其是使用绝对定位时,忘了PDF坐标原点是左下角,不是左上角。
我习惯在PDF页面上先画一个调试用的矩形框,把预留区域标出来,跑一次看位置,调试完成后注释掉。这个方法在复杂报表布局时特别好用,能快速发现是坐标算错还是边距设置问题。
4.3 版本兼容与内存问题
iText 5升级到iText 7时,API变化很大,比如com.itextpdf.text.Document变成了com.itextpdf.kernel.pdf.PdfDocument+com.itextpdf.layout.Document,很多老教程里的代码直接搬过来会编译报错。建议新项目直接用iText 7,老项目如果升级,做好全量回归测试。
生成大量PDF或包含高清大图时,内存容易飙高。JFreeChart创建BufferedImage时如果是大尺寸(比如2000x2000),会占用几十MB内存。并发场景下需要限制线程数,或者用对象池复用iText的字体和图片实例。另外,图片不要用File存储,尽量用ByteArrayOutputStream,避免临时文件堆积,磁盘IO也会拖慢速度。
如果遇到OOM,优先优化图片尺寸,缩小DPI和像素宽度,这比调JVM参数更有效。还可以在生成完后主动调用image.flush()释放图片的像素缓存。
4.4 生成后PDF的质量控制小技巧
生成完PDF不是扔给客户就完事了,很多事情后处理能显著提升质量。比如PDF歪斜、内容偏移,可能是字体度量不一致导致的行高差异,也可能是表格宽度设置溢出页面。我会在生成后用PDF解析工具(比如PDFBox或者iText自身)读取页面大小,校验内容是否超过页面边界。如果发现内容溢出,优先检查表格列宽总和是否超过了有效页面宽度。
还有个经验:HTML转PDF时会遇到“pdf转曲”的问题,也就是把文字转成路径,防止字体缺失导致显示不一致。如果客户需要印刷,这个非常关键。Flying Saucer本身不支持直接转曲,但可以用iText的PdfCanvas对文本进行描边,或者在后处理工具中做。不过一般电子交付不需要转曲,了解即可。
另外,生成PDF之后最好用PDF阅读器打开预览一遍,确认图表、表格、字体都没有问题再发出去。我吃过一次亏:用Flying Saucer渲染的PDF在本机Adobe Reader看着正常,客户用Foxit打开发现部分图片不显示,后来发现是图片用的base64格式过大,Foxit解析超时。后来改成外部文件引用,避免了这个问题。
我在实际项目中的体会是:图表类PDF的难点不在生成,而在规划。先把数据到视觉的映射想清楚,确认字体、坐标、图片分辨率这些细节,代码实现就只是体力活。希望这篇文章能帮你少走一些我已经踩过的弯路,后面遇到具体问题,欢迎一起交流。