HTML导出为PDF

常见方案

  1. 前端实现,html转canvas再转pdf(缺点:pdf中的文字不能复制,而且文字失真)。

  2. 后端实现,freemarker模板引擎+itextpdf(缺点:实现起来比较复杂,很多html中的css不兼容,导致样式严重错乱,如果想要更正样式需要更改itexpdf源码)。

  3. wkhtmptopdf(推荐)

    wkhtmltopdf基于WebKit渲染引擎将HTML内容转换为HTML页面,之后再转换成PDF,所以其转换后的PDF文件的显示效果可以和HTML页面基本保持一致,是一个相当完美的解决方案,美中不足的是他需要你安装软件,并不能像前两种解决方案那样以jar包的形式嵌入到项目中。

这里推荐使用方案3。

安装wkhtmltopdf

官网:https://wkhtmltopdf.org/downloads.html

参数文档:https://wkhtmltopdf.org/usage/wkhtmltopdf.txt

Github:https://github.com/wkhtmltopdf/packaging/releases/

Windows

添加环境变量

1
D:\Program Files\wkhtmltopdf\bin

Linux

Linux安装比较麻烦,以我们的服务器Centos7为例具体如下:

  1. 下载wkhtmltopdf文件wkhtmltox-0.12.6-1.centos7.x86_64.rpm
  2. 将文件导入到需要安装的服务器。
  3. 使用命令安装:rpm -ivh wkhtmltox-0.12.6-1.centos7.x86_64.rpm
  4. 安装过程会遇到安装依赖不存在的错误。
    1. yum search XXX(XXX为缺少的依赖)
    2. 安装搜索到的依赖,命令:yum install XXX
  5. 由于此时导出的pdf存在很多方块,继续执行:yum install urw-fonts libXext openssl-devel
  6. 此时测试发现还存在很多空白展示不正确,需要将windows中的msyh.ttc、msyhbd.ttc、msyhl.ttc复制到linux服务器/usr/share/fonts/msyh,如果没有则mkdir创建目录
1
2
3
4
5
6
yum install -y fontconfig mkfontscale
cd /usr/share/fonts/msyh
mkfontscale
mkfontdir
fc-cache -fv
source /etc/profile

执行:

1
fc-list :lang=zh

可以看到已经安装的中文字体。

使用

导出

本地HTML导出

1
wkhtmltopdf D:\html\test.html D:\html\test.pdf

设置页眉页脚

1
wkhtmltopdf --header-html D:\html\header.html --footer-html D:\html\footer.html  D:\html\test.html D:\html\test.pdf

注意

本地导出的时候引用的外部css和js并不会生效,要保证js和css都在html内。

导出在线网页

1
2
3
4
5
6
wkhtmltopdf https://www.psvmc.cn/ D:\html\test2.pdf
wkhtmltopdf https://www.baidu.com/ D:\html\test3.pdf
wkhtmltopdf https://www.psvmc.cn/zjtools/z/qrcode/index.html D:\html\test4.pdf
wkhtmltopdf https://www.psvmc.cn/zjtools/z/browserinfo/index.html D:\html\test5.pdf
wkhtmltopdf http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test6.pdf
wkhtmltopdf --header-html http://127.0.0.1:5572/source/zjtools/z/echart/header.html --footer-html http://127.0.0.1:5572/source/zjtools/z/echart/footer.html http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test6.pdf

注意

  • 导出在线网页的时候,外部引用的JS和CSS是生效的,但是页面不能有渐渐显示的动画,因为导出的是页面刚加载完的状态。
  • 不支持CSS3(不支持Flex布局、不支持vw和vh)
  • 不支持JS更改页面样式
  • Echarts也要取消动画效果 animation: false,

封面

1
cover <url>  //使用HTML文件作为封面。

测试

1
wkhtmltopdf --outline --outline-depth 2 --header-html http://127.0.0.1:5572/source/zjtools/z/echart/header.html --footer-html http://127.0.0.1:5572/source/zjtools/z/echart/footer.html cover http://127.0.0.1:5572/source/zjtools/z/echart/cover.html http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test6.pdf

目录

1
toc --toc-header-text "目录"

测试

1
wkhtmltopdf --header-html http://127.0.0.1:5572/source/zjtools/z/echart/header.html --footer-html http://127.0.0.1:5572/source/zjtools/z/echart/footer.html toc --toc-header-text "目录" http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test6.pdf

书签(侧边栏)

1
2
--outline  显示目录(文章中h1,h2来定)
--outline-depth <level> 设置目录的深度(默认为4)

测试

1
wkhtmltopdf --outline --outline-depth 2 --header-html http://127.0.0.1:5572/source/zjtools/z/echart/header.html --footer-html http://127.0.0.1:5572/source/zjtools/z/echart/footer.html http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test6.pdf

自定义页面边距

-B, --margin-bottom <unitreal> 下边距,单位毫米,默认10毫米

-L, --margin-left <unitreal> 左边距,单位毫米,默认10毫米

-R, --margin-right <unitreal> 右边距,单位毫米,默认10毫米

-T, --margin-top <unitreal> 上边距,单位毫米,默认10毫米

