news 2026/9/12 6:59:13

FreeMarker+OpenHTMLtoPDF构建高可靠Java PDF生成系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FreeMarker+OpenHTMLtoPDF构建高可靠Java PDF生成系统

1. 项目概述:为什么用 FreeMarker + OpenHTMLtoPDF 做 PDF 生成这件事,比你想象中更值得深挖

FreeMarker 和 OpenHTMLtoPDF 这组技术组合,在 Java Web 开发里属于“不声不响但天天在用”的典型——它不 flashy,不带 AI 标签,也不上热搜,可一旦你负责过合同生成、发票导出、报表归档、电子回单、考试成绩单批量输出这类业务,就会发现:90% 的 PDF 生成需求,根本绕不开它。我做过 7 年企业级文档系统开发,从银行对账单到政务审批存证,从教育平台的结业证书到物流公司的运单模板,全靠这套组合拳撑住日均 30 万+ PDF 的稳定产出。它不是最炫的方案,但它是经过真实高并发、多字体、复杂表格、中文水印、页眉页脚、分页断行等场景千锤百炼出来的“工业级稳态方案”。

标题里那个看似轻描淡写的 “Demo”,其实是整套技术落地的最小可行验证点——它不等于“玩具代码”,而是把 FreeMarker 模板引擎的动态渲染能力,和 OpenHTMLtoPDF 对 HTML/CSS 的精准 PDF 渲染能力,拧成一股绳的关键接口。很多人卡在第一步:为什么不用 iText7 直接画?为什么不用 Flying Saucer?为什么非得加一层 FreeMarker?答案不在文档里,而在实际踩坑现场:iText7 写代码太重,改个页眉要重写三段布局逻辑;Flying Saucer 对 CSS 支持残缺,flex 布局一上就错位,中文换行经常劈开字;而纯 HTML 转 PDF,又没法动态插数据。FreeMarker 正好补上这个缺口——它让模板像写网页一样自然,变量、循环、条件判断全都有,再喂给 OpenHTMLtoPDF 渲染,就成了“所见即所得 + 动态数据驱动”的黄金搭档。

这个 Demo 的价值,远不止于“能跑出来一个 PDF”。它背后是一整套文档生成工程化的方法论:模板如何分层(基础样式库 / 业务模板 / 数据适配层)、字体怎么嵌入才不乱码、表格跨页怎么不断行、页码怎么自动续编、A4 尺寸下 margin 和 padding 的毫米级校准、甚至 PDF 元信息(Title/Author/Creator)怎么写进元数据里——这些细节,全藏在 Demo 的每一行配置、每一个 CSS class、每一次Configuration初始化里。我见过太多团队,因为没吃透这个 Demo 的底层逻辑,上线后遇到“中文显示方块”、“表格被截断”、“页眉重复两次”、“生成速度从 200ms 慢到 2s”,最后推倒重来。所以今天这篇,不讲概念,不列 API,就带你把这行代码背后的每一块砖都拆开、擦亮、重新垒一遍。

2. 技术选型深度拆解:为什么是 FreeMarker + OpenHTMLtoPDF,而不是其他组合?

2.1 FreeMarker:不是“另一个模板引擎”,而是“文档生成的语义中枢”

很多人把 FreeMarker 当成 Thymeleaf 或 Velocity 的平替,这是最大误区。FreeMarker 的核心优势,从来不是语法糖多漂亮,而是它为结构化文档生成做了深度优化。它的设计哲学是:“模板即契约”——业务方提供 Word 或 PDF 样式稿,前端切出 HTML 结构,后端工程师只负责把 Java Bean 映射进去,中间不掺杂任何布局逻辑。这种分离,直接决定了后期维护成本。

举个真实例子:某省社保系统要生成参保凭证,要求每页顶部固定单位 LOGO + 文号,中间是个人基本信息表,底部是“本凭证仅作参考,不作为法律依据”灰色小字。用 iText7 实现,你要写:

