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,?QSpinBoxclass?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,我們將結合reportlab和PdfReader兩個庫。流程如下:
使用PdfReader讀入template.pdf文件,并僅提取第一頁。
創建一個reportlab 的 Canvas對象
使用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?makerloutfile?=?"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,?pyqtSlotfrom?reportlab.pdfgen.canvas?import?Canvasfrom?pdfrw?import?PdfReader from?pdfrw.buildxobj?import?pagexobj from?pdfrw.toreportlab?import?makerlclass?WorkerSignals(QObject):"""Defines?the?signals?available?from?a?running?worker?thread."""error?=?pyqtSignal(str)file_saved_as?=?pyqtSignal(str)class?Generator(QRunnable):"""Worker?threadInherits?from?QRunnable?to?handle?worker?thread?setup,?signalsand?wrap-up.:param?data:?The?data?to?add?to?the?PDF?for?generating."""def?__init__(self,?data):super().__init__()self.data?=?dataself.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?bycanvas.drawString(170,?ystart,?self.data['name'])canvas.save()except?Exception?as?e:self.signals.error.emit(str(e))returnself.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)?#?45first_line?=?lines[0]remainder?=?'?'.join(lines[1:])lines?=?textwrap.wrap(remainder,?75)?#?55lines?=?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,?pyqtSlotfrom?reportlab.pdfgen.canvas?import?Canvasimport?osimport?textwrap from?datetime?import?datetimefrom?pdfrw?import?PdfReader from?pdfrw.buildxobj?import?pagexobj from?pdfrw.toreportlab?import?makerlclass?WorkerSignals(QObject):"""Defines?the?signals?available?from?a?running?worker?thread."""error?=?pyqtSignal(str)file_saved_as?=?pyqtSignal(str)class?Generator(QRunnable):"""Worker?threadInherits?from?QRunnable?to?handle?worker?thread?setup,?signalsand?wrap-up.:param?data:?The?data?to?add?to?the?PDF?for?generating."""def?__init__(self,?data):super().__init__()self.data?=?dataself.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?bycanvas.drawString(170,?ystart,?self.data['name'])#?Date:?Todays?datetoday?=?datetime.today()canvas.drawString(410,?ystart,?today.strftime('%F'))#?Device/Program?Typecanvas.drawString(230,?ystart-28,?self.data['program_type'])#?Product?codecanvas.drawString(175,?ystart-(2*28),?self.data['product_code'])#?Customercanvas.drawString(315,?ystart-(2*28),?self.data['customer'])#?Vendorcanvas.drawString(145,?ystart-(3*28),?self.data['vendor'])ystart?=?250#?Program?Languagecanvas.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)?#?45first_line?=?lines[0]remainder?=?'?'.join(lines[1:])lines?=?textwrap.wrap(remainder,?75)?#?55lines?=?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))returnself.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,?pyqtSlotfrom?reportlab.pdfgen.canvas?import?Canvasimport?os,?csvimport?textwrap from?datetime?import?datetimefrom?pdfrw?import?PdfReader from?pdfrw.buildxobj?import?pagexobj from?pdfrw.toreportlab?import?makerlclass?WorkerSignals(QObject):"""Defines?the?signals?available?from?a?running?worker?thread."""error?=?pyqtSignal(str)finished?=?pyqtSignal()class?Generator(QRunnable):"""Worker?threadInherits?from?QRunnable?to?handle?worker?thread?setup,?signalsand?wrap-up.:param?data:?The?data?to?add?to?the?PDF?for?generating."""def?__init__(self,?data):super().__init__()self.data?=?dataself.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?bycanvas.drawString(170,?ystart,?row.get('name',?''))#?Date:?Todays?datetoday?=?datetime.today()canvas.drawString(410,?ystart,?today.strftime('%F'))#?Device/Program?Typecanvas.drawString(230,?ystart-28,?row.get('program_type',?''))#?Product?codecanvas.drawString(175,?ystart-(2*28),?row.get('product_code',?''))#?Customercanvas.drawString(315,?ystart-(2*28),?row.get('customer',?''))#?Vendorcanvas.drawString(145,?ystart-(3*28),?row.get('vendor',?''))ystart?=?250#?Program?Languagecanvas.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)?#?45first_line?=?lines[0]remainder?=?'?'.join(lines[1:])lines?=?textwrap.wrap(remainder,?75)?#?55lines?=?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))returnself.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等)分配特定的小部件
更多閱讀
5 分鐘快速上手 pytest 測試框架
5分鐘掌握 Python 隨機爬山算法
5分鐘快速掌握 Adam 優化算法
特別推薦
點擊下方閱讀原文加入社區會員
總結
以上是生活随笔為你收集整理的PDF 报告生成器:用 reportlab 和 pdfrw 生成自定义 PDF 报告的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: hdu 3405 world islan
- 下一篇: html中dd dt的效果,html中d