How to support dynamic page header with openhtmltopdf?
openhtmltopdf是一个纯Java的转换
静态HTML页面为PDF的轻量级库,很适合输出静态页面为PDF文件,即将静态的、样式正确的 HTML/CSS 高质量地转换为 PDF。
但是openhtmltopdf不能完整解释HTML/CSS,因此存在诸多的限制:
无 DOM 操作(最关键的限制):在 PDF 生成过程中,无法修改 已解析和布局好的 DOM 结构。这意味着你不能用 document.createElement, appendChild, innerHTML 或类似的方法来动态添加、删除或修改页面元素,并期望这些变化会反映在最终的 PDF 上。
举例来说,我们可以使用 @Page 的 content element(header) 和 running(header)来实现页眉,例如:
<html>
<head>
<style>
@page {
size: A4;
margin: 2cm;
@top-center {
content: element(header);
}
@bottom-center {
content: "第 " counter(page) " 页 / 共 " counter(pages) " 页";
}
}
div.header {
display: block;
position: running(header);
text-align: center;
font-weight: bold;
font-size: 14px;
}
div.content {
font-size: 12px;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="header">我的文档页眉</div>
<div class="content">
<p>这里是正文,会根据内容自动分页。</p>
<p>...</p>
<p>很多很多内容...</p>
</div>
</body>
</html>
但是这个方法有个很大的缺点,就是报表内容体部分(content)和header部分内容如果是动态的话,会和页眉部分重叠!如果生成的页面是完全静态的的,则没有这个问题,但是如果使用thymeleaf 的动态文本语法例如 <p th:text="${line1}"></p>就会导致重叠问题。
<html xmlns:th="http://www.thymeleaf.org">
<head>
<style>
@page {
size: A4;
margin: 2cm;
border: 1px solid black;
@top-center {
content: element(header);
}
@bottom-center {
content: "第 " counter(page) " 页 / 共 " counter(pages) " 页";
}
}
div.header {
display: block;
position: running(header);
text-align: center;
font-weight: bold;
font-size: 14px;
}
div.content {
font-size: 12px;
line-height: 1.5;
}
body {
padding: 0;
margin: 0;
font: 14px serif;
font-family: "MingLiU", serif;
margin: 0;
padding: 3px 10px;
}
</style>
</head>
<body>
<div class="header">
<table>
<tr>
<td style="width: 45%;border-right: 1px solid black; border-bottom: 1px solid black; text-align: center; vertical-align: middle; font-weight: bold;">
<p th:text="${line1}"></p>
<p th:text="${line2}"></p>
<p th:text="${line3}"></p>
</td>
<td style="width: 55%;border-bottom: 1px solid black;">
<table>
<tr th:each="row : ${headerRows}">
<td style="width: 35%" th:text="${row[1]}"></td>
<td th:text="${row[2]}"></td>
</tr>
</table>
</td>
</tr>
</table>
<table style="border-bottom: 1px solid black">
<tr th:each="row : ${subHeaderRows}">
<td style="width:35%" th:text="${row.left}"></td>
<td style="width:65%" th:text="${row.right}"></td>
</tr>
</table>
</div>
<div class="content">
<p>这里是正文,会根据内容自动分页。</p>
<p>...</p>
<p class="cjk" style="page-break-before: always;">第二页内容</p>
<p class="cjk" style="page-break-before: always;">第二页内容</p>
<p class="cjk" style="page-break-before: always;">第二页内容</p>
<p>很多很多内容...</p>
</div>
</body>
</html>

当然,我们可以把content的margin用代码设置为页眉之下,例如:
<style th:text="'@page { margin: 100pt 30pt 40pt 30pt; }'"></style>
但是这样处理后,无法支持动态高度的header,即如果header的总体高度是这个,我们可以用上面的方法搞定。另外,由于内容体,可能有很多,可能需要自动分页,类似Chrome中打印预览表格一样,内容被分成多个页面,每个页面都需要打印表头。如果使用Margin,那么第一页是OK的,后面的页就不正确了,仍然会重叠。
对于这个需求,我们有多个解决方案:
- 每次动态生成整个页面,即把thymeleaf的模板页页动态生成并填充数据,形成完整静态页面,这个可以解决,但是无法利用Thymeleaf的模板优势了。如果客户需要调整模板页面,则代码也需要修改,不是很方便;
- 自己手动分页,然后每页填充整个页眉和分页的内容。实现复杂度高,技术难度高。并且有个缺点,即如果在预览的时候,是分成多页的,每页都是一样的页眉页脚。而实际上,在HTML中查看的时候,我们只是开始有个页眉内容,内容体中的长度是可短可长,不需要分成真正的多页显示的。就是说HTML中查看,应该就是一个普通的页面而已,只是打印的时候,才需要有页眉。如下图:

这里使用一个巧妙的方法,即利用表格,打印的时候,每页重复表头的方式来实现完美的动态支持动态页眉并且利用Thymeleaf的模板优势。如果header高度不固定,我们可以使用下面的方法实现:
利用表头在打印的时候,每页会自动重复来实现该功能。
其中,关键点:
-fs-table-paginate: paginate;
-fs-page-break-min-height: 1.5cm;
完整示例代码如下:
<html xmlns:th="http://www.thymeleaf.org">
<head>
<style>
@page {
size: A4;
border: 1px solid black;
@bottom-left {
content: "Printed by: System \A Printed on: 2025-06-19";
white-space: pre;
font: 10px Helvetica;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font: 10px Helvetica;
}
}
table {
width: 100%;
border-collapse: collapse;
-fs-table-paginate: paginate;
-fs-page-break-min-height: 1.5cm;
}
td {
padding: 3px 10px;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: break-word;
white-space: normal;
}
body {
padding: 0;
margin: 0;
font: 14px serif;
font-family: "MingLiU", serif;
}
</style>
</head>
<body>
<table>
<thead>
<tr>
<th>
<table>
<tr>
<td style="width: 45%;border-right: 1px solid black; border-bottom: 1px solid black; text-align: center; vertical-align: middle; font-weight: bold;">
<p th:text="${line1}"></p>
<p th:text="${line2}"></p>
<p th:text="${line3}"></p>
</td>
<td style="width: 55%;border-bottom: 1px solid black;">
<table>
<tr th:each="row : ${headerRows}">
<td style="width: 35%" th:text="${row[1]}"></td>
<td th:text="${row[2]}"></td>
</tr>
</table>
</td>
</tr>
</table>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="content">
<span class="cjk" th:text="${title}"></span><span th:text="${subTitle}"></span>
<p th:each="p : ${para1}" th:text="${p}"></p>
<p class="cjk" th:utext="${pppp}"></p>
<p class="cjk" style="page-break-before: always;">第二页内容,
<div style="color:red">红色内容</div>
</p>
<p th:each="p2 : ${para2}" th:text="${p2}"></p>
<p class="cjk" style="page-break-before: always;">第三页内容</p>
<p th:each="p2 : ${para3}" th:text="${p2}"></p>
</div>
</td>
</tr>
</tbody>
</table>
</body>
</html>
生成效果如下,完美解决动态页眉:
需要注意的是,上图中,可以看到有些字符变成了 # 号字符(例如红色内容,变成了#色内容),这是因为字体的原因,如果是繁体字符,需要配套 Minliu.ttf 字库,如果是简体中文,需要配套简体中文字库。在ThymeLeaf模板中的font-family中的名字,和Java代码中useFont(font, "xxx")要一致。例如: