Django 结合Vue实现前端页面导出为PDF

Django结合Vue实现前端页面导出为PDF

by:授客 QQ:1033553122

 

测试环境

Win 10

Python 3.5.4

Django-2.0.13.tar.gz

官方下载地址:

https://www.djangoproject.com/download/2.0.13/tarball/

pdfkit-0.6.1.tar.gz

下载地址:

https://pypi.org/project/pdfkit/

https://files.pythonhosted.org/packages/a1/98/6988328f72fe3be4cbfcb6cbfc3066a00bf111ca7821a83dd0ce56e2cf57/pdfkit-0.6.1.tar.gz  

django REST framework-3.9.4

下载地址:

https://github.com/encode/django-rest-framework

wkhtmltox_v0.12.5.zip

下载地址:

https://wkhtmltopdf.org/downloads.html

https://downloads.wkhtmltopdf.org/0.12/0.12.5/wkhtmltox-0.12.5-1.msvc2015-win64.exe  

axios 0.18.0  

echarts 4.2.1  

element-ui: 2.8.2  

Vue 3.1.0  

 

需求描述

如下,要将一个包含echarts图表,elementUI table的测试报告页面导出为PDF文档,页面包含以下类型的元素

 

解决方案

最开始采用“html2canvas和jsPDF”直接前端导出,发现存在问题,只能导出可视区内容,并且是类似截图一样的效果,无法获取翻页数据,然后考虑后台导出,前端通过js获取报告容器元素innerHtml,传递给后台,后台根据这个html元素导出为pdf,发现还是存在问题,echarts图片无法导出,另外,翻页组件等也会被导出,还有就是表格翻页数据无法获取,页面样式缺失等。

最终解决方案:

后台编写好html模板(包含用到的样式、样式链接等),收到请求时读取该模板文件为html文本。从数据库读取前端用到的表格数据,然后替换至模板中对应位置的模板变量;通过echars api先由 js把echarts图表转为base64编码数据,然后随其它导出文件必要参数信息发送到后台,后台接收后转base64编码为图片,然后替换模板中对应的模板变量,这样以后,通过pdfkit类库把模板html文本导出为pdf。最后,删除生成的图片,并且把pdf以blob数据类型返回给前端,供前端下载。

 

pdfkit api使用简介

基础用法

import pdfkit

 

