【JQUICK-PDF・柱状图】信用报告刚上线,领导指着 PDF 里的乱码问我:为什么柱状图标题全是「口口口」?
官网示例:
- FORMULAS 示例页:https://www.jquick.org/jquick-pdf/#demo-biz16
- github 地址:https://github.com/paohaijiao/jquick-pdf
一、故事引入
上个月,我把征信系统的「客户信用评级分布报告」从快照接口迁移到了 jquick-pdf 渲染。代码很简单,一个柱状图,一个模板绑定:
JOption option = new JOption();
option.title().text("客户信用评级分布(2026-Q3)");
JBar bar = new JBar();
bar.name("评级户数").data(320, 580, 410, 190, 85);
String svg = JChartRendererFactory.renderChart(
JChartType.BAR, option, new JBar[]{
bar},
new JCategoryAxis().data("AAA", "AA", "A", "BBB", "BB"),
new JValueAxis());
JPdfConfig config = new JPdfConfig();
byte[] bytes = new JQuickPdfFactory(config)
.bind("svg", svg)
.executeResource("report/credit_report_01_bar.txt");
本地跑得好好的,一上测试环境,柱状图区域里全是「口口口」。领导把截图甩我脸上:「图表标题全乱码了,评级也全是方块。」
我第一反应是字体没注册。但奇怪的是——模板里的表格中文正常,偏偏图表里的中文乱码。同一个 PDF,一半正常一半乱码,这问题邪门。
我把现场代码原封不动丢给三个 AI,问它们「为什么 SVG 绑定进模板后中文变方块」。三个 AI 的回答高度一致,而且——全都错了。
二、AI 集体翻车
AI-A 说:「这是 PDF 编码问题,把模板文件转成 UTF-8 with BOM 就好了。」
AI-B 说:「executeResource 内部用了系统默认字符集读模板,Windows 上要加 -Dfile.encoding=UTF-8 启动参数。」
AI-C 说:「SVG 字符串里有中文字体名,需要取消 SVG 里的 font-family,让它继承 PDF 默认字体。」
共同点:三个 AI 都在「字符集 / 编码」层面打转,没有一个人提到 渲染字符串型文本时,图表文本走的是哪条渲染管线。
实际上核心问题根本不是编码。SVG 本身就是 UTF-8 字符串,绑定进模板没有任何编码损失。真正的坑在于——jquick-pdf 里 数据文本和图表文本走了两条完全不同的字体解析路径。
三、底层原理拆解
原生裸框架:数据文本 vs SVG 文本,两套逻辑
先看调用链。JQuickPdfFactory.executeResource() 本质上是三件事:
- 从 classpath 读模板文件(
credit_report_01_bar.txt),解析成 DOM 树; - 模板里出现
${svg}占位符时,从 context 变量表取值替换; - 渲染器
JTemplatePdfBoxRenderer把节点逐个交给PdfBoxRenderAdapter,算出最终 PDF 字节流。
关键在第三步。模板里的 <table>、<div> 文本,由渲染器走 JFontConfig + PDF 内嵌字体 逻辑——jquick-pdf 自己内置了中文字体资源,表格文本默认就能渲染中文。
而图表 SVG 不是走这条路的。renderChart() 生成的是 文本形式的 SVG 字符串,里面每一段文字都有自己的 font-family 声明。模板渲染器拿到这段 SVG 后,会调用 SVG 解析管线(底层基于 Batik 一类的 SVG 解析器)把矢量图形画进 PDF。SVG 解析器默认不知道 jquick-pdf 内置的中文字体文件在哪个路径。
一句话核心结论:表格文本由 PDF 渲染器按 JFontConfig 解析,图表文本由 SVG 渲染器按其自身的字体探测逻辑解析——两者字体来源完全独立,注册表格字体救不了图表乱码。
Spring 整合框架:乱码被「碰巧治好」,隐藏更深
Spring 项目里常见的做法是把 JQuickPdfFactory 注册成单例 Bean,process-chart 工具类再包一层。这时候有人发现「我在某个地方调了一次 config.fontConfig().registerFont(...) 图表就不乱码了」——于是网上一堆教程教大家这么干。
这恰恰是环境依赖的陷阱。在 Spring 容器里,全局字体注册发生在 Bean 初始化阶段,恰好抢在第一个 SVG 渲染之前,所以图表正常。但如果你在原生裸环境照抄,或者某个 Bean 初始化顺序变了(字体注册被推迟),乱码立刻回来。
四、纠正误区
逐条拆三个 AI 的说法:
- 「转 UTF-8 BOM」:错。
executeResource读模板和 replace${svg}都是内存字符串操作,不存在编码损失。转 BOM 只会让<pdf>根节点多一个不可见字符。 - 「file.encoding 启动参数」:错。这是 JDK 18 之前的经典坑,但它影响的是
new String(byte[])这种隐式编码转换。绑定 SVG 走的是Map<String,Object>,与系统字符集无关。 - 「去掉 SVG 的 font-family」:方向沾边但结论错。SVG 里原本就有字体声明,删掉声明 SVG 解析器就会用默认字体(通常是不含中文的 Helvetica),乱码依旧。
真正该辨析的 API 差异是:bind() 是纯数据注入,executeResource() 是模板渲染。乱码发生在渲染阶段,跟绑定无关。修编码、修启动参数,都是在错误的层打补丁。
五、延伸隐藏坑
这个坑有三个衍生版本:
- Linux 服务器上「好了」:很多生产环境是 UTF-8 locale,某些 SVG 解析器在不同系统上字体回退行为不同,本地 Windows 复现不了,上线才发现。别用环境差异当遮羞布。
- 数字和英文永远正常:图表里的数字标签走的是 SVG 默认字体,ASCII 字符都有兜底,所以很多报表「只坏中文」,一眼看不出是字体问题还是编码问题。
- Spring 单例工厂的并发残留:
JQuickPdfFactory内部有可变 context(变量绑定表)。Spring 单例共享一个实例时,多线程并发bind()会互相覆盖变量——这是比乱码更严重的线上隐患,后面单独写一篇。
六、推荐正确写法
原生环境,按下面这样写,一次成型:
// 1. 先配置字体,再建工厂
JPdfConfig config = new JPdfConfig();
config.fontConfig().registerFont("custom-mid", "STSongStd-Light",
"/fonts/stsong.ttf"); // 注册图表 SVG 能认到的字体
// 2. 图表:SVG 文本显式指定已注册字体族
JOption option = new JOption();
option.title().text("客户信用评级分布(2026-Q3)")
.fontFamily("custom-mid"); // 关键:让 SVG 文本用注册字体
option.legend().fontFamily("custom-mid");
JBar bar = new JBar();
bar.name("评级户数").data(320, 580, 410, 190, 85);
String svg = JChartRendererFactory.renderChart(
JChartType.BAR, option,
new JBar[]{
bar},
new JCategoryAxis().data("AAA", "AA", "A", "BBB", "BB"),
new JValueAxis());
// 3. 模板仅做数据注入,编码全靠 JVM 内部字符串
JPdfConfig renderConfig = new JPdfConfig();
byte[] bytes = new JQuickPdfFactory(renderConfig)
.bind("svg", svg)
.executeResource("report/credit_report_01_bar.txt");
改造思路就一条:让图表文本的字体解析路径和表格文本保持同源。不要靠「碰巧先把字体注册了」这种隐式时序,要在生成 SVG 之前就把字体显式挂进 option。
七、实战验证方案
别信嘴炮,用证据说话。给 JTemplatePdfBoxRenderer 挂上日志,观察两段文本的字体解析结果:
# 打开 jquick-pdf 渲染日志(logback 或 log4j 配置)
<logger name="com.github.paohaijiao.visitor.render" level="DEBUG"/>
日志里会看到两类记录:
resolveTextFont(text=客户信用评级分布, resolved=STSongStd-Light)—— 表格文本正确命中内置字体;resolveSvgFontFamily(family=Helvetica, glyph=客户, missing=true)—— 图表 SVG 命中默认字体、缺字形。
再看字节码层面:把乱码 PDF 用 PDFBox 打开,PDType0Font 的字形映射表里 客户 的码点对应 .notdef,而正常 PDF 对应真实字形索引。内存地址实证:透传 -Dorg.apache.pdfbox.io.RandomAccessRead 断点,观察字体对象 font.getFontDescriptor() 的 identity 是否与注册实例一致。
八、结尾感悟
这个 bug 让我重新信了一条老话:框架的「默认能用」,从来不是承诺,只是巧合的积累。表格中文能用,不代表图表中文能用——两条渲染管线,两种字体来源,中间隔着一个 SVG 解析器。
把「现象」和「层」对应清楚,比记住一百个修复技巧更值钱。
如果你也被这类「一半正常一半乱码」的报表坑过,或者踩过 jquick-pdf 别的隐性坑,欢迎点赞、在看、转发,评论区聊聊你的翻车现场。
📎 附:完整源码与模板(可直接复现)
完整 Java 测试代码
JOption option = new JOption();
option.title().text("信用评级指标对比").subtext("2020-2023年度(单位:万元)");
option.tooltip().trigger(JTrigger.axis);
JCategoryAxis xAxis = new JCategoryAxis();
xAxis.data("2020年", "2021年", "2022年", "2023年");
option.xAxis(xAxis);
option.yAxis(new JValueAxis());
JBar revenue = new JBar();
revenue.name("营业收入").data(8560, 12340, 18920, 25680);
JBar profit = new JBar();
profit.name("净利润").data(1240, 2150, 3890, 5420);
option.series(revenue, profit);
String svg = JChartRendererFactory.renderChart(JChartType.BAR, option);
File dir = new File(path + "creditreport");
if (!dir.exists()) {
dir.mkdirs();
}
FileOutputStream fileOutputStream = new FileOutputStream(path + "creditreport/01_bar.pdf");
JPdfConfig config = new JPdfConfig();
// 开启 flex 行布局:图表与其右侧的指标说明面板在宽度允许时并排,而不是换行堆叠。
config.getLayoutConfig().setFlexLayout(true);
JQuickPdfFactory factory = new JQuickPdfFactory(config);
factory.bind("svg", svg);
byte[] bytes = factory.executeResource("report/credit_report_01_bar.txt");
fileOutputStream.write(bytes);
fileOutputStream.close();
完整模板文件(txt)
<pdf>
<body>
<div style="textAlignment:center; marginBottom:5px; paddings:16px; background:#3E6B9D; color:white; borderRadius:4px">
<h1 style="textAlignment:center;color:white; marginBottom:8px; fontSize:20; bold:true">'企业信用评级报告'</h1>
<p style="textAlignment:center;color:rgba(255,255,255,0.9); fontSize:11; margin:2px">'报告编号: CR-2023-08975 | 评估日期: 2023年11月15日'</p>
<p style="textAlignment:center;color:rgba(255,255,255,0.9); fontSize:11; margin:2px">'评估机构: 四川省企业信用评估中心'</p>
</div>
<div style="marginBottom:3px;borderRadius:4px;">
<div style="padding:12px">
<h2 style="color:#2c3e50; marginTop:0; marginBottom:8px; fontSize:14; bold:true">'四川省泡海椒科技有限公司'</h2>
<table style="width:600px;verticalAlignment:center; fontSize:10">
<tr>
<td style="backgroundColor:#f8f9fa;padding:5px; textAlign:left;width:150px;">'统一信用代码'</td>
<td style="backgroundColor:#f8f9fa;padding:5px;width:150px;">'91110108MA01XX1234'</td>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left;width:150px;">'成立日期'</td>
<td style="padding:5px; width:150px;">'2018年5月20日'</td>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left;">'注册资本'</td>
<td style="padding:5px;">'1000万元人民币'</td>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left;">'法定代表人'</td>
<td style="padding:5px;">'泡海椒'</td>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left;">'所属行业'</td>
<td style="padding:5px;">'软件和信息技术服务业'</td>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left;">'企业规模'</td>
<td style="padding:5px;">'中型(150-200人)'</td>
</tr>
</table>
</div>
</div>
<div style="margin-bottom:15px">
<h2 style="color:#3498db; borderBottom:1px solid #3498db; paddingBottom:4px; fontSize:13; marginBottom:10px">'历年财务数据(单位:万元)'</h2>
<table style="width:600px; fontSize:10; marginTop:8px">
<tr>
<th style="backgroundColor:#3498db; color:white; padding:6px; textAlign:center; border:1px solid #dee2e6">'财务指标'</th>
<th style="backgroundColor:#3498db; color:white; padding:6px; textAlign:center; border:1px solid #dee2e6">'2020年'</th>
<th style="backgroundColor:#3498db; color:white; padding:6px; textAlign:center; border:1px solid #dee2e6">'2021年'</th>
<th style="backgroundColor:#3498db; color:white; padding:6px; textAlign:center; border:1px solid #dee2e6">'2022年'</th>
<th style="backgroundColor:#3498db; color:white; padding:6px; textAlign:center; border:1px solid #dee2e6">'2023年'</th>
<th style="backgroundColor:#3498db; color:white; padding:6px; textAlign:center; border:1px solid #dee2e6">'增长率'</th>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left; border:1px solid #dee2e6; bold:true">'营业收入'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'8,560'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'12,340'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'18,920'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'25,680'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6; color:#4CAF50; bold:true">'+44.2%'</td>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left; border:1px solid #dee2e6; bold:true">'净利润'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'1,240'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'2,150'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'3,890'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'5,420'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6; color:#4CAF50; bold:true">'+63.8%'</td>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left; border:1px solid #dee2e6; bold:true">'总资产'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'15,800'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'22,450'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'31,200'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'42,800'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6; color:#4CAF50; bold:true">'+39.5%'</td>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left; border:1px solid #dee2e6; bold:true">'净资产'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'9,200'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'13,800'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'19,500'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'26,400'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6; color:#4CAF50; bold:true">'+42.1%'</td>
</tr>
<tr>
<td style="backgroundColor:#f8f9fa; padding:5px; textAlign:left; border:1px solid #dee2e6; bold:true">'毛利率'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'42.5%'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'45.8%'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'48.2%'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6">'51.6%'</td>
<td style="padding:5px; textAlign:right; border:1px solid #dee2e6; color:#4CAF50; bold:true">'+9.1%'</td>
</tr>
</table>
</div>
<div style="margin-bottom:15px">
<h2 style="color:#3498db; border-bottom:1px solid #3498db; padding-bottom:4px; font-size:13px; margin-bottom:10px">'关键经营指标'</h2>
<list style="list-style-type:none; padding-left:0; font-size:11px">
<li style="margin-bottom:8px; padding:10px; background-color:#f8f9fa; border-left:4px solid #4CAF50; border-radius:0 4px 4px 0">
'营收增长率:连续三年保持40%以上增长,2023年达到25,680万元'
</li>
<li style="margin-bottom:8px; padding:10px; background-color:#f8f9fa; border-left:4px solid #2196F3; border-radius:0 4px 4px 0">
'利润率提升:净利率从14.5%提升至21.1%,盈利能力显著增强'
</li>
<li style="margin-bottom:8px; padding:10px; background-color:#f8f9fa; border-left:4px solid #FF9800; border-radius:0 4px 4px 0">
'研发投入:研发费用年均增长38.9%,占营收比例达13.3%'
</li>
<li style="margin-bottom:8px; padding:10px; background-color:#f8f9fa; border-left:4px solid #9C27B0; border-radius:0 4px 4px 0">
'人才结构:技术研发人员占比42%,硕士以上学历占35%'
</li>
<li style="margin-bottom:8px; padding:10px; background-color:#f8f9fa; border-left:4px solid #E91E63; border-radius:0 4px 4px 0">
'客户质量:服务15家上市公司,客户续约率92%,大客户满意度4.8/5.0'
</li>
<li style="margin-bottom:8px; padding:10px; background-color:#f8f9fa; border-left:4px solid #009688; border-radius:0 4px 4px 0">
'资产质量:资产负债率维持在38%左右,流动比率2.8,偿债能力良好'
</li>
</list>
</div>
<div style="margin-bottom:15px">
<h2 style="color:#3498db; border-bottom:1px solid #3498db; padding-bottom:4px; font-size:13px; margin-bottom:10px">'信用评级条形图'</h2>
<div style="display:flex; justify-content:space-between; margin-top:10px">
<svg style="width:340px;height:340px">${svg}</svg>
<div style="width:290px">
<div style="backgroundColor:#f8f9fa; padding:15px; borderRadius:4px">
<h3 style="color:#2c3e50; margin-top:0; fontSize:12; marginBottom:8px">'评级指标说明'</h3>
<list style="list-style-type:none; padding-left:0; font-size:10px">
<li style="margin-bottom:6px; padding:5px; border-left:3px solid #3498db">
<span style="bold:true; color:#2c3e50">'财务状况(90分):' </span>
'营收稳定增长'
</li>
<li style="margin-bottom:6px; padding:5px; border-left:3px solid #2ecc71">
<span style="bold:true; color:#2c3e50">'经营能力(95分):' </span>
'客户留存率高'
</li>
<li style="margin-bottom:6px; padding:5px; border-left:3px solid #e74c3c">
<span style="bold:true; color:#2c3e50">'偿债能力(85分):' </span>
'负债率合理'
</li>
<li style="margin-bottom:6px; padding:5px; border-left:3px solid #f39c12">
<span style="bold:true; color:#2c3e50">'发展潜力(92分):' </span>
'研发投入增加'
</li>
</list>
</div>
</div>
</div>
</div>
<div style="margin-bottom:15px; background:linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); padding:15px; border-radius:4px; border-left:4px solid #2196F3">
<h2 style="color:#1565c0; margin-top:0; font-size:13px; margin-bottom:8px">'综合评价'</h2>
<p style="text-align:justify; margin-bottom:8px; font-size:11px">
'四川省泡海椒科技有限公司作为国家级高新技术企业和专精特新"小巨人"企业,在云计算和大数据领域表现出色。'
'公司财务状况健康,经营能力突出,技术创新能力强,具备良好的发展潜力。信用评级为AAA级,展望稳定。'
</p>
<p style="text-align:right; font-size:8px; color:#d32f2f; margin-top:2px; margin-bottom:0">'认证通过'</p>
</div>
<div style="text-align:center; margin-top:15px; padding-top:12px; border-top:1px solid #dee2e6; color:#6c757d; font-size:9px">
<p>'本报告依据公开信息和专业评估模型生成,仅供参考 | 报告生成时间: 2025年09月06日'</p>
<p>'© 2023 企业信用评估中心 版权所有 | 认证编号: CE-2023-001'</p>
</div>
</body>
</pdf>
真实渲染效果预览
以下为上述代码 + 模板真实运行生成的 PDF 首屏预览(由 jquick-pdf 渲染输出,非示意图):
