开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第9天,点击查看活动详情
背景需求:Springboot项目,后端填充html(要包含echarts),生成pdf
经过网上各种方案调查,最终选定使用wkhtmltopdf+静态html界面生成pdf界面,除了这个方案还有一个方案我觉得简单但是不太适合我的场景,感兴趣请查看 小小鱼生博主的文章 。
wkhtmltopdf 是一个将静态html网页截屏生成pdf文件的工具,Linux、Mac、Windows各个操作系统的版本都有。只需要输入目标网页的URL就能将网页完整的导出PDF文件。
流程和原理:
- 1.在操作系统安装wkhtmltopdf工具
- 2.前端编码html+jquery+echarts的纯静态页面,由于wkhtmltopdf工具使用内置的WebKit内核版本较低,所以不兼容太新的is语言,像VueJS这些最新的框架就无法使用这个工具。目前使用的的echarts版本是4.2.1.3。调用wkhtmltopdf命令输入静态网页地址生成pdf文件。
缺点:
- 1.需要在服务器系统中先安装wkhtmltopdf工具
- 2.只能单独开发一个纯静态的html页面来生成报表
- 3.转换速度慢
优点:
- 界面美观,因为是直接截取html网页,所以和前端生成的图表样式一样开发量最小。
一、下载wkhtmltopdf
二、安装wkhtmltopdf
windows安装
- 就正常win软件安装,选择一个你要安装的位置:
- 测试
在安装bin目录下执行命令进行测试:wkhtmltopdf.exe https://www.baidu.com/ D:\wkhtmltopdf\bin\1.pdf
生成的pdf如下:
注意事项:
如果是转换本地HTML页面,可能会出现图片禁止访问类型的错误,可加上以下命令进行转换,这是因为wkhtmltopdf升级版本之后默认禁止了访问。
允许访问需要加命令参数:--enable-local-file-access
整合后命令:wkhtmltopdf.exe --enable-local-file-access D:\wkhtmltopdf\bin\1.html D:\wkhtmltopdf\bin\1.pdf
linux安装
-
首先上传安装包至服务器中,我的目录位置为:
/tools/module -
安装依赖:
yum -y install libXrender* libfontconfig* libXext*yum install xorg-x11-fonts-75dpi.noarch
-
安装:
rpm -ivh wkhtmltox-0.12.6-1.centos7.x86_64.rpm -
配置环境变量
首先打开vim /etc/profile,然后再最后加入以下内容:
接着刷新环境 source /etc/profile
ps:如果要卸载:rpm -e wkhtmltox(不可以直接在ftp服务器中手动删除相关文件)。
wkhtmltopdf默认解压路径:/usr/local/bin/wkhtmltopdf
- 测试
wkhtmltopdf -V,没有报错就是ok
三、代码实现
1. 引入依赖
<!-- 引入thymeleaf依赖包(springboot访问静态资源)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>2.5.14</version>
</dependency>
<!-- 引入freemarker模板引擎-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
<version>2.5.14</version>
</dependency>
2. 编写静态模板
随便写的模板,为了调试加载Echarts
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ECharts</title>
<!-- 引入 echarts.js -->
<script src="http://cdn.bootcss.com/echarts/3.2.3/echarts.min.js"></script>
<script src="http://code.jquery.com/jquery-2.1.4.min.js"></script>
<style>
#flagVal {display: none;}
#text {display: none;}
#xAxisData {display: none;}
#seriesData {display: none;}
</style>
</head>
<body onload="time()">
<div style="color: #4787ed" >你好,${name}!, 当前时间是 <span id="time"></span></div>
<!-- 要填充的数据 -->
<div id="flagVal">${flagVal}</div>
<div id="text">${text}</div>
<div id="xAxisData">${xAxisData}</div>
<div id="seriesData">${series}</div>
<!-- 为ECharts准备一个具备大小(宽高)的Dom -->
<div>
<div id="main" style="width: 700px;height:400px;"></div>
<div id="main2" style="width: 700px;height:400px;"></div>
<div id="main3" style="width: 700px;height:400px;"></div>
<br/><br/>
<div></div>
<div id="main4" style="width: 700px;height:400px;"></div>
</div>
<script type="text/javascript">
// 基于准备好的dom,初始化echarts实例
var myChart = echarts.init(document.getElementById('main'));
var myChart2 = echarts.init(document.getElementById('main2'));
var flagVal = document.getElementById('flagVal').innerText;
var textName = document.getElementById('text').innerText;
var xAxisData = document.getElementById('xAxisData').innerText;
var seriesData = document.getElementById('seriesData').innerText;
// 传来一个值标识什么时候进行格式化数据
if (1==flagVal) {
xAxisData = JSON.parse(xAxisData);
seriesData = JSON.parse(seriesData);
}
// 指定图表的配置项和数据
var option = {
title: {
text: textName
},
tooltip: {},
legend: {
data:['销量']
},
xAxis: {
data: xAxisData
},
yAxis: {},
series: seriesData
};
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
// 指定图表的配置项和数据
var option2 = {
animation: false,
tooltip: {
trigger: 'item'
},
legend: {
top: '5%',
left: 'center'
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '40',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
]
}
]
};
// 使用刚指定的配置项和数据显示图表。
myChart2.setOption(option2);
</script>
<script type="text/javascript">
// 基于准备好的dom,初始化echarts实例
var myChart3 = echarts.init(document.getElementById('main3'));
var myChart4 = echarts.init(document.getElementById('main4'));
// 指定图表的配置项和数据
var option3 = {
// 去掉动画效果
animation: false,
title: {
text: 'Stacked Line'
},
tooltip: {
trigger: 'axis'
},
legend: {
data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
toolbox: {
feature: {
saveAsImage: {}
}
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
name: 'Email',
type: 'line',
stack: 'Total',
data: [120, 132, 101, 134, 90, 230, 210]
},
{
name: 'Union Ads',
type: 'line',
stack: 'Total',
data: [220, 182, 191, 234, 290, 330, 310]
},
{
name: 'Video Ads',
type: 'line',
stack: 'Total',
data: [150, 232, 201, 154, 190, 330, 410]
},
{
name: 'Direct',
type: 'line',
stack: 'Total',
data: [320, 332, 301, 334, 390, 330, 320]
},
{
name: 'Search Engine',
type: 'line',
stack: 'Total',
data: [820, 932, 901, 934, 1290, 1330, 1320]
}
]
};
// 使用刚指定的配置项和数据显示图表。
myChart3.setOption(option3);
option4 = {
// 去掉动画效果
animation: false,
tooltip: {
formatter: '{a} <br/>{b} : {c}%'
},
series: [
{
name: 'Pressure',
type: 'gauge',
progress: {
show: true
},
detail: {
valueAnimation: true,
formatter: '{value}'
},
data: [
{
value: 50,
name: 'SCORE'
}
]
}
]
};
myChart4.setOption(option4);
</script>
<script>
function time(){
var date = new Date();
var year = date.getFullYear();
var month = date.getMonth()+1;
var day = date.getDate();
var week = date.getDay();
week="星期"+"日一二三四五六".charAt(week);
/*switch(week){
case 1:
week="星期一";
break;
case 2:
week="星期二";
break;
case 3:
week="星期三";
break;
case 4:
week="星期四";
break;
case 5:
week="星期五";
break;
case 6:
week="星期六";
break;
case 0:
week="星期日";
break;
}*/
var hour =date.getHours();
hour=hour<10?"0"+hour:hour;
var minute =date.getMinutes();
minute=minute<10?"0"+minute:minute;
var second = date.getSeconds();
second=second<10?"0"+second:second;
var currentTime = year+"-"+month+"-"+day+" "+week+" "+hour+":"+minute+":"+second;
document.getElementById("time").innerHTML=currentTime;
}
setInterval("time()",1000);
</script>
</body>
</html>
3. Wkhtmltopdf 常量配置类
public class WkhtmltopdfConsts {
/**
* 0.12.6版本默认禁用本地文件访问(图片等)
* cmd 命令 加上以下命令参数即可
* 表示启动本地文件访问
*/
private static final String PARAMETER = "--enable-local-file-access";
/**
* 允许本地文件访问
*/
private static final String ALLOW = "--allow";
/**
* wkhtmltopdf在系统中的路径(全路径)
*/
public static final String CONVERSION_PLUGSTOOL_PATH_WINDOW = "D:/wkhtmltopdf/bin/wkhtmltopdf.exe "+PARAMETER;
/**
* wkhtmltopdf在Linux系统中的路径
*/
public static final String CONVERSION_PLUGSTOOL_PATH_LINUX = "/usr/local/bin/wkhtmltopdf"+PARAMETER;
}
4. 放入线程,查看进度
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 线程执行
*/
public class HtmlToPdfInterceptor extends Thread {
private InputStream is;
protected HtmlToPdfInterceptor(InputStream is){
this.is = is;
}
@Override
public void run(){
try{
InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(isr);
String line ;
while ((line = br.readLine()) != null) {
System.out.println(line); //输出内容
}
}catch (IOException e){
e.printStackTrace();
}
}
}
5. 具体实现
import com.alibaba.fastjson2.JSON;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.StringWriter;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* wkhtmltopdf 转换工具类
*/
public class HtmlToPdfUtil {
private static final Logger log = LoggerFactory.getLogger(HtmlToPdfUtil.class);
/**
* wkhtmltopdf在系统中的路径
*/
private static String toPdfTool = WkhtmltopdfConsts.CONVERSION_PLUGSTOOL_PATH_WINDOW;
private static final int BUFFER_SIZE = 2 * 1024;
/**
* todo 填充 html 模板
* @param data 要填充的元数控
* @param htmlTemplate 要使用的模板名称
* @param templatePath 要使用的模板所在位置
* @param htmFolderPath 临时html存放的位置
* @param htmName 临时html名称
*
* @return 填充好的本地html地址
*/
public static String saveHtml(Map<String, Object> data, String htmlTemplate, String templatePath,
String htmFolderPath, String htmName)
throws IOException, TemplateException {
// 获取模板,并设置编码方式
Configuration freemarkerCfg = new Configuration(
Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
//文件夹目录位置
freemarkerCfg.setDirectoryForTemplateLoading(new File(templatePath));
Template template = freemarkerCfg.getTemplate(htmlTemplate, "UTF-8");
StringWriter out = new StringWriter();
// 合并模板跟数据
template.process(data, out);
// htmlData 模板字符流
String htmlData = out.toString();
return writeToFile(htmFolderPath, htmName, htmlData);
}
/**
* 创建文件
*
* @param folderPath 要存放的文件夹路径
* @param fileName 要创建的文件名称
* @param content 要填充的内容
* @return 创建成功的文件地址
*/
public static String writeToFile(String folderPath, String fileName, String content) {
File dirFile = null;
try {
dirFile = new File(folderPath);
if (!(dirFile.exists()) && !(dirFile.isDirectory())) {
if (dirFile.mkdirs()) {
log.info("========创建文件夹成功========");
} else {
log.info("========创建文件夹失败========");
}
}
} catch (Exception e) {
e.printStackTrace();
}
String fullPath = dirFile + "/" + fileName + ".html";
createTemporaryHtml(fullPath, content);
return fullPath;
}
/**
* 创建 临时 Html文件
*
* @param filePath 文件路径
* @param fileContent 文件内容
*/
public static void createTemporaryHtml(String filePath, String fileContent) {
String s;
StringBuilder s1 = new StringBuilder();
BufferedWriter output = null;
try {
File f = new File(filePath);
if (f.exists()) {
} else {
if (f.createNewFile()) {
log.info("========Temporary Html Create Success========");
} else {
log.info("========Temporary Html Create Fail========");
}
}
BufferedReader input = new BufferedReader(new FileReader(f));
while ((s = input.readLine()) != null) {
s1.append(s).append("\n");
}
input.close();
s1.append(fileContent);
output = new BufferedWriter(new FileWriter(f));
output.write(s1.toString());
output.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (output != null) {
try {
output.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* todo html转pdf
*
* @param srcPath html路径,可以是硬盘上的路径,也可以是网络路径
* @param destPath pdf保存路径
* @return 转换成功返回true
*/
public static boolean convert(String srcPath, String destPath) {
log.info("will convert htmlPath={} ------> pdfPath={}", srcPath, destPath);
File file = new File(destPath);
File parent = file.getParentFile();
// 如果pdf保存路径不存在,则创建路径
if (!parent.exists()) {
parent.mkdirs();
}
StringBuilder cmd = new StringBuilder();
String property = System.getProperty("os.name");
log.info("当前运行系统:{}", property);
if (!property.contains("Windows")) {
// 非windows 系统
toPdfTool = WkhtmltopdfConsts.CONVERSION_PLUGSTOOL_PATH_LINUX;
}
cmd.append(toPdfTool);
cmd.append(" ");
cmd.append(" \"");
cmd.append(srcPath);
cmd.append("\" ");
cmd.append(" ");
cmd.append(destPath);
log.info("wkhtmltopdf final cmd:[{}]", cmd);
boolean result = true;
try {
// 打开系统命令
Process proc = Runtime.getRuntime().exec(cmd.toString());
HtmlToPdfInterceptor error = new HtmlToPdfInterceptor(proc.getErrorStream());
HtmlToPdfInterceptor output = new HtmlToPdfInterceptor(proc.getInputStream());
error.start();
output.start();
proc.waitFor();
} catch (Exception e) {
result = false;
e.printStackTrace();
}
return result;
}
/**
* todo 多个文件打包成zip
*
* @param srcDir 要压缩的文件夹路径,如:D:\pdf\2022\11\29
* @param out 要压缩到哪里,叫什么名字,如:D:/pdf/test.zip
* @param KeepDirStructure 是否保留原来的目录结构,true:保留目录结构;false:所有文件跑到压缩包根目录下(注意:不保留目录结构可能会出现同名文件,会压缩失败)
* @throws RuntimeException 压缩失败会抛出运行时异常
*/
public static void toZip(String srcDir, OutputStream out, boolean KeepDirStructure)
throws RuntimeException {
log.info("开始压缩------------------>");
long start = System.currentTimeMillis();
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(out);
File sourceFile = new File(srcDir);
compress(sourceFile, zos, sourceFile.getName(), KeepDirStructure);
long end = System.currentTimeMillis();
log.info("压缩完成,耗时:{} ms", end - start);
} catch (Exception e) {
throw new RuntimeException(" zip error ", e);
} finally {
if (zos != null) {
try {
zos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 递归压缩方法
*
* @param sourceFile 源文件
* @param zos zip输出流
* @param name 压缩后的名称
* @param KeepDirStructure 是否保留原来的目录结构,true:保留目录结构;
* false:所有文件跑到压缩包根目录下(注意:不保留目录结构可能会出现同名文件,会压缩失败)
* @throws Exception
*/
private static void compress(File sourceFile, ZipOutputStream zos, String name,
boolean KeepDirStructure) throws Exception {
byte[] buf = new byte[BUFFER_SIZE];
if (sourceFile.isFile()) {
// 向zip输出流中添加一个zip实体,构造器中name为zip实体的文件的名字
zos.putNextEntry(new ZipEntry(name));
// copy文件到zip输出流中
int len;
FileInputStream in = new FileInputStream(sourceFile);
while ((len = in.read(buf)) != -1) {
zos.write(buf, 0, len);
}
// Complete the entry
zos.closeEntry();
in.close();
} else {
File[] listFiles = sourceFile.listFiles();
if (listFiles == null || listFiles.length == 0) {
// 需要保留原来的文件结构时,需要对空文件夹进行处理
if (KeepDirStructure) {
// 空文件夹的处理
zos.putNextEntry(new ZipEntry(name + "/"));
// 没有文件,不需要文件的copy
zos.closeEntry();
}
} else {
for (File file : listFiles) {
// 判断是否需要保留原来的文件结构
if (KeepDirStructure) {
// 注意:file.getName()前面需要带上父文件夹的名字加一斜杠,
// 不然最后压缩包中就不能保留原来的文件结构,即:所有文件都跑到压缩包根目录下了
compress(file, zos, name + "/" + file.getName(), KeepDirStructure);
} else {
compress(file, zos, file.getName(), KeepDirStructure);
}
}
}
}
}
public static void main(String[] args) throws TemplateException, IOException {
long startTime = System.currentTimeMillis();
log.info("generate pdf start ... ");
// 初始化一个文件夹路径
Calendar calendar = Calendar.getInstance();//得到日历
calendar.setTime(new Date()); //设置当前日期
String yearStr = calendar.get(Calendar.YEAR) + "";//获取当前年
int month = calendar.get(Calendar.MONTH) + 1;//获取月份
int day = calendar.get(Calendar.DATE);//获取天
// 文件最终存放的文件夹路径
String folderPath = "D:\\pdf\\" + yearStr + "\\" + month + "\\" + day;
//////////////////////////////////以下模拟数据取请求\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
//1.拿取要加载的数据
Map<String, Object> echartData = new HashMap<>();
echartData.put("flagVal", 1);// 这个是标识,用于传入html中转换数据格式
echartData.put("name", "wh");
echartData.put("text", "Echart 折线图");
List<String> xAxisList = new ArrayList<>();
xAxisList.add("衬衫");
xAxisList.add("羊毛衫");
xAxisList.add("雪纺衫");
xAxisList.add("裤子");
xAxisList.add("高跟鞋");
xAxisList.add("袜子");
echartData.put("xAxisData", JSON.toJSONString(xAxisList));
List<Object> seriesList = new ArrayList<>();
Map<String, Object> seriesMap = new HashMap<>();
seriesMap.put("name", "销量");
seriesMap.put("type", "bar");
int[] arr = {5, 20, 36, 10, 10, 20};
seriesMap.put("data", arr);
seriesList.add(seriesMap);
echartData.put("series", JSON.toJSONString(seriesList));
//////////////////////////////////以上模拟数据取请求\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
//2.填充html
// 要使用的html模板
String htmlTemplate = "test_template.html";
// 要使用的html模板存放位置
String templatePath = "D:\\Code\\owntest\\keep-learn\\ruoyi-admin\\src\\main\\resources\\templates";
//临时文件名称
String htmName = "a";
// 临时 html 文件路径
String srcPath = saveHtml(echartData, htmlTemplate, templatePath, folderPath, htmName);
//3.执行html到pdf的转换
// pdf文档存储路径,以及名称
String destPath = folderPath + "\\1.pdf";
boolean convert = HtmlToPdfUtil.convert(srcPath, destPath);
// html转pdf成功,删除临时html
if (convert) {
Files.delete(new File(srcPath).toPath());
log.info("临时 {} DELETE SUCCESS", srcPath);
}
log.info("total time(ms)={}", System.currentTimeMillis() - startTime);
}
}
6. 效果预览
运行打印日志:
生成存放位置:
未填充前,模板预览:
最终生成的PDF:
7. 进行压缩
执行上述的 toZip()方法,可以进行压缩