最近在业务上遇到了一个问题是要将页面打印输出成pdf文件,通过点击一个按钮,就能够将页面写在一个pdf上,并下载下来,需要保证pdf的内容具有很好的可读性。
经评估要实现这个需求,一种可行的方案是将HTML页面转为PDF,并实现下载。通过技术调研,最终的方案确定为通过html2canvas + jspdf这两个库来实现,通过使用html2canvas提供的方法,将页面元素转为base64图片流,然后将其插入jspdf插件中,实现保存并下载pdf。
html2canvas + jspdf方案是前端实现页面打印的一种常用方案,但是在实践过程中,遇到的最大问题就是分页截断的问题:当页面元素超过一页A4纸的时候,连续的页面就会因为分页而导致内容被截断,进而影响了pdf的可读性。
由于网上关于分页截断的解决思路比较少,所以特意将此次的解决方案记录下来。
首先,我们开始使用 JSPDF 和 html2canvas 生成一个简单的 PDF文件。
创建一个 JSPDF 实例,设置页面的大小、方向和其他参数。参考官网可以写一个很简单的实例
var doc = new jsPDF({
orientation: 'landscape',
unit: 'in',
format: [4, 2]
}
doc.text('Hello world!', 1, 1)
doc.save('two-by-four.pdf')
生成一个pdf文件,并且在文件中写入一定内容,其实JSPDF这个库就能做到。
但是很多业务场景下,我们的目标内容会更复杂,而且还要考虑样式,所以最好的方式是引入html2canvas这个库,将页面元素转换成base64数据,然后贴在pdf中(使用addImage方法),这样就能保证页面的内容。
引入了html2canvas库后,我们更多关注是利用现成组件库、框架或者原生html和css实现更复杂的页面内容。
使用 html2canvas 捕捉 HTML 内容或特定的 HTML 元素,并将其转换为 Canvas。其中,html2canvas 函数的主要用法是:
html2canvas(element, options);
以下是一些常见的配置选项:
下面是一个简单的demo,可以看到html2canvas能够将dom元素转化为一张base64图片,将鼠标选中元素,可以感受到图片和文字的不同。
<div id="capture" style="padding: 10px; background: #f5da55">
<h4 style="color: #000; ">Hello world!</h4>
</div>
html2canvas(document.querySelector("#capture")).then(canvas => {
document.body.appendChild(canvas)
});
Untitled.png
这一步我们需要使用JSPDF 的addImage方法,其语法如下:
addImage(imageData, format, x, y, width, height, alias, compression)
下面是一串示例代码:
import jsPDF from 'jspdf';
export default function addImageUsage() {
const doc = new jsPDF();
const imageData = 【替换成base64数据流】;
doc.addImage(imageData, 'png', 0, 0, 10, 10);
doc.addImage(imageData, 'png', 100, 100, 10, 10);
doc.addImage(imageData, 'png', 200, 200, 10, 10);
drawNet(doc);
doc.save('output.pdf');
}
const drawNet = (doc) => {
const gap = 10;
const start = [0, 0];
const end = [595.28, 841.89];
// 所有横线
for (let i = start[0]; i < end[0]; i = i + gap) {
doc.line(i, 0, i, end[0]);
}
// 所有纵线
for (let j = start[1]; j < end[1]; j = j + gap) {
doc.line(0, j, end[1], j);
}
};
此示例将在 PDF 文档(默认是A4纸大小,宽高为[595.28, 841.89]像素)的 (10, 10) 、(100, 100) 、(200, 200) 坐标处,添加一张png 图像。图像的宽度和高度将分别为 10 和 10 像素,为了了解pdf中的坐标系统,此示例还在pdf文档中生成了间距为10px的网格系统。
了解了上面的三个关键点,接下来我们将这三个步骤串联起来,实现一个基本的html→pdf的方案。大致步骤如下:
基于这5个步骤,可以实现基本的页面打印。
import html2canvas from 'html2canvas';
import jsPDF, { RGBAData } from 'jspdf';
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement) {
if (!element) return { width: 0, height: 0 };
// canvas元素
const canvas = await html2canvas(element, {
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true // 允许跨域
});
// 获取canvas转化后的宽高
const { width: canvasWidth, height: canvasHeight } = canvas;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
return { width: canvasWidth, height: canvasHeight, data: canvasData };
}
/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
*/
export async function generatePDF({
/** pdf内容的dom元素 */
element,
/** pdf文件名 */
filename
}) {
if (!(element instanceof HTMLElement)) {
return;
}
const pdf = new jsPDF();
// 一页的高度, 转换宽度为一页元素的宽度
const {
width: imageWidth,
height: imageHeight,
data
} = await toCanvas(element);
// 添加图片
function addImage(
_x: number,
_y: number,
pdfInstance: jsPDF,
base_data:
| string
| HTMLImageElement
| HTMLCanvasElement
| Uint8Array
| RGBAData,
_width: number,
_height: number
) {
pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height);
}
addImage(0, 0, pdf, data!, imageWidth, imageHeight);
return pdf.save(filename);
}
通常,在我们的实践中,会发现2个问题:
这两个问题的解决方案是等比例缩放+循环移位:
通过比例缩放,实现页面内容等比例展示在pdf文档中
令页面元素的宽高为x, y(转化成canvas图片的宽高),pdf文档的宽高为w, h。因为高度可以通过加页延伸,所以可以按照宽度进行缩放,缩放后的图片高度可以通过下列公式计算
如果页面的高度超出了pdf文档的高度,即y > h,使用addPage方法添加一页即可。但是在新的一页中,我们的图片内容的高度需要调整。
假设y = 2 * h,这意味我们需要两页才能完整得展示页面内容。在一页pdf中,图片在起始位置插入即可,即
PDF.addImage(pageData, 'JPEG', 0, 0, x, y)// 注意x,y 是缩放后的大小
在第二页pdf中,图片的纵向位置需要调整一页pdf的高度,即
PDF.addImage(pageData, 'JPEG', 0, -h, x, y)// 注意x,y 是缩放后的大小
通过循环计算剩余高度,然后不停调整纵向位置移动base64的图片位置,可以解决多页的问题。
尽管 JSPDF 和 html2canvas 是功能强大的工具,但是他们也有很多槽点,比如得手动分页,手动处理分页截断的问题。等你实践到这一步,就开始面临分页截断的问题,类似的问题也有网友在Github上提出,但是底下依然没有很好的解决思路。
好在掘金上有人分享了一个不错的方法:
jsPDF + html2canvas A4分页截断 完美解决方案(含代码 + 案例) - 掘金
概括一下,其处理分页截断的原理就是在使用addImage之前,将html进行分页,通过维护一个高度位置数据,来记录每次循环迭代addImage的位置。
从高到低遍历维护一个分页数组pages,该数组记录每一页的起始位置,如:pages[0] 对应 第一页起始位置,pages[1] 对应第二页起始位置
Untitled2.png
接下来我们重点讨论如何将页面进行切割,然后生成pages这个数组。
假设页面的高度是1500,pdf宽高是[500, 900],如果不用处理分页截断的问题,我们可以想到第一页(0-900)是用来承载页面从高度为0到900的信息;
第二页(900-1800)是用来承载页面从高度900到1500的,所以pages数组为[0, 900]。
如果要处理分页截断呢,这时候就需要计算页面元素的距离pdf文档起始位置的高度h1,以及该元素的内部高度h2,通过这两个高度来判断这个元素要不要放在下一页,防止截断,示意图如下:
Untitled4.png
如果h1 + h2 > 页面高度, 这时候说明这个元素不处理的就会被分页截断,所以应该要把这个元素放到第二页去渲染,这就意味着pages记录的数据要变化,示意图如下,可以看到pages[1]我们往上调整了,比第二页pdf的起始位置更高。
Untitled5.png
说明渲染第二页pdf的时候,要从h1开始渲染,pages数组为[0, h1],解释为第一页pdf渲染页面高度区域为0-900, 第二页pdf渲染html高度区域为h1-1500。注意到第一页渲染的时候到尾部的时候,会有部分内容和第二页头部内容重合。因为h1到900这部分的内容肯定会渲染,这部分内容一直都是页面元素,我们改变pages[1]的值的原因只是创建一个副本,让页面看起来内容没有被截断。
为了解决这个问题(为了美观),我们用填充一块白色区域遮掉它!此处使用jspdf的rect和setFillColor方法,把重合的区域遮白处理。
pdf.setFillColor(255, 255, 255);
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F');
上面我们谈到了h1和h2,其中h1是元素盒子的上边距到打印区域的高度(比例缩放后的高度),h2是元素盒子的内部高度。
计算h1: getBoundingClientRect方法
const rect = contentElement.getBoundingClientRect() || {};
const topDistance = rect.top;
return topDistance;
Untitled6.png
计算h2:offsetHeight方法
Untitled7.png
值得注意的是,因为打印区域的html元素不一定是从窗口顶部开始,所以为了计算实际的h1(元素到打印区域的顶部距离),可以采用这样的方法:
// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所以要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map((item) => item - pages[0]);
上述即是在实现前端页面生成pdf的过程中遇到的问题,以及解决思路。
为了更直观得感受效果,本文也给出了不同场景(单页、多页、多页截断、自定义页眉页脚、横向)下的pdf生成效果,可以通过此链接体验:https://pdf-demo-phi.vercel.app/
此demo的源代码如下:pdf-demo
与现有文章不同的是,本仓库的代码特点在于:
作者:燕平
来源:微信公众号:Goodme前端团队
出处:https://mp.weixin.qq.com/s/-1nA-VI6kmgqHRcYs_NZFA
示:本篇文章内容建议使用电脑浏览器查阅。
今天把之前关于Flask_Blog项目中关于帖子的主页展示进行分页,并实现点击用户名称,显示该用户所发的帖子信息和数量。接下来开始:
修改Flask_Blog\flaskblog\routes.py,修改home方法使用分页查询方式,每页显示两条数据:
添加根据用户姓名查出所发帖子列表方法:
修改Flask_Blog\flaskblog\templates\home.html,添加点击用户名跳转到用户所发帖子连接,添加分页html代码:
在Flask_Blog\flaskblog\templates新建一个文件user_posts.html:
然后我们在文件夹Flask_Blog打开命令行cmd,运行python run.py,访问http://127.0.0.1:5000/:
一共加了8条数据,所以分了4页,鼠标点击第2页,显示第二页内容:
点击帖子的用户名test,查看test用户的发帖信息:
今天关于帖子的分页功能实现就到这里!
关注公号yale记
下面的是我的公众号二维码图片,欢迎关注。
开发过程中,经常做的一件事,也是最基本的事,就是从数据库中查询数据,然后在客户端显示出来。当数据少时,可以在一个页面内显示完成。然而,如果查询记录是几百条、上千条呢?直接一个页面显示完全的话,表格得多长啊。。。。。。这时,我们可以用分页技术。
何为分页?效果图如下:
这里总共查询了100条记录,如果一次性显示的话表格会很多行,用户体验不佳。而我们采用分页显示的话,一页显示10条记录,共十页。用户可以自行翻阅,记录少,清晰显示。
下面谈谈分页效果的实现,思路有三种:
其一:纯JS实现分页。一次性查询记录并加载到html的table中。然后通过选择性地显示某些行来达到分页显示的目的。这是一种伪分页,障眼法而已。只能用于数据少的情况下。一旦数据多了,十几万条数据加载到html中会变得很慢。而且不实时,一次加载完后数据就写死在页面了,若数据库中有变化,浏览器端显示的仍是上次加载过来的数据。
首先:用table来显示查询出来的记录们,全部显示。
<table width="500" id="idData">
<%
String user_id, user_name, user_sex, user_phone, user_age;
while (sqlRst.next()) {
user_id = sqlRst.getString(1);
user_name = sqlRst.getString(2);
user_sex = sqlRst.getString(3);
user_phone = sqlRst.getString(4);
user_age = sqlRst.getString(5);
%>
<tr>
<td><%=user_id%></td>
<td><%=user_name%></td>
<td><%=user_sex%></td>
<td><%=user_phone%></td>
<td><%=user_age%></td>
</tr>
<%
}
%>
</table>
<br/>
<table width="60%" align="right">
<tr><td><div id="changePages" name="changePages"></div></td></tr>
</table>
然后,在JS中修改table中某些行显示,某些行隐藏。
<script type="text/javascript">
function goPage(pno,psize){
var itable = document.getElementById("idData");//获取table
var num = itable.rows.length;//得到记录总数
var totalPage = 0;
var pageSize = psize;//一页显示pageSize条记录
//计算总页数
if(num/pageSize > parseInt(num/pageSize)){
totalPage=parseInt(num/pageSize)+1;
}else{
totalPage=parseInt(num/pageSize);
}
//当前页数
var currentPage = pno;
//获取当前页第一条、最后一条记录的行号
var startRow = (currentPage - 1) * pageSize+1;
var endRow = currentPage * pageSize;
endRow = (endRow > num)? num : endRow;
//修改table中当前页对应的行的属性为显示,非本页的记录为隐藏
for(var i=1;i<(num+1);i++){
var irow = itable.rows[i-1];
if(i>=startRow && i<=endRow){
irow.style.display = "block";
}else{
irow.style.display = "none";
}
}
//分页页码列表
var tempStr = "共"+num+"条记录 分"+totalPage+"页 当前第"+currentPage+"页";
if(currentPage>1){
tempStr += "<a href=\"#\" onClick=\"goPage("+(1)+","+psize+")\">首页</a>";
tempStr += "<a href=\"#\" onClick=\"goPage("+(currentPage-1)+","+psize+")\"><上一页</a>"
}else{
tempStr += "首页";
tempStr += "<上一页";
}
if(currentPage<totalPage){
tempStr += "<a href=\"#\" onClick=\"goPage("+(currentPage+1)+","+psize+")\">下一页></a>";
tempStr += "<a href=\"#\" onClick=\"goPage("+(totalPage)+","+psize+")\">尾页</a>";
}else{
tempStr += "下一页>";
tempStr += "尾页";
}
document.getElementById("changePages").innerHTML = tempStr;
}
</script>
其二:一次查询,分批显示。
就是说,我们可以执行一个数据库查询操作,得到结果集rs。然后,通过指针的移动来显示当前页面的记录。这样,就可以以 rs.absolute(当前页面号*每页记录数)定位到当前页的第一条记录,然后通过while循环显示n条记录(n为每页显示记录数)。在跳页时,只需修改currentPage,即可在重定位到下一页时把当前页面号改掉,重新定位记录指针,通过while遍历显示n条记录。与JS选择性显示不同,这里是选择性遍历。与JS分页不同的是,这里分页每次跳页修改的是遍历的指针,每次跳页都要进行一次全面查询。同样地,不适合大数据量查询。这里比JS分页优化的地方在于——实时性。每次跳页都会查询一次数据库,保证数据的实时性。
参考代码:
<%
int intPageSize = 10; //一页显示的记录数 int intRowCount; //记录总数
int intPageCount; //总页数
String strPage; //从表单或URL传送的待显示页码
int intPage; //待显示页码 ,由strPage转换成的整数
//---计算记录总数的第一种方法:查询出所有记录,移动结果集指针到最后一条,获取最后一条记录的行号
//查询所有数据 ResultSet sqlRst = sqlStmt.executeQuery("select * from user");
//获取记录总数
sqlRst.last(); //光标在最后一行
intRowCount = sqlRst.getRow(); //获得当前行号,即总记录数
//记算总页数
intPageCount = (int)Math.ceil(intRowCount/(intPageSize*1.0));
//将记录指针定位到待显示页的第一条记录上
sqlRst.absolute((intPage - 1) * intPageSize + 1);
//显示数据
int i=0;
String user_id, user_name, user_sex, user_phone, user_age;
while (i < intPageSize && !sqlRst.isAfterLast()) {
user_id = sqlRst.getString(1);
user_name = sqlRst.getString(2);
user_sex = sqlRst.getString(3);
user_phone = sqlRst.getString(4);
user_age = sqlRst.getString(5);
%>
<tr>
<td><%=user_id%></td>
<td><%=user_name%></td>
<td><%=user_sex%></td>
<td><%=user_phone%></td>
<td><%=user_age%></td>
</tr>
<%
sqlRst.next();//移动记录指针指向下一条记录
i++;//统计当前页已显示多少条记录
}
%>
其三:在服务端分页。跳到第n页才查询、显示第n页内容。要点就是根据客户端表格的“页面”计算出数据库要查询的当前页面的第一条记录的位置。优点:实时性:跳页才查询。数据量小:只加载当前页的记录进行显示。
重点在于两条语句:select count(*) from ...:查询得到记录总条数
select * from .. limit pageNo,rowsCount:查询从第pageNo条开始的rowsCount条数据。
int pages=0; //待显示页面
int count=0; //总条数
int totalpages=0; //总页数
int limit=10; //每页显示记录条数
//计算记录总数的第二种办法:使用mysql的聚集函数count(*)
ResultSet sqlRst = sqlStmt.executeQuery("select count(*) from user");
if(sqlRst.next()){
count = sqlRst.getInt(1);//结果为count(*)表,只有一列。这里通过列的下标索引(1)来获取值
}
//由记录总数除以每页记录数得出总页数
totalpages = (int)Math.ceil(count/(limit*1.0));
//获取跳页时传进来的当前页面参数
String strPage = request.getParameter("pages");
//判断当前页面参数的合法性并处理非法页号(为空则显示第一页,小于0则显示第一页,大于总页数则显示最后一页)
if (strPage == null) {
pages = 1;
} else {
try{
pages = java.lang.Integer.parseInt(strPage);
}catch(Exception e){
pages = 1;
}
if (pages < 1){
pages = 1;
}
if (pages > totalpages){
pages = totalpages;
}
}
//由(pages-1)*limit算出当前页面第一条记录,由limit查询limit条记录。则得出当前页面的记录
sqlRst = sqlStmt.executeQuery("select * from user order by user_id limit " + (pages - 1) * limit + "," + limit);
while (sqlRst.next()){//遍历显示}
跳页的实现:跳页是通过重定向来实现的,通过向当前网页传进待显示的pages,在跳转后根据pages重新算出页面显示的第一条,查limit条显示。
<form name="f1" method="POST" action="index.jsp" onSubmit="return checknum()">
<table border="0" align="center" >
<tr>
<td>第<%=pages%>页 共<%=totalpages%>页 <a href="index.jsp?pages=1">首页</a></td>
<td><a href="index.jsp?pages=<%=(pages<1)?pages:(pages-1) %>"> 上一页</a></td>
<td><a href="index.jsp?pages=<%=(pages>=totalpages)?totalpages:(pages+1)%>"> 下一页</a></td>
<td><a href="index.jsp?pages=<%=totalpages%>">最后一页</a></td>
<td>转到第:<input type="text" name="page" size="8">页<input type="submit" value="GO" name="cndok"></td>
</tr>
</table>
</form>
附:常见数据库分页查询语句
1.oracle数据库分页
select * from (select a.*,rownum rc from 表名 where rownum<=endrow) a where a.rc>=startrow
2.DB2数据库分页
Select * from (select rownumber() over() as rc,a.* from (select * from 表名 order by列名) as a) where rc between startrow and endrow
3.SQL Server 2000数据库分页
Select top pagesize * from 表名 where 列名 not in(select top pagesize*page 列名 from 表名 order by列名) order by列名
4.SQL Server 2005数据库分页
Select * from (select 列名,row_number() over(order by 列名1) as 别名from 表名) as t where t.列名1>=startrow and t.列名1<=endrow
5.MySQL数据库分页
Select * from 表名 limit startrow,pagesize
(Pagesize为每页显示的记录条数)
6.PostgreSQL数据库分页
Select * from 表名 limit pagesize,offset startrow
(Pagesize为每页显示的记录条数)
转载请注明原文地址:http://www.cnblogs.com/ygj0930/p/6134851.html
*请认真填写需求信息,我们会在24小时内与您取得联系。