Paragraph header = new Paragraph("XX省人力资源和社会保障厅"); header.setTextAlignment(TextAlignment.CENTER); document.add(header); // 然后手动计算 logo 图片位置、设置缩放、插入 ImageData...

而 FreeMarker 模板里,就是一行:

<div class="header"> <img src="${base}/images/logo.png" alt="LOGO" width="120"/> <p class="doc-number">${data.docNumber!}</p> </div>

FreeMarker 不关心图片路径怎么解析,它只做一件事:把data.docNumber这个字符串,原封不动塞进${}里。真正的路径解析、资源加载、缓存策略,由 Spring 的ServletContextTemplateLoader或自定义TemplateLoader控制。这种“职责锁死”,让模板修改和 Java 代码修改完全解耦——运营要改文号格式,改 HTML 就行;法务要加免责声明,加一行<p class="disclaimer">...</p>就完事,不用动半行 Java。

提示:FreeMarker 的!操作符(如${data.name!})不是可有可无的语法糖。它代表“默认空值处理”,在 PDF 生成场景里至关重要。比如data.phone可能为 null,若不加!,整个模板渲染会抛NullPointerException,PDF 生成直接失败。而${data.phone!'-'}会安全输出-,保证文档结构不崩。这是生产环境必须加的“安全阀”。

2.2 OpenHTMLtoPDF:不是“HTML 转 PDF 工具”,而是“CSS 渲染引擎的 PDF 重定向器”

OpenHTMLtoPDF 的本质,是把 Flying Saucer(XHTMLRenderer)的渲染内核,用现代 Java 重构并大幅增强后的产物。它不自己解析 HTML,而是复用标准 DOM 解析器;它不自己实现 CSS 引擎,而是深度集成 CSS 2.1 规范,并对 CSS 3 的部分特性(如@page,break-inside,transform)做了生产级支持。这意味着:你写的 CSS,只要浏览器能认,OpenHTMLtoPDF 大概率也能认——前提是,你得知道它认到什么程度。

我们实测过主流 CSS 特性兼容性(基于 OpenHTMLtoPDF 1.0.10 + Java 11):

CSS 特性支持度关键说明生产建议
@page { size: A4; margin: 2cm; }★★★★★完全支持,可精确控制纸张尺寸与页边距必用,避免默认 1in 边距导致内容被裁
break-inside: avoid;★★★★☆表格行、div 块级元素内避免分页表格跨页必备,否则出现“表头在第一页,数据在第二页”的灾难
flex布局★★☆☆☆仅支持基础display: flex+flex-direction: column/rowflex-wrapgap不支持避免使用,改用floatinline-block+text-align
@font-face字体嵌入★★★★★支持src: url(...)加载本地 ttf/otf,且自动子集化(只嵌入文档用到的字形)中文必备,解决宋体/黑体乱码核心问题
position: fixed★☆☆☆☆仅支持@page内的@top-center等伪元素,不支持普通元素页眉页脚必须用@page :first { @top-center { ... } }方式

这个表格不是随便列的。去年我们给某法院做裁判文书生成系统,就栽在flex-wrap上——前端用 flex 布局做当事人信息栏,测试环境 Chrome 显示完美,一上 OpenHTMLtoPDF 就全部挤成一行。最后硬是改成display: inline-block+vertical-align: top+width: calc(50% - 8px)才搞定。所以选 OpenHTMLtoPDF,不是因为它“能转”,而是因为它“能稳转”,且错误行为可预测、可规避。

2.3 为什么坚决不选 iText7 直接编码?

iText7 是 PDF 生成领域的“瑞士军刀”,功能强大到令人窒息。但它的问题,恰恰出在太强大:它让你从零开始构建 PDF 的每一个原子——字体、颜色、坐标、路径、流对象。这对简单文本还行,一旦涉及复杂布局,代价巨大。

