需求背景
已有的系统中,已在前端做了单条数据的打印功能,使用的是 react-to-print
,但是用户觉得一个一个打印比较麻烦(主要是想汇集材料留档),因此想要一个批量打印 PDF 的功能,但是前端没法做(原因会在下方说明),因此就更换到后端来实现了,还好 iText 救我狗命(doge)
最初尝试在前端实现此功能,但是效果不理想,html2canvas + jspdf
打印出来的效果很不好,样式与原 html 样式有差距不说,大图片的挂载更是一个严重的问题,根本无法实现大批量导出。有兴趣的可以尝试一下,反正我折腾了很久最终还是放弃了,目前后端导出的 PDF 样式与前端使用 react-to-print
导出的效果基本一致。
功能内容
该文章中实现的的主要功能如下,可以直接拷贝使用:
- 自定义导出字体(微软雅黑)
- 使用 Thymeleaf 自定义渲染内容
- PDF 批量打印
- 导出 zip 文件
- 前端接收并显示已接收大小
iText7 相比 iText5 的 PDF 渲染速度要快非常多(从 5 换到 7,导出速度提高了最少 5 倍),而且对 css 样式的可使用性有明显提高,flex
布局可以正常使用,开发体验已经很棒了
吐槽:iText 本身渲染 PDF 的写法跟坨屎一样,
new Paragraph("Hello Word")
这种也就写一些简单的内容了,复杂一点的能把人累死
后端 PDF 渲染导出
先添加相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- thymeleaf 需要这个包, 否则转 html 时会报错 -->
<dependency>
<groupId>ognl</groupId>
<artifactId>ognl</artifactId>
<version>3.1.26</version>
</dependency>
<!-- 核心 将 Tymeleaf 渲染成 PDF -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>6.2.0</version>
</dependency>
<!-- 自定义字体 默认不支持中文字体渲染 -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>font-asian</artifactId>
<version>9.2.0</version>
</dependency>
导出的 PdfUtil 类
这里注册了微软雅黑的默认字体和粗体,如果没有粗体要求的可以不导入。
windows 系统字体统一都在 C:\Windows\Fonts
下,这里将字体拷贝到了 resources
目录下
目前只用到了 convertHtmlToPdfByte
这个方法
package com.uckj.digit.utils;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.io.font.FontProgram;
import com.itextpdf.io.font.TrueTypeCollection;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.font.FontProvider;
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.nio.file.Files;
@Slf4j
public class PdfExportUtil {
// 微软雅黑字体文件路径
private static final String FONT_PATH = "fonts/msyh.ttc";
private static final String FONT_BOLD_PATH = "fonts/msyhbd.ttc";
private static FontProvider FONT_PROVIDER;
/**
* 初始化字体提供器
*/
private static synchronized void initFontProvider() {
if (FONT_PROVIDER != null) return;
try {
FontProvider provider = new FontProvider();
registerMsyhFont(provider);
FONT_PROVIDER = provider;
} catch (Exception e) {
log.error("字体初始化失败", e);
// 回退到系统字体
FONT_PROVIDER = new FontProvider();
FONT_PROVIDER.addSystemFonts();
}
}
/**
* 注册微软雅黑字体
*/
private static void registerMsyhFont(FontProvider provider) throws Exception {
// 注册常规字体 (msyh.ttc)
try (InputStream regularIs = PdfExportUtil.class.getClassLoader().getResourceAsStream(FONT_PATH)) {
TrueTypeCollection regularTtc = new TrueTypeCollection(regularIs.readAllBytes());
FontProgram regularProgram = regularTtc.getFontByTccIndex(0);
provider.addFont(regularProgram);
}
// 注册粗体字体 (msyhbd.ttc)
try (InputStream boldIs = PdfExportUtil.class.getClassLoader().getResourceAsStream(FONT_BOLD_PATH)) {
byte[] boldData = boldIs.readAllBytes();
TrueTypeCollection boldTtc = new TrueTypeCollection(boldData);
FontProgram boldProgram = boldTtc.getFontByTccIndex(0);
provider.addFont(boldProgram);
}
log.info("微软雅黑字体注册成功");
}
private static ConverterProperties createConverterProperties() {
ConverterProperties properties = new ConverterProperties();
// 初始化字体
if (FONT_PROVIDER == null) {
initFontProvider();
}
properties.setFontProvider(FONT_PROVIDER);
return properties;
}
/**
* 将HTML内容转换为PDF
*/
private static void convertToPdf(String htmlContent, OutputStream outputStream) {
try {
// 创建PDF文档
PdfWriter pdfWriter = new PdfWriter(outputStream);
PdfDocument pdfDocument = new PdfDocument(pdfWriter);
pdfDocument.setDefaultPageSize(PageSize.DEFAULT);
// 转换HTML到PDF
HtmlConverter.convertToPdf(htmlContent, pdfDocument, createConverterProperties());
} catch (Exception e) {
log.error("HTML转PDF失败", e);
throw new RuntimeException("HTML转PDF失败", e);
}
}
/**
* 将HTML文件转换为PDF
*/
private static void convertFileToPdf(File htmlFile, OutputStream outputStream) {
try {
// 创建PDF文档
PdfWriter pdfWriter = new PdfWriter(outputStream);
PdfDocument pdfDocument = new PdfDocument(pdfWriter);
pdfDocument.setDefaultPageSize(PageSize.A4);
// 转换HTML文件到PDF
HtmlConverter.convertToPdf(Files.newInputStream(htmlFile.toPath()),
pdfDocument, createConverterProperties());
} catch (Exception e) {
log.error("HTML文件转PDF失败", e);
throw new RuntimeException("HTML文件转PDF失败", e);
}
}
/**
* 将HTML内容转换为PDF字节数组
*/
public static byte[] convertHtmlToPdfByte(String htmlContent) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
convertToPdf(htmlContent, outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
log.error("HTML转PDF字节失败", e);
throw new RuntimeException("HTML转PDF字节失败", e);
}
}
public static void convertHtmlToPdf(String htmlContent, OutputStream outputStream) {
convertToPdf(htmlContent, outputStream);
}
public static void convertHtmlFileToPdf(File htmlFile, OutputStream outputStream) {
convertFileToPdf(htmlFile, outputStream);
}
public static void convertHtmlToPdfFile(String htmlContent, File outputFile) {
try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {
convertToPdf(htmlContent, outputStream);
} catch (Exception e) {
log.error("HTML转PDF文件失败", e);
throw new RuntimeException("HTML转PDF文件失败", e);
}
}
}
ThymeleafUtil 类
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.util.Map;
public class ThymeleafRenderer {
private final TemplateEngine templateEngine;
// 仅读取 templates 下的 html 文件
public ThymeleafRenderer() {
this.templateEngine = new TemplateEngine();
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
templateEngine.setTemplateResolver(resolver);
}
// 传递给 Thymeleaf 的参数
public String render(String templateName, Map<String, Object> variables) {
Context context = new Context();
if (variables != null) {
variables.forEach(context::setVariable);
}
return templateEngine.process(templateName, context);
}
}
写 Thymeleaf 注意事项
这个没啥说的,单纯的 Thymeleaf 语法而已,值得注意的是,Thymeleaf 模板中,有些 html 元素的默认样式和浏览器不一致
table
元素手动设置display: table;
才和浏览器一致caption
与浏览器相比默认多了一些边距,需注意- 其它样式细节自行发现即可
- 使用 iText7 可以用 flex 布局,但是 iText5 不行,iText5 的样式甚至需要兼容到 IE11 以下
- 若涉及到一些纯静态数据,可以转成 json,读取 json 文件并转成 Map,再传递给 Thymeleaf,引入 css 和 js 的写法我用上,有兴趣的可以尝试
批量打印 PDF 并返回 zip 流
到这里只需要调用上边的工具类即可,注意:这里不太好设置 zip 的 Content-Length
,因为 zip 的大小不固定,如果为了这个数值再去读一次 zip 的大小,有点得不偿失了。
public void exportTestPdf(List<String> ids, HttpServletResponse response) {
final String ZIP_NAME = "my-test.zip";
response.setContentType("application/zip");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + URLEncoder.encode(ZIP_NAME, StandardCharsets.UTF_8) + "\"");
if (ids == null || ids.isEmpty()) return;
try {
ThymeleafRenderer thymeleafRenderer = new ThymeleafRenderer();
// 伪代码,根据实际业务处理
List<MyList> myList = myListService.getBatchListByIds(ids);
try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
for (MyList my : myList) {
String fileName = my.getId() + ".pdf";
Path pdfPath = folderPath.resolve(fileName);
// 传递给 Thymeleaf 的参数(伪代码)
Map<String, Object> map = new HashMap<>();
map.put("data", my);
// 指定 thymeleaf 的模板并渲染为 html
String htmlContent = thymeleafRenderer.render("my-test", map);
byte[] pdfBytes = PdfExportUtil.convertHtmlToPdfByte(htmlContent);
// 添加PDF到ZIP
ZipEntry entry = new ZipEntry(fileName);
zos.putNextEntry(entry);
zos.write(pdfBytes);
zos.closeEntry();
}
zos.finish();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
前端接收流文件并保存
流格式的文件保存做不到浏览器的下载一样的显示,因此只能从体验的角度来告知使用者,文件正在下载
可以使用 axios
的 onDownloadProgress
来获取已下载的文件大小,并展示给用户,这样在大文件导出时,不至于什么变化都没有,让使用者干等
const res = await axios.post(
'/pdf-export/xxx',
{ ids: [] /** 导出的数据 id 集合 */ },
{
headers: {
responseType: 'blob', // 用 blob 接收数据流
},
onDownloadProgress(progressEvent) {
// 可以在此处获取已经下载的文件大小,并展示给用户,下载的进度百分比能不做就不做,浪费导出时间
console.log('已下载大小', progressEvent.loaded);
},
},
);
const blob = new Blob([res.data],{type: 'application/zip'});
const href = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = href;
a.download = `${Date.now().toString()}.zip`;
a.rel = 'noopener noreferrer';
document.body.append(a);
a.click();
URL.revokeObjectURL(a.href);
a.remove();
贴一个 PDF 的导出样例