Z.S.K.'s Records

用python将html页面转换成pdf文件,顺便解决中文乱码

现在很多主流的浏览器都直接支持把html转换成pdf,比如谷歌大神器,Ctrl+P就能直接完美的转换,再复杂的css样式都没毛病,但我们不能奢求所有客户都使用谷歌浏览器,也不是所有客户都知道可以Ctrl+P,所以不得不在项目中提供一个[另存为PDF]的功能,而且还需要能自动转换为PDF文件,这对于定期生成报表功能尤其重要.

python环境下, 对于将html转换成pdf,比较常用的有以下几个开源库:

  • reportlab
  • xhtml2pdf
  • weasyprint
  • pdfkit
  • wkhtmltopdf

在实现这个需求之前也网上对比了这几个库,个人感觉weasyprint是最理想的方案,而且该项目的开发者非常活跃,但由于当时在机器上其它原因导致weasyprint依赖包没有装上,也花了点时间reportlab研究了它的API,因为已经有了报表的html模板,感觉没有必使用它的API从头到尾再生成一个了,所有放弃了,再来说说xhmt2pdf,查看xhtml2pdf的源码,里面引用了reportlab库,而且xhtml2pdf貌似没怎么更新,2017年也偶尔有几个github的push,最后使用xhmt2pdf的原因一方面是xhtml2pdf可以t很直接地实现我需要的功能,另一方面很大程度上是因为当时网上查看方案的时候看的最多的就是xhtml2pdf的例子,所以选定了xhtml2pdf.更多深入的探究大家可以上各项目的github摸索下吧.

环境

  • flask
  • python3.4
  • windows

问题

html2pdf转换成pdf,主要碰到以下几个问题:

  1. 中文转换后乱码问题
  2. 中文的自动换行问题
  3. BytesIO/StringIO问题
  4. 资源引用路径问题

解决

第一个问题一般都是默认的引用字体不支持中文,故可在模板文件中直接引用对应的支持中文的字体文件,这里使用微软雅黑字体,去网上下一个,把该字体文体放到reportlab库的安装路径下/font文件夹下,然后在模板文件中如下引用即可:

1
2
3
4
@font-face {
font-family: msyh;
src: url("msyh.ttf");
}

当然上面也可以不把字体文件放到font目录下,反正只需要上面代码src.url里能找到字体文件即可

第二个问题中文的自动换行问题,网上通用的做法是在.py文件中引用以下几句代码:

1
2
3
4
5
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont('msyh', 'msyh.ttf'))
import reportlab.lib.styles
reportlab.lib.styles.ParagraphStyle.defaults['wordWrap'] = 'CJK'

最重要的是最后一句话,CJK指的是中日韩文字,这句话的意思是按照中文韩文字的规则进行换行判断,查看reportlab_paragraph.py源码:

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
if style.wordWrap == 'CJK':
#use Asian text wrap algorithm to break characters
blPara = self.breakLinesCJK([first_line_width, later_widths])
else:
blPara = self.breakLines([first_line_width, later_widths])
self.blPara = blPara
autoLeading = getattr(self, 'autoLeading', getattr(style, 'autoLeading', ''))
leading = style.leading
if blPara.kind == 1 and autoLeading not in ('', 'off'):
height = 0
if autoLeading == 'max':
for l in blPara.lines:
height += max(l.ascent - l.descent, leading)
elif autoLeading == 'min':
for l in blPara.lines:
height += l.ascent - l.descent
else:
raise ValueError('invalid autoLeading value %r' % autoLeading)
else:
if autoLeading == 'max':
leading = max(leading, LEADING_FACTOR * style.fontSize)
elif autoLeading == 'min':
leading = LEADING_FACTOR * style.fontSize
height = len(blPara.lines) * leading
self.height = height

但是很可惜,本人各种尝试之后追了源码之后也没有实现中文自动换行,后来没有办法只能使用手工br的方式,好在模板html文件不是很大,工作量不是很大也就忍了