我们做过对比实验:生成一份含 5 列 20 行数据的统计报表(含合计行、隔行变色、列宽自适应、页眉页脚),三种方案耗时与代码量:

方案Java 代码行数平均生成时间(单次)维护难度中文支持成本
iText7 纯编码482 行186ms★★★★★(每次改样式都要重算坐标)高(需手动注册字体、处理 Unicode)
Thymeleaf + Flying Saucer127 行模板 + 89 行 Java320ms★★★☆☆(Thymeleaf 表达式较重)中(Flying Saucer 对中文支持不稳定)
FreeMarker + OpenHTMLtoPDF63 行模板 + 41 行 Java112ms★★☆☆☆(改 HTML/CSS 即可)低(@font-face一行解决)

关键差异在“可预测性”。iText7 里cell.setPaddingTop(5f)的 5f 是什么单位?是点(pt)?是毫米(mm)?是像素(px)?文档没说清,只能试。而 OpenHTMLtoPDF 里padding-top: 5px就是 5 像素,按 CSS 规范走,前端工程师一眼就懂。这种认知一致性,直接降低 60% 的协作沟通成本。

2.4 为什么不用浏览器 Headless 模式(Puppeteer/Playwright)?

Headless 浏览器方案近年很火,尤其适合需要 JS 渲染的复杂页面。但它在 PDF 生成场景有三个硬伤:

  1. 资源开销不可控:每个 PDF 生成请求,都启动一个 Chromium 实例(内存占用 150MB+),并发 50 请求,就是 7.5GB 内存。而 OpenHTMLtoPDF 单线程处理,100 并发内存增长不到 200MB。
  2. 启动延迟高:首次生成 PDF,Puppeteer 需加载整个浏览器内核,冷启动 800ms+;OpenHTMLtoPDF 加载模板后,首字节响应 < 50ms。
  3. 页眉页脚不一致:Chrome 的@media print和 PDF 导出的@page行为有细微差异,比如margin-top在打印预览里生效,导出 PDF 时却失效——这种 bug 查三天都不一定定位到。

我们曾用 Puppeteer 生成电子发票,结果发现:当发票金额超过 10 位数字时,Chrome 自动启用科学计数法显示(1.23456789e+10),而业务方要求必须显示完整数字。修复方案是加font-variant-numeric: tabular-nums,但这个 CSS 属性在旧版 Chromium 里不支持……最后还是切回 OpenHTMLtoPDF,用white-space: nowrap+overflow: hidden强制不换行,一劳永逸。

3. 核心细节解析:从 Demo 到生产级 PDF 的 7 个关键跃迁点

3.1 模板层:FreeMarker 模板不是 HTML,而是“可执行的文档契约”

一个合格的 FreeMarker PDF 模板,必须满足三个硬性条件:语义清晰、样式内聚、数据隔离。很多人直接拿网页 HTML 改,结果生成 PDF 时各种错位、字体丢失、空白页——根源在于没理解模板的“契约属性”。

看一个反面案例(常见错误):

<!-- 错误示范:混用语义与样式 --> <div style="font-family: 'SimSun'; font-size: 14px; color: #333;"> <h2>用户信息</h2> <p>姓名:<span style="color: #000;">${user.name!}</span></p> <p>手机号:<span style="color: #000;">${user.phone!}</span></p> </div>

问题在哪?

  • style内联写死字体,无法统一管理;若要换微软雅黑,得全局搜索替换;
  • h2标签语义是“二级标题”,但 PDF 里它可能被渲染成过大字号,破坏版式节奏;
  • span嵌套过深,OpenHTMLtoPDF 解析 DOM 时易产生额外空白节点,导致行高异常。

正确写法(契约式模板):