页面分页

添加如下样式的元素 后面的元素会换到下一页

1
2
3
.page {
page-break-after: always;
}

页眉页脚

纯文本

1
wkhtmltopdf --header-left "码客说有限公司" --header-right "[date] [time] 机密文件" --header-line --header-spacing 3 --footer-spacing 3 --footer-center "- 第 [page] 页-" http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test7.pdf

HTML

--header-html <url> 页眉内容,通过html自定义

--footer-html <url> 页脚内容,通过html自定义

wkhtmltopdf 将自动带上一些参数供页面使用:

1
'page', 'frompage', 'topage', 'webpage', 'section', 'subsection', 'date', 'isodate', 'time', 'title', 'doctitle', 'sitepage', 'sitepages'

header.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!DOCTYPE html>
<html>

<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<script>
function subst () {
var vars = {};
var query_strings_from_url = document.location.search.substring(1).split('&');
for (var query_string in query_strings_from_url) {
if (query_strings_from_url.hasOwnProperty(query_string)) {
var temp_var = query_strings_from_url[query_string].split('=', 2);
vars[temp_var[0]] = decodeURI(temp_var[1]);
}
}
var css_selector_classes = ['page', 'frompage', 'topage', 'webpage', 'section', 'subsection', 'date', 'isodate', 'time', 'title', 'doctitle', 'sitepage', 'sitepages'];
for (var class_name in css_selector_classes) {
if (css_selector_classes.hasOwnProperty(class_name)) {
var element = document.getElementsByClassName(css_selector_classes[class_name]);
for (var j = 0; j < element.length; ++j) {
element[j].textContent = vars[css_selector_classes[class_name]];
}
}
}
}
</script>
</head>

<body style="border:0; margin: 0;" onload="subst()">
<table style="border-bottom: 1px dashed black; width: 100%">
<tr>
<td class="section"></td>
</tr>
</table>
</body>

</html>

footer.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!DOCTYPE html>
<html>

<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<script>
function subst () {
var vars = {};
var query_strings_from_url = document.location.search.substring(1).split('&');
for (var query_string in query_strings_from_url) {
if (query_strings_from_url.hasOwnProperty(query_string)) {
var temp_var = query_strings_from_url[query_string].split('=', 2);
vars[temp_var[0]] = decodeURI(temp_var[1]);
}
}
var css_selector_classes = ['page', 'frompage', 'topage', 'webpage', 'section', 'subsection', 'date', 'isodate', 'time', 'title', 'doctitle', 'sitepage', 'sitepages'];
for (var class_name in css_selector_classes) {
if (css_selector_classes.hasOwnProperty(class_name)) {
var element = document.getElementsByClassName(css_selector_classes[class_name]);
for (var j = 0; j < element.length; ++j) {
element[j].textContent = vars[css_selector_classes[class_name]];
}
}
}
}
</script>
</head>

<body style="border:0; margin: 0;" onload="subst()">
<table style="border-top: 1px dashed black; width: 100%">
<tr>
<td style="text-align:center">
页码 <span class="page"></span> / <span class="topage"></span>
</td>
</tr>
</table>
</body>

</html>

页面大小

页面高度要排除页眉页脚

1
2
3
4
5
6
.page {
width: 210mm;
height: 275mm;
page-break-after: always;
background-color: azure;
}

单位毫米

1
2
3
4
5
6
7
--disable-smart-shrinking 页面不缩放 页面就不缩小了 很重要
--dpi 110 这个参数不要用默认值,要设置大一点

--page-width <unitreal> 页面宽度 (default unit millimeter)
--page-height <unitreal> 页面高度 (default unit millimeter)
// 或者
--page-size <size> 设置纸张大小: A4, Letter, etc.

测试

1
wkhtmltopdf --dpi 110 --disable-smart-shrinking --page-width 210 --page-height 297 --header-left "码客说有限公司" --header-right "[date] [time] 机密文件" --header-line --header-spacing 3 --footer-spacing 3 --footer-center "- 第 [page] 页-" http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test8.pdf

控制导出区域

1
2
--print-media-type              Use print media-type instead of screen  使用打印媒体类型代替屏幕
--no-print-media-type Do not use print media-type instead of screen (default) 不要使用打印媒体类型来代替屏幕(默认)

页面中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<style media="print">
//这表示是在打印时的样式
.noprint {
display: none;
font-size:16px;
COLOR: red;
}
</style>
<style media="screen">
//这表示是在屏幕显示时的样工
.print {
font-size:14px;
COLOR: green;
}
</style>

页面加载完成判断

由于页面加载完成需要一定时间,wkhtml提供了两种方式来等待页面加载完成:延时和页面状态判断。两者使用一种即可,都配置的话页面状态判断方式生效。

延时

通过配置项:--javascript-delay <msec> 实现,msec单位为毫秒。

页面状态

通过配置项:--window-status <windowStatus> 实现,其中 windowStatus 为html页面上的 window.status 值。

1
--window-status completed

然后在ajax完成回调时 ,

1
window.status = "completed";

这样的话,就会完全支持异步调用。

注意:

封面页面和内容页面都要设置,页眉和页脚不用设置。

