PDF 报告生成器:用 reportlab 和 pdfrw 生成自定义 PDF 报告
Python中文社区
共 36359字,需浏览 73分钟
·
2021-04-29 21:18
reportlab
,可让您使用文本和图片类原件创建PDFpdfrw
,一个用于从现有PDF读取和提取页面的库
reportlab
来绘制整个PDF,但是使用外部工具设计模板然后在其上叠加动态内容会更容易。我们可以使用pdfrw
来读取模板PDF,提取页面,然后可以使用reportlab
在该页面上进行绘制。这样一来,我们就可以将自定义信息(来自我们的应用程序)直接覆盖到现有的PDF模板上,并以新名称保存。Qt
包含一个QFormLayout
布局,该布局简化了生成简单表单布局的过程。它的工作方式类似于网格,但是您可以将元素的行添加在一起,并将字符串自动转换为QLabel
对象。我们的框架应用程序,包括与模板表单匹配的完整布局,如下所示。from PyQt5.QtWidgets import QPushButton, QLineEdit, QApplication, QFormLayout, QWidget, QTextEdit, QSpinBox
class Window(QWidget):
def __init__(self):
super().__init__()
self.name = QLineEdit()
self.program_type = QLineEdit()
self.product_code = QLineEdit()
self.customer = QLineEdit()
self.vendor = QLineEdit()
self.n_errors = QSpinBox()
self.n_errors.setRange(0, 1000)
self.comments = QTextEdit()
self.generate_btn = QPushButton("Generate PDF")
layout = QFormLayout()
layout.addRow("Name", self.name)
layout.addRow("Program Type", self.program_type)
layout.addRow("Product Code", self.product_code)
layout.addRow("Customer", self.customer)
layout.addRow("Vendor", self.vendor)
layout.addRow("No. of Errors", self.n_errors)
layout.addRow("Comments", self.comments)
layout.addRow(self.generate_btn)
self.setLayout(layout)
app = QApplication([])
w = Window()
w.show()
app.exec()
reportlab
和PdfReader
两个库。流程如下:使用 PdfReader
读入template.pdf
文件,并仅提取第一页。创建一个 reportlab
的Canvas
对象使用 pdfrw.toreportlab.makerl
生成画布对象,然后使用canvas.doForm()
将其添加到Canvas
中。在画布上绘制自定义位 将PDF保存到文件
result.pdf
保存在同一文件夹中。from reportlab.pdfgen.canvas import Canvas
from pdfrw import PdfReader
from pdfrw.buildxobj import pagexobj
from pdfrw.toreportlab import makerl
outfile = "result.pdf"
template = PdfReader("template.pdf", decompress=False).pages[0]
template_obj = pagexobj(template)
canvas = Canvas(outfile)
xobj_name = makerl(canvas, template_obj)
canvas.doForm(xobj_name)
ystart = 443
# Prepared by
canvas.drawString(170, ystart, "My name here")
canvas.save()
IO
操作,因此可能会花费一些时间(例如,如果我们从网络驱动器中加载文件)。因此,最好在单独的线程中进行处理。接下来,我们将定义这个自定义线程运行器。QRunner
框架来处理该流程是很有意义的,这也使以后为每个作业添加可自定义的模板变得很简单。我们在使用多线程教程中可以看到相同的方法,在该方法中,我们使用QRunner
的子类来保存我们的自定义运行代码,并在单独的QObject
子类上实现特定于运行器的信号。from PyQt5.QtWidgets import QPushButton, QLineEdit, QApplication, QFormLayout, QWidget, QTextEdit, QMessageBox, QSpinBox
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from reportlab.pdfgen.canvas import Canvas
from pdfrw import PdfReader
from pdfrw.buildxobj import pagexobj
from pdfrw.toreportlab import makerl
class WorkerSignals(QObject):
"""
Defines the signals available from a running worker thread.
"""
error = pyqtSignal(str)
file_saved_as = pyqtSignal(str)
class Generator(QRunnable):
"""
Worker thread
Inherits from QRunnable to handle worker thread setup, signals
and wrap-up.
:param data: The data to add to the PDF for generating.
"""
def __init__(self, data):
super().__init__()
self.data = data
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
try:
outfile = "result.pdf"
template = PdfReader("template.pdf", decompress=False).pages[0]
template_obj = pagexobj(template)
canvas = Canvas(outfile)
xobj_name = makerl(canvas, template_obj)
canvas.doForm(xobj_name)
ystart = 443
# Prepared by
canvas.drawString(170, ystart, self.data['name'])
canvas.save()
except Exception as e:
self.signals.error.emit(str(e))
return
self.signals.file_saved_as.emit(outfile)
file_saved_as
,它发出已保存的PDF文件的文件名(成功时)error
,它以调试字符串的形式发出错误信号
QThreadPool
来添加运行我们的自定义运行器。我们可以将它添加到__init__
块的MainWindow
中。class Window(QWidget):
def __init__(self):
super().__init__()
self.threadpool = QThreadPool()
QRunner
,我们只需要实现generate
方法来创建运行器,将表单字段中的数据传递给运行器,并开始运行生成器。def generate(self):
self.generate_btn.setDisabled(True)
data = {
'name': self.name.text(),
'program_type': self.program_type.text(),
'product_code': self.product_code.text(),
'customer': self.customer.text(),
'vendor': self.vendor.text(),
'n_errors': str(self.n_errors.value()),
'comments': self.comments.toPlainText()
}
g = Generator(data)
g.signals.file_saved_as.connect(self.generated)
g.signals.error.connect(print) # Print errors to console.
self.threadpool.start(g)
def generated(self, outfile):
pass28
generate_btn
,目的是使用户在生成过程中无法多次按下按钮。然后,我们从控件中构造数据字典,使用.text()
方法从QLineEdit
控件中获取文本,.value()
从QSpinBox
中获取值,以及.toPlainText()
获得QTextEdit
的纯文本表示。因为我们要放置文本格式,所以我们将数值转换为字符串。Generator
运行器的实例,并传入了数据字典。我们将file_saved_as
信号连接到生成的方法(在底部定义,但尚未执行任何操作),并将错误信号连接到标准Python打印功能:这会自动将任何错误打印到控制台。Generator
实例,并将其传递到线程池的.start()
方法以使其排队运行(它应立即启动)。然后,我们可以将此方法挂接到主窗口__init__
中的按钮上,例如:self.generate_btn.pressed.connect(self.generate)
result.pdf
保存在启动该应用程序的同一文件夹中。到目前为止,我们只在页面上放置了一个文本块,因此让我们完成生成器的工作,以将所有字段写在正确的位置。y
坐标增加了页面的高度(所以0,0
在左下角),因此在之前的代码中,我们为顶行定义ystart
,然后为每行减去28。ystart = 443
# Prepared by
canvas.drawString(170, ystart, self.data['name'])
# Date: Todays date
today = datetime.today()
canvas.drawString(410, ystart, today.strftime('%F'))
# Device/Program Type
canvas.drawString(230, ystart-28, self.data['program_type'])
# Product code
canvas.drawString(175, ystart-(2*28), self.data['product_code'])
# Customer
canvas.drawString(315, ystart-(2*28), self.data['customer'])
# Vendor
canvas.drawString(145, ystart-(3*28), self.data['vendor'])
ystart = 250
# Program Language
canvas.drawString(210, ystart, "Python")
canvas.drawString(430, ystart, self.data['n_errors'])
field.setMaxLength(25)
W
)填充,则任何实际行都将适合。textwrap
库,一旦我们删除了换行符,我们就可以使用该库包装文本。import textwrap
comments = comments.replace('\n', ' ')
lines = textwrap.wrap(comments, width=80)
import textwrap
comments = comments.replace('\n', ' ')
lines = textwrap.wrap(comments, width=65) # 45
first_line = lines[0]
remainder = ' '.join(lines[1:])
lines = textwrap.wrap(remainder, 75) # 55
lines = lines[:4] # max lines, not including the first.
Ws
线插入空间所需的换行长度。这是最短的线,但不现实。使用的值应适用于大多数普通文本。y
位置,将它们打印到 PDF 上。模板文档中各行之间的间距为28。comments = self.data['comments'].replace('\n', ' ')
if comments:
lines = textwrap.wrap(comments, width=65) # 45
first_line = lines[0]
remainder = ' '.join(lines[1:])
lines = textwrap.wrap(remainder, 75) # 55
lines = lines[:4] # max lines, not including the first.
canvas.drawString(155, 223, first_line)
for n, l in enumerate(lines, 1):
canvas.drawString(80, 223 - (n*28), l)
乱数假文
文本的结果。os.startfile
以该类型的默认启动器打开文件 —— 在这种情况下,使用默认的PDF查看器打开PDF。QMessageBox
def generated(self, outfile):
self.generate_btn.setDisabled(False)
try:
os.startfile(outfile)
except Exception:
# If startfile not available, show dialog.
QMessageBox.information(self, "Finished", "PDF has been generated")
from PyQt5.QtWidgets import QPushButton, QLineEdit, QApplication, QFormLayout, QWidget, QTextEdit, QMessageBox, QSpinBox
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from reportlab.pdfgen.canvas import Canvas
import os
import textwrap
from datetime import datetime
from pdfrw import PdfReader
from pdfrw.buildxobj import pagexobj
from pdfrw.toreportlab import makerl
class WorkerSignals(QObject):
"""
Defines the signals available from a running worker thread.
"""
error = pyqtSignal(str)
file_saved_as = pyqtSignal(str)
class Generator(QRunnable):
"""
Worker thread
Inherits from QRunnable to handle worker thread setup, signals
and wrap-up.
:param data: The data to add to the PDF for generating.
"""
def __init__(self, data):
super().__init__()
self.data = data
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
try:
outfile = "result.pdf"
template = PdfReader("template.pdf", decompress=False).pages[0]
template_obj = pagexobj(template)
canvas = Canvas(outfile)
xobj_name = makerl(canvas, template_obj)
canvas.doForm(xobj_name)
ystart = 443
# Prepared by
canvas.drawString(170, ystart, self.data['name'])
# Date: Todays date
today = datetime.today()
canvas.drawString(410, ystart, today.strftime('%F'))
# Device/Program Type
canvas.drawString(230, ystart-28, self.data['program_type'])
# Product code
canvas.drawString(175, ystart-(2*28), self.data['product_code'])
# Customer
canvas.drawString(315, ystart-(2*28), self.data['customer'])
# Vendor
canvas.drawString(145, ystart-(3*28), self.data['vendor'])
ystart = 250
# Program Language
canvas.drawString(210, ystart, "Python")
canvas.drawString(430, ystart, self.data['n_errors'])
comments = self.data['comments'].replace('\n', ' ')
if comments:
lines = textwrap.wrap(comments, width=65) # 45
first_line = lines[0]
remainder = ' '.join(lines[1:])
lines = textwrap.wrap(remainder, 75) # 55
lines = lines[:4] # max lines, not including the first.
canvas.drawString(155, 223, first_line)
for n, l in enumerate(lines, 1):
canvas.drawString(80, 223 - (n*28), l)
canvas.save()
except Exception as e:
self.signals.error.emit(str(e))
return
self.signals.file_saved_as.emit(outfile)
class Window(QWidget):
def __init__(self):
super().__init__()
self.threadpool = QThreadPool()
self.name = QLineEdit()
self.program_type = QLineEdit()
self.product_code = QLineEdit()
self.customer = QLineEdit()
self.vendor = QLineEdit()
self.n_errors = QSpinBox()
self.n_errors.setRange(0, 1000)
self.comments = QTextEdit()
self.generate_btn = QPushButton("Generate PDF")
self.generate_btn.pressed.connect(self.generate)
layout = QFormLayout()
layout.addRow("Name", self.name)
layout.addRow("Program Type", self.program_type)
layout.addRow("Product Code", self.product_code)
layout.addRow("Customer", self.customer)
layout.addRow("Vendor", self.vendor)
layout.addRow("No. of Errors", self.n_errors)
layout.addRow("Comments", self.comments)
layout.addRow(self.generate_btn)
self.setLayout(layout)
def generate(self):
self.generate_btn.setDisabled(True)
data = {
'name': self.name.text(),
'program_type': self.program_type.text(),
'product_code': self.product_code.text(),
'customer': self.customer.text(),
'vendor': self.vendor.text(),
'n_errors': str(self.n_errors.value()),
'comments': self.comments.toPlainText()
}
g = Generator(data)
g.signals.file_saved_as.connect(self.generated)
g.signals.error.connect(print) # Print errors to console.
self.threadpool.start(g)
def generated(self, outfile):
self.generate_btn.setDisabled(False)
try:
os.startfile(outfile)
except Exception:
# If startfile not available, show dialog.
QMessageBox.information(self, "Finished", "PDF has been generated")
app = QApplication([])
w = Window()
w.show()
app.exec_()
from PyQt5.QtWidgets import QPushButton, QLineEdit, QApplication, QFormLayout, QWidget, QTextEdit, QMessageBox, QSpinBox, QFileDialog
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from reportlab.pdfgen.canvas import Canvas
import os, csv
import textwrap
from datetime import datetime
from pdfrw import PdfReader
from pdfrw.buildxobj import pagexobj
from pdfrw.toreportlab import makerl
class WorkerSignals(QObject):
"""
Defines the signals available from a running worker thread.
"""
error = pyqtSignal(str)
finished = pyqtSignal()
class Generator(QRunnable):
"""
Worker thread
Inherits from QRunnable to handle worker thread setup, signals
and wrap-up.
:param data: The data to add to the PDF for generating.
"""
def __init__(self, data):
super().__init__()
self.data = data
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
try:
filename, _ = os.path.splitext(self.data['sourcefile'])
folder = os.path.dirname(self.data['sourcefile'])
template = PdfReader("template.pdf", decompress=False).pages[0]
template_obj = pagexobj(template)
with open(self.data['sourcefile'], 'r', newline='') as f:
reader = csv.DictReader(f)
for n, row in enumerate(reader, 1):
fn = f'{filename}-{n}.pdf'
outfile = os.path.join(folder, fn)
canvas = Canvas(outfile)
xobj_name = makerl(canvas, template_obj)
canvas.doForm(xobj_name)
ystart = 443
# Prepared by
canvas.drawString(170, ystart, row.get('name', ''))
# Date: Todays date
today = datetime.today()
canvas.drawString(410, ystart, today.strftime('%F'))
# Device/Program Type
canvas.drawString(230, ystart-28, row.get('program_type', ''))
# Product code
canvas.drawString(175, ystart-(2*28), row.get('product_code', ''))
# Customer
canvas.drawString(315, ystart-(2*28), row.get('customer', ''))
# Vendor
canvas.drawString(145, ystart-(3*28), row.get('vendor', ''))
ystart = 250
# Program Language
canvas.drawString(210, ystart, "Python")
canvas.drawString(430, ystart, row.get('n_errors', ''))
comments = row.get('comments', '').replace('\n', ' ')
if comments:
lines = textwrap.wrap(comments, width=65) # 45
first_line = lines[0]
remainder = ' '.join(lines[1:])
lines = textwrap.wrap(remainder, 75) # 55
lines = lines[:4] # max lines, not including the first.
canvas.drawString(155, 223, first_line)
for n, l in enumerate(lines, 1):
canvas.drawString(80, 223 - (n*28), l)
canvas.save()
except Exception as e:
self.signals.error.emit(str(e))
return
self.signals.finished.emit()
class Window(QWidget):
def __init__(self):
super().__init__()
self.threadpool = QThreadPool()
self.sourcefile = QLineEdit()
self.sourcefile.setDisabled(True) # must use the file finder to select a valid file.
self.file_select = QPushButton("Select CSV...")
self.file_select.pressed.connect(self.choose_csv_file)
self.generate_btn = QPushButton("Generate PDF")
self.generate_btn.pressed.connect(self.generate)
layout = QFormLayout()
layout.addRow(self.sourcefile, self.file_select)
layout.addRow(self.generate_btn)
self.setLayout(layout)
def choose_csv_file(self):
filename, _ = QFileDialog.getOpenFileName(self, "Select a file", filter="CSV files (*.csv)")
if filename:
self.sourcefile.setText(filename)
def generate(self):
if not self.sourcefile.text():
return # If the field is empty, ignore.
self.generate_btn.setDisabled(True)
data = {
'sourcefile': self.sourcefile.text(),
}
g = Generator(data)
g.signals.finished.connect(self.generated)
g.signals.error.connect(print) # Print errors to console.
self.threadpool.start(g)
def generated(self):
self.generate_btn.setDisabled(False)
QMessageBox.information(self, "Finished", "PDFs have been generated")
app = QApplication([])
w = Window()
w.show()
app.exec()
template.pdf
和此示例CSV文件运行此应用,以生成一些TPS报告。现在我们生成了多个文件,完成后打开它们并没有多大意义。取而代之的是,我们始终只显示一次“完成”消息。信号 file_saved_as
已重命名为finished
,并且由于不再使用文件名str
,我们将其删除。用于获取文件名的 QLineEdit
已禁用,因此无法直接进行编辑:设置源CSV文件的唯一方法是直接选择文件,确保已在其中。我们基于导入文件名和当前行号自动生成输出文件名。文件名取自输入CSV:CSV文件名为 tps.csv
,文件名为tps-1.pdf
,tps-2.pdf
等。文件被写到源CSV所在的文件夹中。由于某些行/文件可能会漏掉必填字段,因此我们在行字典上使用 .get()
并使用默认的空字符串。
使模板和输出文件位置可配置 —— 使用Qt文件对话框 从文件和模板(JSON)一起加载字段位置,因此您可以将同一表单用于多个模板 使字段可配置-这非常棘手,但是您可以为特定类型( str
,datetime
,int
等)分配特定的小部件
更多阅读
特别推荐
点击下方阅读原文加入社区会员
评论