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

技术

picture.image

如果您的工作涉及生成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上。

picture.image

模板为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或将其连接到按钮。

picture.image

生成 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时出现问题。

picture.image

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

由于字符的宽度是可变的,因此这并不是完美的选择,但这无关紧要。如果我们换行以最宽的字符( 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)  
  

      

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

picture.image

自动显示结果

创建文件后,运行程序会在信号中返回创建文件的文件名(当前始终相同)。最好自动将生成的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 优化算法

特别推荐

picture.image

picture.image

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

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论