<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="${base}/css/pdf-base.css"> </head> <body> <div class="document"> <header class="doc-header"> <h1 class="doc-title">用户信息凭证</h1> <p class="doc-date">${.now?string("yyyy年MM月dd日")}</p> </header> <main class="doc-content"> <section class="user-info"> <h2 class="section-title">基本信息</h2> <dl class="info-list"> <dt>姓名</dt> <dd class="info-value">${user.name!}</dd> <dt>手机号</dt> <dd class="info-value">${user.phone!}</dd> </dl> </section> </main> <footer class="doc-footer"> <p class="disclaimer">本凭证由系统自动生成,仅供参考。</p> </footer> </div> </body> </html>

注意:${.now?string("yyyy年MM月dd日")}是 FreeMarker 内置日期格式化,无需 Java 层传入new Date().now是 FreeMarker 的内置变量,代表当前时间,?string是内建函数,比手写SimpleDateFormat更安全(无线程安全问题)。

这个模板的“契约感”体现在:

  • 所有样式通过class控制,pdf-base.css文件集中管理字体、行高、间距;
  • dl/dt/dd语义化标签替代p+span,OpenHTMLtoPDF 对定义列表的渲染更稳定;
  • header/main/footer结构明确划分区域,方便后续用 CSS@page精确控制各区域位置。

3.2 字体嵌入:解决中文乱码的唯一正解,不是“加个字体路径”那么简单

OpenHTMLtoPDF 默认只支持 Java 内置字体(如Serif,SansSerif),这些字体在 Windows/Linux/macOS 上映射不同,且不包含中文字形。直接跑 Demo,中文必成方块。解决方案是@font-face嵌入 + 字体子集化,但操作有陷阱。

第一步:准备字体文件
必须用.ttf.otf格式(.woff/.woff2不支持)。推荐思源黑体(Noto Sans CJK SC)或霞鹜文楷(LXGW WenKai),免费可商用。注意:不要用 Windows 自带simhei.ttf,它版权不明,且字形缺失严重。

第二步:CSS 中声明字体

/* pdf-base.css */ @font-face { font-family: "SourceHanSansSC"; src: url("file:///opt/fonts/NotoSansCJKsc-Regular.ttc") format("truetype"); font-weight: normal; font-style: normal; } @font-face { font-family: "SourceHanSansSC"; src: url("file:///opt/fonts/NotoSansCJKsc-Bold.ttc") format("truetype"); font-weight: bold; font-style: normal; }

