PDF 报告生成器:用 reportlab 和 pdfrw 生成自定义 PDF 报告

如果您的工作涉及生成PDF报告,发票等,则您可能已经考虑过使用Python自动化。Python有一些很不错的第三方库用于处理PDF文件,使您可以从脚本中读取和写入PDF。同样,您也可以将这些库作为简单GUI工具的基础,从而为您提供一种在桌面上操作自动填充或编辑PDF报告的简便方法。

在本教程中,我们将使用两个库来创建自定义PDF报告填充器。数据将使用Qt表单收集:只需编辑字段,按“生成”按钮即可在文件夹中获取填写的表单。我们将在这里使用的两个库是:

  • reportlab,可让您使用文本和图片类原件创建PDF

  • pdfrw,一个用于从现有PDF读取和提取页面的库

尽管我们可以使用reportlab来绘制整个PDF,但是使用外部工具设计模板然后在其上叠加动态内容会更容易。我们可以使用pdfrw来读取模板PDF,提取页面,然后可以使用reportlab在该页面上进行绘制。这样一来,我们就可以将自定义信息(来自我们的应用程序)直接覆盖到现有的PDF模板上,并以新名称保存。

在此示例中,我们通过手动输入字段,但是您可以修改应用程序以从外部CSV文件读取PDF数据并从中生成多个PDF。

PDF 模板

为了进行测试,我使用Google Docs创建了一个自定义的TPS报告模板,并将页面下载为PDF。该页面包含许多要填写的字段。在本教程中,我们将编写一个PyQt表单,用户可以填写该表单,然后将数据写到正确位置的PDF上。

模板为A4格式。将其与脚本保存在同一文件夹中。

如果您想使用其他模板,请随时使用。只需记住,编写表单时需要调整表单字段的位置。

布置表单视图

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()

在编写用于替换/自动化纸质表格的工具时,尝试模仿纸质表格的布局通常是个好主意,这样就很熟悉了。

上面的代码运行后在窗口中提供以下布局。您已经可以在字段中输入内容,但是按下按钮尚无任何作用 —— 我们尚未编写代码来生成PDF或将其连接到按钮。

生成 PDF 文本

为了将基本模板生成PDF,我们将结合reportlabPdfReader两个库。流程如下:

  • 使用PdfReader读入template.pdf文件,并仅提取第一页。

  • 创建一个reportlabCanvas对象

  • 使用pdfrw.toreportlab.makerl生成画布对象,然后使用canvas.doForm()将其添加到Canvas中。

  • 在画布上绘制自定义位

  • 将PDF保存到文件

代码如下所示,不需要Qt,您可以保存到文件并按原样运行。运行后,生成的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()

由于生成PDF的过程正在进行IO操作,因此可能会花费一些时间(例如,如果我们从网络驱动器中加载文件)。因此,最好在单独的线程中进行处理。接下来,我们将定义这个自定义线程运行器。

在单独的线程中运行生成器

由于每个生成器都是一个孤立的工作,因此使用Qt的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的纯文本表示。因为我们要放置文本格式,所以我们将数值转换为字符串。

为了实际生成PDF,我们创建了刚刚定义的Generator运行器的实例,并传入了数据字典。我们将file_saved_as信号连接到生成的方法(在底部定义,但尚未执行任何操作),并将错误信号连接到标准Python打印功能:这会自动将任何错误打印到控制台。

最后,我们使用Generator实例,并将其传递到线程池的.start()方法以使其排队运行(它应立即启动)。然后,我们可以将此方法挂接到主窗口__init__中的按钮上,例如:

self.generate_btn.pressed.connect(self.generate)

如果立即运行该应用程序,则按下按钮将触发PDF的生成,并且结果将作为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)

对于注释字段,事情有些棘手。该字段可以更长,并且需要将行包装在模板中的多行上。该字段还接受换行符(通过按Enter键),这些换行符会在写入PDF时出现问题。

如您在上面的屏幕截图中所见,换行符在文本中显示为黑色正方形。好的方面是,仅删除换行符将使换行更加容易:我们可以将每行换行为指定数量的字符。

由于字符的宽度是可变的,因此这并不是完美的选择,但这无关紧要。如果我们换行以最宽的字符(W)填充,则任何实际行都将适合。

Python带有内置的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.

换行线(45和55)上的注释标记显示了将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)

这给出了一些带有乱数假文文本的结果。

自动显示结果

创建文件后,运行程序会在信号中返回创建文件的文件名(当前始终相同)。最好自动将生成的PDF呈现给用户,这样他们就可以检查运行是否正常。在Windows上,我们可以使用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")

完整代码

PyQt5 的完整代码如下所示。

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_()

从CSV文件生成

在上面的示例中,您需要输入数据以手动填写。如果您没有大量的PDF生成,这很好,但是如果您有一个完整的CSV文件,可以生成报告的数据,那么就没那么有趣了。在下面的示例中,我们没有向用户显示表单字段列表,而是要求提供可从中生成PDF的源CSV文件 —— 文件中的每一行都使用文件中的数据生成单独的PDF文件。

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.pdftps-2.pdf等。文件被写到源CSV所在的文件夹中。

  • 由于某些行/文件可能会漏掉必填字段,因此我们在行字典上使用.get()并使用默认的空字符串。

可能的改进

如果您想改进此代码,可以尝试以下方法

  • 使模板和输出文件位置可配置 —— 使用Qt文件对话框

  • 从文件和模板(JSON)一起加载字段位置,因此您可以将同一表单用于多个模板

  • 使字段可配置-这非常棘手,但是您可以为特定类型(strdatetimeint等)分配特定的小部件

更多阅读

5 分钟快速上手 pytest 测试框架

5分钟掌握 Python 随机爬山算法

5分钟快速掌握 Adam 优化算法

特别推荐


点击下方阅读原文加入社区会员

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值