ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java生成图表PDF全攻略:iText、JFreeChart与Flying Saucer实战

Java生成图表PDF全攻略:iText、JFreeChart与Flying Saucer实战 做后端开发的朋友早晚会遇到一个需求把数据报表、统计图表生成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通常需要这些能力组合。如果你和我一样需要快速把业务数据变成带图表、带表格、带复杂排版的PDFiText 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 Saucerorg.xhtmlrenderer把HTMLCSS渲染成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到800PDF用户空间单位只有595宽结果图直接超出页面范围。解决方式很简单先归一化数据再映射到绘图区域的宽度和高度。比如绘图区域宽度为400纵轴数据范围是0到500某个数据值为250对应的像素纵坐标就是250/500*300150。这样无论数据多大图都能缩放进指定区域。2.3 图片清晰度与PDF体积的平衡用JFreeChart生成图片时默认生成的PNG分辨率可能只有96 DPI插入PDF后放大就模糊。要解决清晰度得在生成图片时设置DPI和实际尺寸。比如目标在PDF里显示宽度为400像素约5.5英寸想要300 DPI的清晰度那图片像素宽度应设为1650而不是400。JFreeChart里可以用BufferedImage指定像素尺寸我一般先计算DPI缩放系数scale dpi / 72显示宽度200scale300/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.xJFreeChart 1.5.3Flying Saucer 9.1.20。先放一下POM依赖properties itext.version7.2.5/itext.version /properties dependencies dependency groupIdcom.itextpdf/groupId artifactIditext7-core/artifactId version${itext.version}/version typepom/type /dependency dependency groupIdorg.jfree/groupId artifactIdjfreechart/artifactId version1.5.3/version /dependency dependency groupIdorg.xhtmlrenderer/groupId artifactIdflying-saucer-pdf-openpdf/artifactId version9.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 charsetUTF-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.PdfDocumentcom.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的难点不在生成而在规划。先把数据到视觉的映射想清楚确认字体、坐标、图片分辨率这些细节代码实现就只是体力活。希望这篇文章能帮你少走一些我已经踩过的弯路后面遇到具体问题欢迎一起交流。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进