全部配置

1
wkhtmltopdf --dpi 110 --disable-smart-shrinking --page-width 210 --page-height 297 --window-status completed --header-left "码客说有限公司" --header-right "[date] [time] 机密文件" --header-line --header-spacing 3 --footer-spacing 3 --footer-center "- 第 [page] 页-" cover http://127.0.0.1:5572/source/zjtools/z/echart/cover.html toc --toc-header-text "目录" http://127.0.0.1:5572/source/zjtools/z/echart/index.html D:\html\test10.pdf

注意

cover 要放在 toc 的前面,这两个的顺序决定了生成的顺序。

Java代码

HtmlToPdfInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package wkhtmltopdf;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;


public class HtmlToPdfInterceptor extends Thread {
private InputStream is;

public HtmlToPdfInterceptor(InputStream is){
this.is = is;
}

public void run(){
try{
InputStreamReader isr = new InputStreamReader(is, "utf-8");
BufferedReader br = new BufferedReader(isr);
String line = null;
while ((line = br.readLine()) != null) {
System.out.println(line.toString()); //输出内容
}
}catch (IOException e){
e.printStackTrace();
}
}
}

HtmlToPdf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package wkhtmltopdf;

import java.io.File;

public class HtmlToPdf {
//wkhtmltopdf在系统中的路径
private static final String toPdfTool = "D:\\wkhtmltopdf\\bin\\wkhtmltopdf.exe";

/**
* html转pdf
* @param srcPath html路径,可以是硬盘上的路径,也可以是网络路径
* @param destPath pdf保存路径
* @return 转换成功返回true
*/
public static boolean convert(String srcPath, String destPath){
File file = new File(destPath);
File parent = file.getParentFile();
//如果pdf保存路径不存在,则创建路径
if(!parent.exists()){
parent.mkdirs();
}

StringBuilder cmd = new StringBuilder();
cmd.append(toPdfTool);
cmd.append(" ");
cmd.append(" --header-line");//页眉下面的线
cmd.append(" --header-center 这里是页眉这里是页眉这里是页眉这里是页眉 ");//页眉中间内容
//cmd.append(" --margin-top 30mm ");//设置页面上边距 (default 10mm)
cmd.append(" --header-spacing 10 ");// (设置页眉和内容的距离,默认0)
cmd.append(srcPath);
cmd.append(" ");
cmd.append(destPath);

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;
}
public static void main(String[] args) {
HtmlToPdf.convert("http://www.cnblogs.com/xionggeclub/p/6144241.html", "d:/wkhtmltopdf.pdf");
}
}

其他环境

Java:https://github.com/jhonnymertz/java-wkhtmltopdf-wrapper

NodeJS:https://github.com/devongovett/node-wkhtmltopdf

C#:https://github.com/codaxy/wkhtmltopdf

GO:https://github.com/SebastiaanKlippert/go-wkhtmltopdf

Python:https://github.com/JazzCore/python-pdfkit

小知识

判断是否支持Flex

JS中判断

JS里也提供了Window.CSS.supports()方法,用来检查浏览器对css3属性是否支持:

使用两个参数:一个是属性名,另一个是属性值 。

1
var supportsFlex = CSS.supports("display", "flex");

REM

1
2
//判断是否支持rem单位
var supportsRem = CSS.supports("width","1rem");

CSS中判断

1
2
3
4
5
@supports ( display: flex ) {
body {
display: flex;
}
}

其它语法

1
2
3
4
5
6
7
8
9
10
/* 支持Flex布局 */
@supports (display: flex) {}
/* 不支持Flex布局 */
@supports not (display: flex) {}
/* 同时支持Flex布局和Grid布局 */
@supports (display: flex) and (display: grid) {}
/* 支持Flex布局或者支持Grid布局 */
@supports (display: flex) or (display: grid) {}

@supports (display: flex) and (display: grid) and (gap: 0) {}

支持Flex

但是wkhtmltopdf不支持这种方式。

https://github.com/skin2skin/flex-native/blob/master/README-zh_CN.md

在普通的HTML中使用

1
<script src="https://unpkg.com/flex-native@1.2.0/dist/flex.native.min.js"></script>

在模块化中使用

1
import('flex-native');

使用时请在CSS中的任何display: flex声明之前添加一个 -js-display: flex声明, 或在构建过程中使用PostCSS Flexibility自动添加-js前缀。

CSS中

1
2
3
4
5
6
.wrapper{
-js-display:flex;
display:flex;
align-items:center;
justify-content:center;
}

元素上

1
<div style='display:flex;align-items:center' />

如下的方式是行不通的

本来想不支持Flex,只要使用JS来兼容Flex,但是实际测试是行不通的。

JS中判断引用JS

1
2
3
4
5
6
7
8
9
<script type="text/javascript">
var supportsFlex = CSS.supports("display", "flex");
if(!supportsFlex){
var flex_element = document.createElement("script");
flex_element.setAttribute("type", "text/javascript");
flex_element.setAttribute("src", "https://unpkg.com/flex-native@1.2.0/dist/flex.native.min.js");
document.body.appendChild(flex_element);
}
</script>