Java 富文本内容生成 PDF 文件落地方案
Java 富文本内容生成 PDF 文件落地方案。采用 Jsoup 进行白名单过滤与 XSS 防护,FreeMarker 渲染静态 HTML 模板,Flying-Saucer 配合 iText 2.x 完成 PDF 生成。核心解决中文字体动态加载嵌入、图片尺寸强制转换(px 转 pt)、表格分页错乱等问题。支持本地与 MinIO 双存储策略,适用于 B 端系统固定版式报告导出需求。

Java 富文本内容生成 PDF 文件落地方案。采用 Jsoup 进行白名单过滤与 XSS 防护,FreeMarker 渲染静态 HTML 模板,Flying-Saucer 配合 iText 2.x 完成 PDF 生成。核心解决中文字体动态加载嵌入、图片尺寸强制转换(px 转 pt)、表格分页错乱等问题。支持本地与 MinIO 双存储策略,适用于 B 端系统固定版式报告导出需求。

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
@TableName("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>" +
"...(省略部分数据)");
至此,我们完成了:
整套代码已在生产环境跑通,直接拷贝即可启动。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 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