pdfkit.from_url('https://www.w3school.com.cn, 'out.pdf')

pdfkit.from_file('test.html', 'out.pdf')

pdfkit.from_string('Hello!', 'out.pdf')

 

 

可以通过传递多个url、文件来生成pdf文件:

pdfkit.from_url(['https://www.w3school.com.cn', 'www.cnblogs.com'], 'out.pdf')

如上,将会把访问两个网站后打开的内容按网站在list中的顺序,写入out.pdf,也可以不带https://、http://,如下

pdfkit.from_url(['www.w3school.com.cn', 'www.cnblogs.com'], 'out.pdf')

pdfkit.from_file(['file1.html', 'file2.html'], 'out.pdf')

 

可以通过打开的文件来生成PDF

with open('file.html') as f:

pdfkit.from_file(f, 'out.pdf')

 

也可以不输出到文件,直接保存到内存中,以便后续处理

pdf = pdfkit.from_url('www.w3school.com.cn ', False)

 

默认的,pdfkit会显示所有wkhtmltopdf的输出,可以通过添加options参数,并设置quiet的值(quiet除外,还有很多其他选项可设置,具体参考官方文档),如下::

options = {

'quiet': ''

}

pdfkit.from_url('https://www.w3school.com.cn, 'out.pdf', options=options)

 

此外还可以为要生成的pdf添加css样式,特别适合css样式采用“外联样式”的目标对象。

#单个CSS样式文件

css = 'example.css'

pdfkit.from_file('file.html', options=options, css=css)

 

# 多个css样式

css = ['example.css', 'example2.css']

pdfkit.from_file('file.html', options=options, css=css)

 

添加configuration参数,如下,指定wkhtmltopdf安装路径

config = pdfkit.configuration(wkhtmltopdf='/opt/bin/wkhtmltopdf')

pdfkit.from_string(html_string, output_file, configuration=config)

 

更多详情参考官方文档

https://pypi.org/project/pdfkit/

 

实现步骤

1.安装wkhtmltox

安装完成后,找到安装目录下wkhtmltopdf.exe所在路径(例中为D:\Program Files\wkhtmltopdf\bin\wkhtmlpdf.exe),添加到系统环境变量path中(实践时发现,即便是配置了环境变量,运行时也会报错:提示:No wkhtmltopdf executable found: "b''"

 

解决方案:

如下,生成pdf前指定wkhtmltopdf.exe路径

config = pdfkit.configuration(wkhtmltopdf='/opt/bin/wkhtmltopdf')

pdfkit.from_string(html_string, output_file, configuration=config)

 

2.安装pdfkit

3.前端请求下载报告

仅保留关键代码

<script>

export default {

return {

echartPicIdDict: {}, // 存放echart图表ID 数据格式为: {" echartPicUniqueName":"echartPicUUID" },比如 {"doughnut-pie-chart":"xdfasfafafadfafafafafdasf" } // 创建Echarts图表时需要指定一个id,例中创建每个echart图表时,都会生成一个UUID作为该echart图表的id,并且会把该UUID保存到this.echartPicIdDict。

reportId: "", // 存放用户所选择的测试报告ID

...略

}

},

 

 

methods: {

...略

// 下载报告

downloadSprintTestReport() {

try {

...略

 

let echartBase64Info = {}; // 存放通过getDataURL获取的echarts图表base64编码信息

 

 

// 获取echart图表base64编码后的数据信息

for (let key in this.echartPicIdDict) {

// let echartObj = this.$echarts.getInstanceById(this.echartPicIdDict[key]); // 结果 echartObj=undefined

let echartDomObj = document.getElementById(this.echartPicIdDict[key]);

if (echartDomObj) {

const picBase64Data = echartDomObj.getDataURL(); //返回数据格式:data:image/png;base64,base64编码数据

echartBase64Info[key] = picBase64Data;

}

}

}

 

 

// 发送下载报告请求

downloadSprintTestReportRequest({

reportId: this.reportInfo.id,

sprintId: this.reportInfo.sprintId,

...略

echartBase64Info: echartBase64Info

})

.then(res => {

let link = document.createElement("a");

let blob = new Blob([res.data], {

type: res.headers["content-type"]

});

 

 

link.style.display = "none";

link.href = window.URL.createObjectURL(blob);

// 下载文件名无法通过后台响应获取,因为获取不到Content-Disposition响应头

link.setAttribute("download", this.reportInfo.title + ".pdf");

 

 

document.body.appendChild(link);

link.click();

document.body.removeChild(link);

})

.catch(res => {

if (

Object.prototype.toString.call(res.response.data) ==

"[object Blob]"

) {

let reader = new FileReader();

reader.onload = e => {

let responseData = JSON.parse(e.target.result);

if (responseData.msg) {

this.$message.error(

res.msg || res.message + ":" + responseData.msg

);

} else {

this.$message.error(

res.msg || res.message + ":" + responseData.detail

);

}

};

reader.readAsText(res.response.data);

} else {

this.$message.error(res.msg || res.message);

}

});

} catch (err) {

this.$message.error(res.message);

}

},

 

}

</script>

 

4、 后端编写模板

<!DOCTYPE HTML>

<html>

<head>

<meta charset="UTF-8" />

<!-- elementUI -->

<!-- 引入样式 -->

<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css" />

<!-- 引入组件库 -->

<script src="https://unpkg.com/element-ui/lib/index.js"></script>

<style>

...略

.plan-info {

border-width: 1px;

border-style: solid;

background: rgba(241, 239, 239, 0.438);

border-color: rgb(204, 206, 206);

 

}

 

.plan-info .plan-info-table-td {

text-align: center;

padding-top: 3px;

padding-bottom: 3px;

font-size: 14px;

}

 

.plan-info .plan-info-table-td-div {

display: inline;

}

...略

 

</style>

</head>

<body>

...略

<div class="sprint-test-report-detail">

<span style="font-weight: bold;">测试计划:</span>

<div class="plan-info">

<table>

<thead>

<tr>

<th style="border: none; width: 6%; height: 0px;">ID</th>

<th style="border: none; width: 20%; height: 0px;">计划名称</th>

<th style="border: none; width: 10%; height: 0px;">预估开始日期</th>

<th style="border: none; width: 10%; height: 0px;">实际开始时间</th>

<th style="border: none; width: 10%; height: 0px;">预估完成日期</th>

<th style="border: none; width: 10%; height: 0px;">实际完成时间</th>

<th style="border: none; width: 25%; height: 0px;">关联组别</th>

<th style="border: none; width: 9%; height: 0px;">测试环境</th>

</tr>

</thead>

<tbody>

${relatedPlans}

</tbody>

</table>

</div>

</div>

<div class="sprint-test-report-detail">

<span style="font-weight: bold;">测试范围:</span>

<div>

<span>${test_scope}</span>

</div>

</div>

<div class="sprint-test-report-detail">

<span style="font-weight: bold;">测试统计</span>

<div>

<div>

<img src="${defect_status_pie}" />

</div>

...略

</div>

...略

</div>

</body>

</html>

 

 

注意:html中需要在head元素中添加<meta charset="UTF-8">,以防生成的pdf中文乱码,另外,确保系统中有中文字体,否则也会出现乱码,如下:

 

5、 后端接口

仅保留关键代码

#!/usr/bin/env python

# -*- coding:utf-8 -*-

 

__author__ = '授客'

 

from rest_framework.views import APIView

from rest_framework.response import Response

from rest_framework import status

 

from backend.models import SprintTestReport

 

from django.utils import timezone

from django.http import FileResponse

 

 

from django.conf import settings

import pdfkit

import json

import base64

import uuid

import os

import logging

 

logger = logging.getLogger('mylogger')



class SprintTestreportPDFAPIView(APIView):

'''迭代测试报告pdf文件下载'''

 

@staticmethod

def convert_related_plans_to_html(self, related_plans):

'''转换报告相关联的测试计划数据格式为html格式数据,返回转换后的数据'''

 

result = ''

tr = '''<tr>

<td>

<div>{id}</div>

</td>

<td>

<div>{name}</div>

</td>

<td>

<div>{begin_time}</div>

</td>

<td>

<div>{start_time}</div>

</td>

<td>

<div>{end_time}</div>

</td>

<td>

<div>{finish_time}</div>

</td>

<td>

<div>{groups}</div>

</td>

<td>

<div>{environment}</div>

</td>

</tr>'''

 

for related_plan in related_plans:

result += tr.format(**related_plan)

 

return result

 

...略

 

 

 

def post(self, request, format=None):

'''下载pdf格式报告'''

 

result = {}

try:

data = request.data

report_id = data.get('report_id')

 

 

echart_base64_info_dict = data.get('echart_base64_info')

 

# 读取迭代测试报告html模板

report_html_str = '' # 存放html格式的迭代测试报告

 

current_dir, tail = os.path.split(os.path.abspath(__file__))

template_filepath = os.path.normpath(os.path.join(current_dir, 'sprint_test_report/sprint_test_report_template.html'))

with open(template_filepath, 'r', encoding='utf-8') as f:

for line in f:

report_html_str += line

 

# 读取报告数据

sprint_report = SprintTestReport.objects.filter(id=report_id)

if sprint_report.first():

try:

...略

report_data = sprint_report.values('title','introduction', 'related_plans', 'test_scope', 'individual_test_statistics', 'individual_dev_statistics', 'product_test_statistics', 'conclusion', 'suggestion', 'risk_analysis')[0]

 

# 替换测试计划

related_plans = json.loads(report_data['related_plans'])

related_plans = self.convert_related_plans_to_html(related_plans)

report_html_str = report_html_str.replace('${relatedPlans}', related_plans)

 

...略

 

# 生成echart图表图片

time_str = timezone.now().strftime('%Y%m%d')

uuid_time_str = str(uuid.uuid1()).replace('-', '') + time_str

 

file_name_dict = {}

for key, value in echart_base64_info_dict.items():

data_type, base64_data = value.split(',') # value 数据格式 data:image/png;base64,base64编码数据

file_suffix = '.' + data_type.split('/')[1].split(';')[0]

file_name = key + uuid_time_str + file_suffix

file_name_dict[key] = file_name

 

file_path = os.path.normpath(os.path.join(current_dir, 'sprint_test_report/%s' % file_name))

with open(file_path, 'wb') as f:

imgdata = base64.b64decode(base64_data)

f.write(imgdata)

 

# 替换 echart图表

for key in echart_base64_info_dict.keys():

# report_html_str = report_html_str.replace('${%s}' % key, '%s/sprint_test_report/%s' % (current_dir, file_name_dict[key])) # 注意,这里,迭代测试报告模板中的变量名称被设置为和key一样的值,所以可以这么操作

report_html_str = report_html_str.replace('${%s}' % key,os.path.normpath(os.path.join(current_dir, 'sprint_test_report/%s' % file_name_dict[key])))

 

# 生成pdf文档

time_str = timezone.now().strftime('%Y%m%d')

file_name = str(uuid.uuid1()).replace('-', '') + time_str + '.pdf'

config = pdfkit.configuration(wkhtmltopdf=settings.WKHTMLTOPDF)

file_dir = settings.MEDIA_ROOT + '/sprint/testreport'

options = {'dpi': 300, 'image-dpi':600,  'page-size':'A3',  'encoding':'UTF-8', 'page-width':'1903px'}

                    pdfkit.from_string(report_html_str, '%s/%s' % (file_dir, file_name), configuration=config, options=options)                    

file_absolute_path =  '%s/%s' % (file_dir, file_name)

# 删除生成的图片文件

for key in echart_base64_info_dict.keys():

os.remove('%s/sprint_test_report/%s' % (current_dir, file_name_dict[key]))

 

# 返回数据给前端

if os.path.exists(file_absolute_path) and os.path.isfile(file_absolute_path):

file = open(file_absolute_path, 'rb')

file_response = FileResponse(file)

file_response['Content-Type']='application/octet-stream'

file_response['Content-Disposition']='attachment;filename={}.pdf'.format(report_data['title'] ) # 不知道为啥,前端获取不到请求头Content-Disposition

return file_response

else:

result['msg'] = '生成pdf报告失败'

result['success'] = False

return Response(result, status.HTTP_400_BAD_REQUEST)

except Exception as e:

result['msg'] = '%s' % e

result['success'] = False

return Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR)

else:

result['msg'] = '生成迭代测试报告失败,报告不存在'

result['success'] = False

return Response(result, status.HTTP_400_BAD_REQUEST)

except Exception as e:

result['msg'] = '%s' % e

result['success'] = False

return Response(result, status.HTTP_500_INTERNAL_SERVER_ERROR)

 

导出效果(部分截图)