关键点:

  • url()必须是绝对路径(file:///协议),相对路径url("./fonts/xxx.ttf")会失败;
  • format("truetype")不能写成"ttf""opentype",OpenHTMLtoPDF 只认"truetype"
  • 同一字体族名("SourceHanSansSC")下,必须分开声明normalbold,否则加粗失效。

第三步:全局应用字体

* { font-family: "SourceHanSansSC", "SimSun", sans-serif; font-size: 12px; line-height: 1.5; }

这里有个隐藏技巧:font-family列表末尾保留"SimSun"作为降级字体。当 OpenHTMLtoPDF 因权限问题读不到 ttc 文件时,至少能 fallback 到系统宋体,不至于全屏方块。

第四步:强制子集化(防字体包爆炸)
OpenHTMLtoPDF 默认嵌入整个字体文件(10MB+),导致 PDF 体积飙升。必须开启子集化:

PdfRendererBuilder builder = new PdfRendererBuilder(); builder.useFontSubstitution(true); // 启用字体替换 builder.useFontEmbedding(true); // 启用字体嵌入 // 关键:指定只嵌入文档实际用到的字形 builder.useFontSubset(true); // 必须设为 true

实测:一份含 200 个中文字符的 PDF,未子集化体积 12.3MB,开启子集化后仅 387KB,且渲染速度提升 40%。

3.3 分页控制:让表格、章节不被“腰斩”的 CSS 黑科技

PDF 最让人头疼的,就是表格跨页时表头消失、长段落被硬生生劈成两半。OpenHTMLtoPDF 的分页引擎基于 CSS Paged Media 规范,但支持有限,必须用“组合拳”。

方案一:表格跨页保表头

.table-container { break-inside: avoid; /* 容器内不许分页 */ } table { page-break-inside: avoid; /* 表格整体不许分页 */ width: 100%; } thead { display: table-header-group; /* 关键!让 thead 在每页重复 */ }

display: table-header-group是 OpenHTMLtoPDF 对thead的特殊支持,它会让表头在每页顶部自动重现。但注意:必须配合page-break-inside: avoid,否则表头可能单独占一页。

方案二:章节不被劈开

.section { break-before: always; /* 新章节强制另起一页 */ break-after: avoid; /* 章节结束后不许分页 */ } .section-title { break-after: avoid; /* 标题后不许分页,确保标题和内容同页 */ }

break-before/break-after是 CSS 3 Paged Media 的标准属性,OpenHTMLtoPDF 支持良好。always表示强制分页,avoid表示尽量不分页。

方案三:长段落防劈字

p, li { orphans: 3; /* 孤行控制:段落末尾至少留 3 行 */ widows: 3; /* 寡行控制:段落开头至少留 3 行 */ word-break: keep-all; /* 中文不换行,防字被劈开 */ }

orphans/widows是印刷排版术语,指段落首尾孤立的行。设为 3,意味着 OpenHTMLtoPDF 会主动调整分页点,避免出现“一段话只剩一行在页底,其余在下页”的尴尬。

3.4 页眉页脚:用@page伪类实现专业级文档头尾

OpenHTMLtoPDF 对@page的支持非常成熟,这是实现页眉页脚的唯一推荐方式。别信网上那些用position: fixed的 hack,它在 PDF 里根本不可靠。

标准写法:

@page { size: A4; margin: 2cm; @top-center { content: "XX公司 - 用户凭证"; font-family: "SourceHanSansSC"; font-size: 10px; color: #666; } @bottom-center { content: "第 " counter(page) " 页,共 " counter(pages) " 页"; font-family: "SourceHanSansSC"; font-size: 10px; color: #666; } } @page :first { @top-center { content: none; /* 首页不显示页眉 */ } @bottom-center { content: "XX公司 版权所有"; /* 首页页脚不同 */ } }

counter(page)counter(pages)是 CSS 计数器,OpenHTMLtoPDF 完全支持。@page :first专门针对首页定制,比如首页加 LOGO、去页眉、换页脚文案。

注意:@top-center里的内容,会自动居中显示在页面顶部 margin 区域内,不会侵占正文空间。这是@page的核心优势——它操作的是“页面介质”,而非“文档流”。

3.5 PDF 元数据:让生成的文件真正“可识别、可归档”

一份专业的 PDF,必须包含标准元数据(Metadata),否则在档案系统、OCR 识别、搜索引擎中会被当成“无主文件”。OpenHTMLtoPDF 提供了setMetaData()方法,但很多人漏掉。

PdfRendererBuilder builder = new PdfRendererBuilder(); builder.withUri("http://localhost/template.ftl"); // 模板路径 builder.to("output.pdf"); // 设置 PDF 元数据 builder.useMetadata(new PdfMetadata() .setTitle("用户信息凭证_" + System.currentTimeMillis()) .setAuthor("XX系统自动化服务") .setCreator("OpenHTMLtoPDF v1.0.10") .setSubject("用户个人信息凭证") .setKeywords("用户,凭证,PDF,自动化") .setCreationDate(new Date()) ); builder.run();

这些字段会写入 PDF 的/Info字典,用 Adobe Acrobat 或pdfinfo命令行工具可查看:

$ pdfinfo output.pdf Title: 用户信息凭证_1712345678901 Author: XX系统自动化服务 Creator: OpenHTMLtoPDF v1.0.10 Producer: OpenHTMLtoPDF CreationDate: Thu Apr 4 10:23:45 2024 CST

没有元数据的 PDF,在企业文档管理系统里无法被分类检索,也违反 ISO 19005(PDF/A 归档标准)的基本要求。

3.6 性能调优:从 Demo 的 500ms 到生产级的 80ms

Demo 代码往往忽略性能,但生产环境并发一上来,慢就是故障。我们总结出 4 个必做优化点:

  1. FreeMarker Configuration 复用
    Configuration对象是线程安全的,且初始化耗时(加载模板、解析语法树)。必须单例复用,禁止每次请求 new 一个:

    // ✅ 正确:Spring Bean 管理 @Bean public Configuration freeMarkerConfig() throws IOException { Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); cfg.setDirectoryForTemplateLoading(new File("/opt/templates")); cfg.setDefaultEncoding("UTF-8"); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); return cfg; }
  2. 模板缓存开启
    FreeMarker 默认开启模板缓存,但需确认setTemplateCacheThreshold()设置合理:

    cfg.setTemplateCacheThreshold(100); // 缓存最多 100 个模板,避免 OOM
  3. OpenHTMLtoPDF Builder 复用
    PdfRendererBuilder不是线程安全的,但可以复用其配置:

    // 预先构建好通用配置 private final PdfRendererBuilder baseBuilder = new PdfRendererBuilder() .useFontSubstitution(true) .useFontEmbedding(true) .useFontSubset(true); // 每次请求只 setUri/setTo,不重复配置 public void generatePdf(String templateUri, String outputPath) { baseBuilder.withUri(templateUri).to(outputPath).run(); }
  4. 异步生成 + 队列削峰
    对于大文件(>10MB)或高并发(>100 QPS),必须加异步层:

    @Async // Spring @Async public CompletableFuture<Void> asyncGeneratePdf(...) { // 执行 PDF 生成 builder.run(); return CompletableFuture.completedFuture(null); }

    配合 Redis 队列或 Kafka,把 PDF 生成任务丢进消息队列,主业务线程立即返回,避免阻塞。

3.7 安全加固:防止模板注入与路径遍历的双重防护

FreeMarker 模板引擎存在include/import指令,若用户可控模板路径,可能引发任意文件读取(LFI)。OpenHTMLtoPDF 的url()函数也可能被利用。必须双管齐下:

FreeMarker 层防护:

// 自定义 TemplateLoader,限制可访问目录 public class SecureTemplateLoader implements TemplateLoader { private final File rootDir = new File("/opt/templates"); @Override public Object findTemplateSource(String name) throws IOException { File file = new File(rootDir, name); // 标准路径规范化检查 if (!file.getCanonicalPath().startsWith(rootDir.getCanonicalPath())) { throw new IOException("Access denied: " + name); } return file; } // ... 其他方法 } cfg.setTemplateLoader(new SecureTemplateLoader());

OpenHTMLtoPDF 层防护:
禁用url()函数的外部协议:

// 自定义 ResourceResolver builder.useResourceResolver(new DefaultResourceResolver() { @Override public InputStream resolveResource(String uri) throws IOException { // 只允许 file:/// 协议,且路径必须在安全目录内 if (uri.startsWith("file:///opt/fonts/")) { return super.resolveResource(uri); } throw new IOException("Resource access denied: " + uri); } });

这两道防线,能 100% 阻断模板注入和字体路径遍历攻击。我们曾审计过某政务平台,其 FreeMarker 模板允许?include任意路径,攻击者通过?template=../../../../etc/passwd直接读取服务器密码文件——这种低级漏洞,在 PDF 生成场景里绝不能容忍。

4. 实操过程详解:从零搭建一个可交付的 PDF 生成服务

4.1 环境准备与依赖配置(Maven)

项目用 Maven 管理依赖,核心依赖只有 3 个,但版本必须严格匹配:

<properties> <freemarker.version>2.3.32</freemarker.version> <openhtmltopdf.version>1.0.10</openhtmltopdf.version> <spring-boot.version>2.7.18</spring-boot.version> </properties> <dependencies> <!-- FreeMarker 模板引擎 --> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>${freemarker.version}</version> </dependency> <!-- OpenHTMLtoPDF 核心渲染 --> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-core</artifactId> <version>${openhtmltopdf.version}</version> </dependency> <!-- OpenHTMLtoPDF 字体支持(必须!)--> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-pdfbox</artifactId> <version>${openhtmltopdf.version}</version> </dependency> <!-- OpenHTMLtoPDF SVG 支持(可选,用于图表)--> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-svg-support</artifactId> <version>${openhtmltopdf.version}</version> </dependency> </dependencies>

注意:openhtmltopdf-pdfbox是必须的,它提供 PDF 输出能力;openhtmltopdf-core只是渲染内核,不输出 PDF。若漏掉pdfbox依赖,builder.run()会抛NoClassDefFoundError

4.2 FreeMarker 配置类(Spring Boot)

@Configuration public class FreemarkerConfig { @Value("classpath:/templates/") private Resource templateLocation; @Bean @Synchronized public Configuration freemarkerConfiguration() throws IOException { Configuration configuration = new Configuration(Configuration.VERSION_2_3_31); // 模板加载器:从 classpath 加载 ClassTemplateLoader templateLoader = new ClassTemplateLoader( this.getClass().getClassLoader(), "templates/"); configuration.setTemplateLoader(templateLoader); // 编码与异常处理 configuration.setDefaultEncoding("UTF-8"); configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); configuration.setLogTemplateExceptions(false); configuration.setWrapUncheckedExceptions(true); // 缓存配置 configuration.setTemplateCacheThreshold(100); configuration.setCacheStorage(new MruCacheStorage(100, 10)); // 自定义宏(可选) configuration.setSharedVariable("dateUtils", new DateUtils()); return configuration; } // 自定义日期工具类,供模板调用 public static class DateUtils { public String formatDate(Date date) { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); } } }

这个配置类做了 5 件事:指定模板路径、设置 UTF-8 编码、配置异常策略(RETHROW_HANDLER 保证错误堆栈不丢失)、开启模板缓存、注入共享变量。其中MruCacheStorage(100, 10)表示缓存 100 个模板,每个模板最多缓存 10 个版本(应对热更新)。

4.3 PDF 生成服务核心类

@Service public class PdfGenerationService { private final Configuration freemarkerConfig; private final PdfRendererBuilder baseBuilder; public PdfGenerationService(Configuration freemarkerConfig) { this.freemarkerConfig = freemarkerConfig; // 预构建 OpenHTMLtoPDF Builder,避免每次 new this.baseBuilder = new PdfRendererBuilder() .useFontSubstitution(true) .useFontEmbedding(true) .useFontSubset(true) .useResourceResolver(new SecureResourceResolver()); // 安全资源解析器 } /** * 生成 PDF 主方法 * @param templateName 模板名(如 "user-certificate.ftl") * @param dataModel 数据模型(Map 或 Java Bean) * @param outputPath 输出路径 */ public void generatePdf(String templateName, Object dataModel, String outputPath) throws IOException, DocumentException { // 1. FreeMarker 渲染 HTML 字符串 String htmlContent = renderTemplate(templateName, dataModel); // 2. OpenHTMLtoPDF 渲染 PDF try (ByteArrayInputStream input = new ByteArrayInputStream(htmlContent.getBytes(StandardCharsets.UTF_8))) { PdfRendererBuilder builder = new PdfRendererBuilder(input, "http://localhost/"); builder.to(outputPath); // 设置元数据 builder.useMetadata(new PdfMetadata() .setTitle("PDF_" + System.currentTimeMillis()) .setAuthor("System") .setCreator("OpenHTMLtoPDF")); builder.run(); } } private String renderTemplate(String templateName, Object dataModel) throws IOException, TemplateException { Template template = freemarkerConfig.getTemplate(templateName); StringWriter writer = new StringWriter(); template.process(dataModel, writer); return writer.toString(); } // 安全资源解析器,防止路径遍历 private static class SecureResourceResolver implements ResourceResolver { @Override public InputStream resolveResource(String uri) throws IOException { if (uri == null || !uri.startsWith("file:///opt/fonts/")) { throw new IOException("Invalid resource URI: " + uri); } return Files.newInputStream(Paths.get(uri.substring("file://".length()))); } } }

这个服务类体现了生产级设计:baseBuilder复用、renderTemplate抽离、SecureResourceResolver防护、try-with-resources确保流关闭。generatePdf方法签名清晰,参数语义明确,便于单元测试。

4.4 Controller 层:RESTful 接口设计

@RestController @RequestMapping("/api/pdf") public class PdfController { private final PdfGenerationService pdfService; public PdfController(PdfGenerationService pdfService) { this.pdfService = pdfService; } @PostMapping("/generate") public ResponseEntity<byte[]> generateCertificate(@RequestBody PdfRequest request) throws IOException, DocumentException { // 参数校验 if (request.getTemplateName() == null || request.getData() == null) { return ResponseEntity.badRequest().build(); } // 生成唯一文件名 String fileName = "certificate_" + System.currentTimeMillis() + ".pdf"; String outputPath = "/tmp/" + fileName; // 执行生成 pdfService.generatePdf(request.getTemplateName(), request.getData(), outputPath); // 返回文件流 byte[] fileBytes = Files.readAllBytes(Paths.get(outputPath)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentDispositionFormData("attachment", fileName); return ResponseEntity.ok() .headers(headers) .body(fileBytes); } // 请求体 DTO public static class PdfRequest { private String templateName; private Map<String, Object> data; // getter/setter public String getTemplateName() { return templateName; } public void setTemplateName(String templateName) { this.templateName = templateName; } public Map<String, Object> getData() { return data; } public void setData(Map<String, Object> data) { this.data = data; } } }

接口设计遵循 REST 规范:POST `/api/pdf/g

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 6:59:11

3步让CUDA程序跑在AMD显卡上:ZLUDA指南

3步让CUDA程序跑在AMD显卡上&#xff1a;ZLUDA指南 【免费下载链接】ZLUDA CUDA on non-NVIDIA GPUs 项目地址: https://gitcode.com/GitHub_Trending/zl/ZLUDA ZLUDA 是一个面向非NVIDIA显卡的 CUDA 兼容层&#xff1a;不改动一行代码&#xff0c;就能让现成的 CUDA 程…

作者头像 李华
网站建设 2026/9/12 6:58:32

spaCy 如何用 spancat 组件构建 Span 级文本分类流水线?

spaCy 如何用 spancat 组件构建 Span 级文本分类流水线&#xff1f; 【免费下载链接】spaCy &#x1f4ab; Industrial-strength Natural Language Processing (NLP) in Python 项目地址: https://gitcode.com/GitHub_Trending/sp/spaCy 如果你需要的不是"整句分一…

作者头像 李华
网站建设 2026/9/12 6:57:34

Upscayl AI图像放大实战:批量放大一整文件夹500px图片到4倍

Upscayl AI图像放大实战&#xff1a;批量放大一整文件夹500px图片到4倍 【免费下载链接】upscayl &#x1f199; Upscayl - #1 Free and Open Source AI Image Upscaler for Linux, MacOS and Windows. 项目地址: https://gitcode.com/GitHub_Trending/up/upscayl Upsca…

作者头像 李华
网站建设 2026/9/12 6:54:56

CLAUDE.md:AI协作项目的结构化记忆中枢设计

1. 项目概述&#xff1a;CLAUDE.md 如何成为AI项目的"记忆中枢"在多人协作的AI项目开发中&#xff0c;最头疼的问题莫过于"规范失忆"——新加入的开发者总要反复询问"这个参数为什么设0.7&#xff1f;""那段异常处理逻辑是谁加的&#xff1…

作者头像 李华
网站建设 2026/9/12 6:53:57

Android消息循环机制:Looper、Handler与线程通信解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华