Java 富文本内容生成 PDF 完整落地指南
介绍使用 Java 将富文本内容转换为 PDF 文件的完整方案。技术栈包括 Jsoup 进行 HTML 清洗与 XSS 防护,FreeMarker 渲染模板,Flying-Saucer 结合 iText 生成 PDF。核心流程涵盖富文本标准化、中文字体动态嵌入、图片尺寸适配及分页处理。文中提供了实体类设计、工具类实现代码及常见问题解决方案,如中文乱码、图片断开和样式失效等,适用于 B 端系统报表导出场景。

介绍使用 Java 将富文本内容转换为 PDF 文件的完整方案。技术栈包括 Jsoup 进行 HTML 清洗与 XSS 防护,FreeMarker 渲染模板,Flying-Saucer 结合 iText 生成 PDF。核心流程涵盖富文本标准化、中文字体动态嵌入、图片尺寸适配及分页处理。文中提供了实体类设计、工具类实现代码及常见问题解决方案,如中文乱码、图片断开和样式失效等,适用于 B 端系统报表导出场景。


微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
演示环境:JDK 8 + Spring Boot
B 端系统常见需求:用户在富文本编辑器里撰写月度总结 → 系统一键导出版式固定、中文字体完整、可直接打印的 PDF 文件。 本文给出一个开箱即用的 Java 实现,覆盖:
| 功能 | 组件 | 版本 | 备注 |
|---|---|---|---|
| 富文本过滤 | Jsoup | 1.17.2 | 白名单模式,保留基本样式 |
| 模板引擎 | FreeMarker | 2.3.32 | 纯 Java,不依赖 Web |
| HTML→PDF | Flying-Saucer | 9.1.22 | 基于 iText 2.x,完美支持 CSS 2.1 |
| 字体加载 | iText | 2.1.7 | 支持 TTC/TTF 嵌入 |
| 文件存储 | MinIO / 本地 | 最新 | 通过配置切换 |
src
├── main
│ ├── java/org/example
│ │ ├── entity/
│ │ ├── service/
│ │ └── utils/
│ └── resources/
│ ├── fonts/
│ └── templates/
BrowserReportService → RichTextProcessor → HtmlEscapeUtil → PdfGenerateUtil → Storage 提交富文本报告 JSON → processRichTextContent() 清洗后的 HTML → createEscapedReportForRichText() 仅转义纯文本字段 → generatePdf("monthly_report.ftl", model) byte[] → 上传 / 本地落盘返回 PDF 下载地址
@Data
@Table(name = "monthly_work_report")
public class MonthlyWorkReport implements Serializable {
private Long id;
private String month; // 2024-12
private String creator; // 填报人
private String departmentName;
private String reportTitle; // 自定义标题
// ======== 富文本字段 ========
private String monthlyKeyWorkCompletion;
private String monthlyWorkOverview;
private String nextMonthWorkPlan;
private String reviewIssueCoordination;
// ======== 文件相关 ========
private String fileName;
private String fileUrl;
}
@Component
@Slf4j
public class RichTextProcessor {
public void processRichTextContent(MonthlyWorkReport report) {
report.setMonthlyKeyWorkCompletion(processFieldContent(report.getMonthlyKeyWorkCompletion()));
// ... 其余字段同理
}
private String processFieldContent(String content) {
if (content == null || content.trim().isEmpty()) {
return "<p></p>";
}
// 1. 基本 XHTML 修复
String fixed = fixXhtmlCompatibility(content);
// 2. 白名单过滤
Safelist safelist = Safelist.relaxed()
.addTags("div","span","hr","thead","tbody")
.addAttributes(":all","style","class","colspan","rowspan")
.addAttributes("img","src","alt")
.addProtocols("img","src","data","http","https");
String cleaned = Jsoup.clean(fixed, safelist);
// 3. 图片尺寸限制
cleaned = processImages(cleaned);
// 4. 表格样式
cleaned = processTables(cleaned);
// 5. 样式标准化
return normalizeStyles(cleaned);
}
private String processImages(String html) {
org.jsoup.nodes.Document doc = Jsoup.parse(html);
doc.select("img").forEach(img -> {
img.removeAttr("width").removeAttr("height");
img.attr("style","max-width:400pt !important;max-height:300pt !important;");
if(!img.hasAttr("alt")) img.attr("alt","图片");
});
return doc.body().html();
}
private String fixXhtmlCompatibility(String html) {
return html.replaceAll("<br>","<br/>")
.replaceAll("<hr>","<hr/>")
.replaceAll("<img([^>]+)>","<img$1/>");
}
// ... 其余方法见源码
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title>${rep.reportTitle!"月度工作报告"}</title>
<style>
@page { size: A4; margin: 2.5cm 2cm 2cm 2cm; @bottom-center { content:"第 "counter(page)" 页"; font-family:"SimSun", serif; font-size: 10.5pt; } }
body { font-family:"SimSun","宋体", serif; font-size: 16pt; line-height: 28pt; color: #000; text-align: justify; }
.main-title { font-family:"方正小标宋简", serif; font-size: 22pt; text-align: center; margin: 0 0 40px; }
.section-title { font-family:"SimHei", serif; font-size: 16pt; margin: 35px 0 20px; page-break-after: avoid; }
/* 其余样式略 */
</style>
</head>
<body>
<div class="main-title">${rep.reportTitle}</div>
<div class="basic-info">
<div><strong>报告人:</strong>${rep.creator!"-"}</div>
<div><strong>所在部门:</strong>${rep.departmentName!"-"}</div>
<div><strong>报告月份:</strong>${rep.month!"-"}</div>
</div>
<!-- 一、重点工作清单事项完成情况 -->
<div class="section">
<div class="section-title">一、重点工作清单事项完成情况</div>
<div class="rich-content">
<#attempt> ${rep.monthlyKeyWorkCompletion!}
<#recover>
<div class="plain-text-fallback">暂无内容</div>
</#attempt>
</div>
</div>
<!-- 其余章节同理 -->
</body>
</html>
@Slf4j
@Component
public class PdfGenerateUtil {
@Resource
private Configuration freemarkerConfig;
public byte[] generatePdf(String templateName, Map<String,Object> model) throws Exception {
// 1. 渲染 HTML
Template template = freemarkerConfig.getTemplate(templateName);
String html = FreeMarkerTemplateUtils.processTemplateIntoString(template, model);
// 2. XHTML 兼容修复
html = validateAndFixHtml(html);
// 3. 图片尺寸强制转换(pt 单位)
html = processImageDimensionsForPdf(html);
// 4. 生成 PDF
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ITextRenderer renderer = new ITextRenderer();
configureChineseFonts(renderer);
renderer.setDocumentFromString(html);
renderer.layout();
renderer.createPDF(out);
renderer.finishPDF();
return out.toByteArray();
}
}
private void configureChineseFonts(ITextRenderer renderer) throws Exception {
String[][] fontConfigs = {
{"宋体","fonts/simsun.ttc"},
{"黑体","fonts/simhei.ttf"},
{"楷体_GB2312","fonts/楷体_GB2312.TTF"},
{"仿宋_GB2312","fonts/仿宋_GB2312.TTF"},
{"方正小标宋简体","fonts/方正小标宋简.TTF"}
};
for (String[] fc : fontConfigs) {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(fc[1])) {
if (is == null) { log.warn("字体文件不存在:{}", fc[1]); continue; }
File tmp = createTempFontFile(is, fc[1]);
renderer.getFontResolver().addFont(tmp.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
log.info("成功加载字体:{}", fc[0]);
}
}
}
private File createTempFontFile(InputStream in, String path) throws IOException {
String name = path.substring(path.lastIndexOf('/')+1);
File tmp = File.createTempFile("font_", "_" + name);
tmp.deleteOnExit();
Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
return tmp;
}
private String validateAndFixHtml(String html) {
if (html == null) return "";
org.jsoup.nodes.Document doc = Jsoup.parse(html);
doc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
doc.select("img").forEach(img -> { if (!img.tag().isSelfClosing()) img.tagName("img"); });
return doc.html();
}
private String processImageDimensionsForPdf(String html) {
return html.replaceAll("(max-width:\s*)\d+px","$1400pt")
.replaceAll("(max-height:\s*)\d+px","$1300pt");
}
}
@Component
public class HtmlEscapeUtil {
/**
* 富文本字段不做 HTML 转义,仅对纯文本字段转义
*/
public MonthlyWorkReport createEscapedReportForRichText(MonthlyWorkReport original) {
MonthlyWorkReport escaped = new MonthlyWorkReport();
escaped.setMonth(escapeHtmlContent(original.getMonth()));
escaped.setCreator(escapeHtmlContent(original.getCreator()));
escaped.setDepartmentName(escapeHtmlContent(original.getDepartmentName()));
escaped.setReportTitle(escapeHtmlContent(original.getReportTitle()));
// 以下字段已清洗,不再转义
escaped.setMonthlyKeyWorkCompletion(original.getMonthlyKeyWorkCompletion());
escaped.setMonthlyWorkOverview(original.getMonthyWorkOverview());
escaped.setNextMonthWorkPlan(original.getNextMonthWorkPlan());
escaped.setReviewIssueCoordination(original.getReviewIssueCoordination());
return escaped;
}
private String escapeHtmlContent(String content) {
if (content == null) return "";
return content.replace("&","&")
.replace("<","<")
.replace(">",">")
.replace("\"",""")
.replace("'","'");
}
}
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 中文乱码 | 方框/问号 | 嵌入字体 + 使用 BaseFont.IDENTITY_H |
| 图片断开 | 空白或报错 | 1. 限宽 400pt 2. base64 图片需 data:image/xxx;base64, 前缀 |
| 分页错乱 | 表格/标题截断 | page-break-inside: avoid; |
| 样式失效 | 字体大小异常 | px→pt、CSS 2.1 子集 |
| 图片过大撑破版面 | 横向溢出 | max-width:400pt !important; display:block; margin:auto |
MonthlyWorkReport report = new MonthlyWorkReport();
// 基本信息
report.setMonth("2024-12");
report.setCreator("张三");
report.setCreatorId("user_001");
report.setDepartmentId("dept_001");
report.setDepartmentName("技术管理中心");
report.setReportTitle("2024 年 12 月月度工作报告");
report.setReportType(1); // 1 表示在线编辑
// 模拟前端富文本数据(包含各种样式)
report.setMonthlyKeyWorkCompletion("<p><strong>(一)公司截至 9 月 30 日生产情况</strong></p>"
+"<p class=\"ql-align-justify ql-indent-1\">1.公司收入(验工计价)</p>"
+"<p class=\"ql-align-justify ql-indent-1\">2.分包结算</p>"
+"<p><strong>(二)三季度重点工作清单事项完成情况</strong></p>"
+"<p class=\"ql-indent-1\">技术管理中心待办事项共 5 项,已完成任务 2 项,持续推进 3 项。</p>"
+"<p class=\"ql-indent-1\">1<strong>.</strong>已完成任务总结如下:</p>"
+"<p class=\"ql-indent-1\">\t<strong>序号 6,第二批\"揭榜挂帅\"工作。</strong></p>"
+"<p class=\"ql-indent-1\">\t<strong>完成情况:</strong>组织开展榜挂帅项目揭榜申报,<strong>一是</strong>组织开展报名和申报答辩等各项工作通知书;<strong>二是</strong>开展工作复盘,总结成效和问题,制定下一步优化方案。</p>"
+"<p class=\"ql-indent-1\">\t<strong>序号 37,智项目启动。</strong></p>"
+"<p class=\"ql-indent-1\">\t<strong>完成情况:</strong>\"智\"日召开项目策划会,启动项目建设。</p>"
+"<p class=\"ql-indent-1\">2<strong>.</strong>持续推进中任务进展如下:</p>"
+"<p class=\"ql-indent-1\">\t<strong>序号 8,构建包专家库。</strong></p>"
+"<p class=\"ql-indent-1\">\t<strong>完成情况:</strong>优化完善专家库建设方案,梳理专家入库标准,细化各部门职责分工方案。</p>"
+"<p class=\"ql-indent-1\">\t<strong>序号 9,优化水平。</strong></p>"
+"<p class=\"ql-indent-1\">\t<strong>完成情况:</strong>开发在线工具,统一各部门生产经营数据出口,实现大交班会材料线上呈现。</p>"
+"<p><br></p>"
+"<p><strong>(三)三季度重点工作完成情况</strong></p>"
+"<p class=\"ql-indent-1\"><strong>1.落实科数部工作</strong></p>"
+"<p class=\"ql-indent-1\">\t一是完善中国中铁\"十。\"</p>"
+"<p class=\"ql-indent-1\">\t二是修订信息\"</p>"
+"<p class=\"ql-indent-1\">\t三是支持科数\"</p>"
+"<p class=\"ql-indent-1\"><strong>2.项目管理</strong></p>"
+"<p class=\"ql-indent-1\">\t一是基于股份亿元。\"</p>"
+"<p class=\"ql-indent-1\">\t二是完成项目策划、变更、验收、收入及成本等流程调整。\"</p>"
+"<p class=\"ql-indent-1\">\t三是按照股份公同落实。\"</p>"
+"<p class=\"ql-indent-1\">\t四是组织存在项目材料中编制日期与合同周期不匹配问题。\"</p>"
+"<p class=\"ql-indent-1\"><strong>3.科研管理</strong></p>"
+"<p class=\"ql-indent-1\"><strong>4.信息化管理</strong></p>"
+"<p class=\"ql-indent-1\">\t<strong>(1)信息系统维护</strong></p>"
+"<p class=\"ql-indent-2\">① 完成 OA 系统升级</p>"
+"<p class=\"ql-indent-2\">② 优化数据库性能</p>"
+"<p class=\"ql-indent-1\">\t<strong>(2)网络安全保障</strong></p>"
+"<p class=\"ql-indent-2\">① 开展安全演练</p>"
+"<p class=\"ql-indent-2\">② 漏洞修复与防护</p>");
此处为生成的 PDF 预览图,展示排版效果、字体嵌入情况及分页处理结果。
至此,我们完成了:
整套代码已在生产环境跑通,直接拷贝即可启动。