SpringBoot填充HTML生成pdf,并压缩为zip文件

710 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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

image.png

二、安装wkhtmltopdf

windows安装

  1. 就正常win软件安装,选择一个你要安装的位置: image.png
  2. 测试

在安装bin目录下执行命令进行测试:wkhtmltopdf.exe https://www.baidu.com/ D:\wkhtmltopdf\bin\1.pdf

image.png

生成的pdf如下:

image.png

注意事项:

如果是转换本地HTML页面,可能会出现图片禁止访问类型的错误,可加上以下命令进行转换,这是因为wkhtmltopdf升级版本之后默认禁止了访问。

允许访问需要加命令参数:--enable-local-file-access

整合后命令:wkhtmltopdf.exe --enable-local-file-access D:\wkhtmltopdf\bin\1.html D:\wkhtmltopdf\bin\1.pdf

linux安装

  1. 首先上传安装包至服务器中,我的目录位置为:/tools/module

  2. 安装依赖:

    • yum -y install libXrender* libfontconfig* libXext*
    • yum install xorg-x11-fonts-75dpi.noarch
  3. 安装:rpm -ivh wkhtmltox-0.12.6-1.centos7.x86_64.rpm

  4. 配置环境变量

首先打开vim /etc/profile,然后再最后加入以下内容:

image.png

接着刷新环境 source /etc/profile

ps:如果要卸载:rpm -e wkhtmltox(不可以直接在ftp服务器中手动删除相关文件)。 wkhtmltopdf默认解压路径:/usr/local/bin/wkhtmltopdf

  1. 测试 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. 效果预览

运行打印日志: image.png

生成存放位置: image.png

未填充前,模板预览: image.png

最终生成的PDF: image.png

7. 进行压缩

执行上述的 toZip()方法,可以进行压缩

参考:博主橙子の大人 & 博主阿尔法哲