SpringBoot - 实现将HTML内容转换为PDF文档教程(使用OpenHTMLToPDF)
作者:hangge | 2025-11-21 08:49
在前一篇文章中我演示了如何借助 docx4j 将 HTML(或 XHTML)转换为 Word 文档(点击查看),本文接着演示如何使用 OpenHTMLToPDF 这个第三方库 OpenHTMLToPDF 将 HTML 内容转换成 PDF 文件。
(2)接着为了避免导出的文件中文不显示(显示 # 号乱码),需要将中文字体文件放在 resources/fonts 目录下, 后面使用时进行注册。
(2)代码主要说明如下:
(2)我们使用 Postman 测试这个 /export-pdf 接口,传入 html 内容和文件名,并将结果保存到本地。
1,准备工作
(1)首先我们需要在项目的 pom.xml 中添加 OpenHTMLToPDF 以及相关的依赖。
<!-- OpenHTMLToPDF (基于 PDFBox 输出) --> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-pdfbox</artifactId> <version>1.0.10</version> </dependency> <!-- 可选:Svg 支持(如果你的 html 含 svg 图片) --> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-slf4j</artifactId> <version>1.0.10</version> </dependency> <!-- 用于解析/修改 HTML(处理 img) --> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.16.1</version> </dependency> <!-- apache commons-io:简化文件/流操作 --> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.14.0</version> </dependency>
(2)接着为了避免导出的文件中文不显示(显示 # 号乱码),需要将中文字体文件放在 resources/fonts 目录下, 后面使用时进行注册。
提示:这里我使用的“思源黑体”这个开源字体。Google 与 Adobe 联合开发的思源黑体(Noto Sans SC)常规字重版本,属于无衬线开源字体,支持中文、拉丁文等 30,000+ 字符,采用 OFL 开源协议允许免费商用。(点击下载)
2,样例代码
(1)我这里创建一个 Controller,该 Controller 暴露 /export-pdf 接口,接收形如 { html: "...", filename?: "xxx.docx" } 的 JSON 数据,返回 application/pdf 二进制流。
package com.example.demo.controller;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.apache.commons.io.FilenameUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.apache.commons.io.FileUtils;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
@RestController
@CrossOrigin
public class ExportController {
/**
* POST /export-pdf
* 请求体示例:
* { "html": "<html>...</html>", "filename": "report.pdf" }
*
* 注意:OpenHTMLToPDF 不会执行 JS,请确保前端传过来的是已经渲染好的静态 HTML(editor.getHTML())。
*/
@PostMapping(value = "/export-pdf", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> exportPdf(@RequestBody ExportRequest req,
@RequestHeader(value = "baseUri", required = false) String baseUri) {
String html = req.getHtml();
String filename = (req.getFilename() == null || req.getFilename().isEmpty()) ?
("document-" + System.currentTimeMillis() + ".pdf") : req.getFilename();
List<File> tmpFiles = new ArrayList<>(); // 用于所有临时图片/字体,最后统一删除
try {
// 1) 用 Jsoup 处理 HTML,方便操作 <img> 等
Document doc = Jsoup.parse(html);
// 处理 <img>:若 data: 或 http(s) -> 下载或转为临时文件,并替换 src 为 file:///
Elements imgs = doc.select("img");
for (Element img : imgs) {
String src = img.attr("src");
if (src == null || src.isEmpty()) continue;
try {
if (src.startsWith("data:")) {
// data:image/...;base64,...
String base64Part = src.substring(src.indexOf(",") + 1);
byte[] data = Base64.getDecoder().decode(base64Part);
String ext = guessExtensionFromData(src);
File tmp = File.createTempFile("img_" + UUID.randomUUID(), "." + ext);
FileUtils.writeByteArrayToFile(tmp, data);
tmpFiles.add(tmp);
img.attr("src", tmp.toURI().toString());
} else if (src.startsWith("http://") || src.startsWith("https://")) {
// 下载远程图片
URL url = new URL(src);
String ext = guessExtensionFromUrl(src);
File tmp = File.createTempFile("img_" + UUID.randomUUID(),
ext != null ? "." + ext : ".img");
try (InputStream in = url.openStream()) {
FileUtils.copyInputStreamToFile(in, tmp);
tmpFiles.add(tmp);
img.attr("src", tmp.toURI().toString());
} catch (Exception ex) {
// 若某张图片下载失败,记录并继续(不要阻塞整个转换)
ex.printStackTrace();
}
} else {
// 相对路径或 file:// 等,保留原样
}
} catch (Exception ex) {
// 单张处理异常不影响全局
ex.printStackTrace();
}
}
// 2) optional: 注入默认打印样式,提升与浏览器一致性(用户的 <style> 会与它合并)
// 如果前端已经包含专门的 print CSS,可以考虑跳过或覆盖部分属性。
String defaultPrintStyle = "<style>" +
"@page { size: A4; margin: 10mm; }" +
"body { font-family: 'NotoSansSC-Regular', Arial, Helvetica, sans-serif; " +
"font-size: 12pt; line-height: 1.6; color: #222; }" +
"p { margin: 0 0 10px; }" +
"img { max-width: 100%; height: auto; }" +
"table { border-collapse: collapse; width: 100%; }" +
"th { background-color: #f2f2f2; }" +
"th, td { padding: 8px; border: 1px solid #ddd; vertical-align: top; }" +
"thead { display: table-header-group; }" +
"tr { page-break-inside: avoid; }" +
"</style>";
// 将打印样式插入到 <head> 中(若没有 head,Jsoup 会补齐)
Element head = doc.head();
head.append(defaultPrintStyle);
// 3) 最终 HTML
String processedHtml = doc.outerHtml();
// 先处理自闭合标签
processedHtml = processedHtml.replaceAll("<(img|meta|br|hr)([^>]*)>", "<$1$2/>");
processedHtml = processedHtml.replaceAll("<(col)(?!group)([^>]*)>", "<$1$2/>");
// 4) OpenHTMLToPDF 渲染
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
PdfRendererBuilder builder = new PdfRendererBuilder();
// baseUri 用于解析 HTML 中的相对链接。如果请求 header 中传了 baseUri 就用它,
// 否则可以把 baseUri 指向临时目录(where tmp images are located),或留空 ""。
String effectiveBaseUri = (baseUri != null && !baseUri.isEmpty()) ? baseUri : "";
// 如果你把远程图片下载为 file://... ,用 file 的 parent dir 作为 baseUri 也可
if (tmpFiles.size() > 0) {
// 取第一个临时文件的 parent 作为 baseUri(保证 file:///... 能解析相对路径)
File parent = tmpFiles.get(0).getParentFile();
if (parent != null) {
effectiveBaseUri = parent.toURI().toString();
}
}
builder.withHtmlContent(processedHtml, effectiveBaseUri);
// 可选:如果你需要中文或自定义字体,请把字体文件放在 resources/fonts 下,
// 并在下面注册(示例给出如何加载 fonts/NotoSansSC-Regular.otf)
// 如果没有自定义字体可以跳过这一步
try {
// 下面示例尝试加载 resources/fonts 下的几个常见字体文件(按需修改)
List<String> resourceFonts = Arrays.asList(
"fonts/NotoSansSC-Regular.ttf",
"fonts/NotoSansSC-Bold.ttf"
);
for (String resFontPath : resourceFonts) {
InputStream fis = getClass().getClassLoader().getResourceAsStream(resFontPath);
if (fis != null) {
File tmpFont = File.createTempFile("font_" + UUID.randomUUID(), ".tmp");
FileUtils.copyInputStreamToFile(fis, tmpFont);
tmpFiles.add(tmpFont);
// 使用文件名(不含扩展)作为 font-family 名称,随后你可以在 CSS 中使用该名称
String family = FilenameUtils.getBaseName(resFontPath); // NotoSansSC-Regular
builder.useFont(tmpFont, family);
// 也可以在 processedHtml 的 style 中设置 body { font-family: 'family'; }
}
}
} catch (Exception fe) {
// 字体加载失败不应阻止渲染,打印日志并继续
fe.printStackTrace();
}
builder.toStream(os);
// 重要:run() 可能抛异常
builder.run();
byte[] pdfBytes = os.toByteArray();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDisposition(ContentDisposition.attachment().filename(filename).build());
headers.setContentLength(pdfBytes.length);
return ResponseEntity.ok()
.headers(headers)
.body(pdfBytes);
}
} catch (Exception ex) {
ex.printStackTrace();
return ResponseEntity.status(500).body(("PDF Conversion failed: " + ex.getMessage())
.getBytes(StandardCharsets.UTF_8));
} finally {
// 清理临时文件
for (File f : tmpFiles) {
try {
if (f != null && f.exists()) f.delete();
} catch (Exception ignored) { }
}
}
}
// 从 data: URI 头部猜测图片格式
private static String guessExtensionFromData(String dataUri) {
try {
String mime = dataUri.substring(5, dataUri.indexOf(";"));
if (mime.contains("/")) {
return mime.substring(mime.indexOf("/") + 1);
}
} catch (Exception ignored) {}
return "png";
}
// 从 URL 路径猜测图片格式
private static String guessExtensionFromUrl(String url) {
try {
String lower = url.toLowerCase();
if (lower.contains(".png")) return "png";
if (lower.contains(".jpg") || lower.contains(".jpeg")) return "jpg";
if (lower.contains(".gif")) return "gif";
if (lower.contains(".svg")) return "svg";
if (lower.contains(".bmp")) return "bmp";
} catch (Exception ignored) {}
return null;
}
// DTO (复用你现有的 ExportRequest)
public static class ExportRequest {
private String html;
private String filename;
public String getHtml() { return html; }
public void setHtml(String html) { this.html = html; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
}
}
- 处理 data: URI 与远程图片(下载为临时文件并把 src 改为 file://)
- 注入一个默认的打印样式(@page、line-height、table 样式等),可与前端传入的 <style> 一起工作
- 可选地从 resources/fonts/ 加载字体并注册到 renderer(如需中文支持,务必把对应字体放入 src/main/resources/fonts/ 并在代码中列出)
- 使用 PdfRendererBuilder 输出 PDF 并返回二进制响应
- 清理所有临时文件(图片 + 可能的字体临时文件)
3,运行测试
(1)假设我需要将如下 html 页面内容转换成 pdf:
<html>
<head>
<meta charset="utf-8" />
<style>
body {
font-family: "Arial", sans-serif;
font-size: 12p;
}
table {
border-collapse: collapse;
}
th {
background: #F9FAFB;
font-weight: 600;
}
td,
th {
border: 1px solid #ccc;
padding: 4px;
}
</style>
</head>
<body>
<p>欢迎访问<a href="https://www.hangge.com">hangge.com</a></p>
<p><img src="https://www.hangge.com/blog/images/logo.png" alt="logo"></p>
<p></p>
<p>测试<strong>加粗</strong>测试<em>斜体</em>测试<u>下划线</u>测试<s>删除线</s></p>
<p></p>
<p><span style="color: rgb(255, 102, 255);">修改文字颜色</span></p>
<p><span style="font-size: 22px;">调整文字大小</span></p>
<p></p>
<p>下面是一个3x3表格</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名称</th>
<th>用户年龄</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>刘小龙</td>
<td>88</td>
</tr>
<tr>
<td>2</td>
<td>张大伟</td>
<td>66</td>
</tr>
</tbody>
</table>
</body>
</html>
(2)我们使用 Postman 测试这个 /export-pdf 接口,传入 html 内容和文件名,并将结果保存到本地。
提示:html 内容可以是完整的 html 代码,也可以想示例图片一样只传递 <body> 标签里面的内容。

(3)打开保存下来的 report.pdf 文件,可以看到该文档内容如下:

全部评论(0)