第三个问题其实是版本的问题,网上很多教程都是基于python2.x环境下,python2.x环境下,StringIO是from cStringIO import StringIO,而在python3.x下,StringIO在io下了,要from io import StringIO,还有就是pisa.CreatePDF(StringIO(pdf_data.encode(‘utf-8’)), pdf)在python3.x下StringIO无法使用,只能使用BytesIO,查看源码document.py里也改成了out = io.BytesIO(),故应该改成

1
pdf = pisa.CreatePDF(BytesIO(html.encode('utf8')),result,encoding='utf8',link_callback=fetch_resources)

第四个问题则是flask运行,默认的根目录为app.py所有目录,如果模板中使用了资源引用,如

1
<img src="{{ url_for('static',filename='img/pdf/0.png') }}" />

则生成pdf的时候应用会提示 **”need a valid filename”**错误,其它就是找不到该资源,因为它需要按绝对路径去引用,所以需要pisa.CreatePDF指定link_callback函数,

flask环境如下:

1
2
3
def fetch_resources(uri,rel):
path = os.getcwd() + uri
return path

而如果是Djang则如下:

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
def link_callback(uri, rel):
"""
Convert HTML URIs to absolute system paths so xhtml2pdf can access those
resources
"""
# use short variable names
sUrl = settings.STATIC_URL # Typically /static/
sRoot = settings.STATIC_ROOT # Typically /home/userX/project_static/
mUrl = settings.MEDIA_URL # Typically /static/media/
mRoot = settings.MEDIA_ROOT # Typically /home/userX/project_static/media/

# convert URIs to absolute system paths
if uri.startswith(mUrl):
path = os.path.join(mRoot, uri.replace(mUrl, ""))
elif uri.startswith(sUrl):
path = os.path.join(sRoot, uri.replace(sUrl, ""))
else:
return uri # handle absolute uri (ie: http://some.tld/foo.png)

# make sure that file exists
if not os.path.isfile(path):
raise Exception(
'media URI must start with %s or %s' % (sUrl, mUrl)
)
return path

生成PDF代码如下,这里当把PDF当做附件,下载的时候浏览器会提示另存为下载框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#生成pdf
@app.route('/pdfdownload/', methods=['GET','POST'])
def pdfdownload():
#其它业务代码
#填充pdf模板文件
html = render_template('pdfdownload.html')
result = BytesIO()
#生成pdf
pdf = pisa.CreatePDF(BytesIO(html.encode('utf-8')),result,encoding='utf-8',link_callback=fetch_resources)
resp = make_response(result.getvalue())
result.close()
resp.headers["Content-Disposition"] = ("attachment; filename='{0}'".format('skxt-jkxj.pdf'))
resp.headers['Content-Type'] = 'application/pdf'
return resp

如果是自动生成PDF文件,官方代码如下:

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
from xhtml2pdf import pisa

# Define your data
sourceHtml = "<html><body><p>To PDF or not to PDF</p></body></html>"
outputFilename = "test.pdf"

# Utility function
def convertHtmlToPdf(sourceHtml, outputFilename):
# open output file for writing (truncated binary)
resultFile = open(outputFilename, "w+b")

# convert HTML to PDF
pisaStatus = pisa.CreatePDF(
sourceHtml, # the HTML to convert
dest=resultFile) # file handle to recieve result

# close output file
resultFile.close() # close output file

# return True on success and False on errors
return pisaStatus.err

# Main program
if __name__ == "__main__":
pisa.showLogging()
convertHtmlToPdf(sourceHtml, outputFilename)

官方的usage.rst其它很有用,还有些example也很值得借鉴,有时候遇到问题不防也看看issue,你碰到的问题,其它人肯定也碰到过.

参考文章:

转载请注明原作者: 周淑科(https://izsk.me)


 wechat
Scan Me To Read on Phone
I know you won't do this,but what if you did?