2023-12-29

PDF をデータベースに格納 ~ PySide6

PySide (Qt for Python) は、Qt(キュート)の Python バインディングで、GUI などを構築するためのクロスプラットフォームなライブラリです。Linux/X11, macOS および Microsoft Windows をサポートしています。配布ライセンスは LGPL で公開されています。

本ブログの過去記事 [1] で、PDF ファイルを SQLite のデータベースに格納する PySide6 のサンプルを紹介しました。その後 PostgreSQL でも同じことをしようと試行錯誤していたところ、バイナリをデータベースに格納するのに Base64 のモジュールより、PySide6 の QByteArray [2] クラスを利用した方がシンプルになりそうだったので、書き直すことにしました。

また SQLite と PostgreSQL それぞれに対応したサンプルを、なるべく共通な部分を流用することで一緒に紹介することにしました。

今回のテーマ
  • 選択した PDF ファイルを読み込んでデータベースへ格納します。そして、データベースから読み込んだ内容を(一旦保存して) QPdfView クラスのウィジェット上に表示する、という GUI サンプルを紹介します。
  • データベースは最初 SQLite で確認したあと、PostgreSQL でも同じことができるサンプルを作成します。そのため、SQLite と PostgreSQL で共通に使える部分が多くなるように意識してサンプルを作成します。

なお、PDF ファイルのサンプルは、東京国立博物館ニュースの PDF ファイルを利用させていただきました [3]

下記の OS 環境で動作確認をしています。

Fedora Workstation 39 x86_64
Python 3.11.7
PySide6 6.6.1
(libpq-15.3-1.fc39.x86_64)
AlmaLinux 9.3 (192.168.0.34) x86_64
PostgreSQL (server) 15.5

【注意】

本稿で紹介するサンプルは、シンプルで判りやすく、かつサイズ(行数)を抑えることを優先したため、想定しうるエラー処理をほとんどしていません。

データベースに SQLite を利用したサンプル

以下にサンプル qt_db_sqlite_pdf.py を示しました(SQLitePDF クラス)。

SQLite 版サンプルの実行に必要なファイル
  • qt_db_sqlite_pdf.py(メイン)
  • qt_db_common_pdf.py
qt_db_sqlite_pdf.py
import os
import sys
import tempfile
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction
from PySide6.QtPdf import QPdfDocument
from PySide6.QtPdfWidgets import QPdfView
from PySide6.QtSql import QSqlDatabase, QSqlQuery
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QMainWindow,
QSizePolicy,
QToolBar,
QWidget,
)
from qt_db_common_pdf import (
get_content_from_filename,
get_list_file,
insert_filename_content,
)
class SQLitePDF(QMainWindow):
app_title = 'SQLite & PDF test'
def __init__(self):
super().__init__()
self.con = self.get_connection()
self.init_table()
self.combo = None
self.init_ui()
self.setWindowTitle(self.app_title)
self.resize(600, 800)
self.update_filelist()
@staticmethod
def create_table():
query = QSqlQuery()
sql = """
CREATE TABLE IF NOT EXISTS pdfrepo (
name_file TEXT UNIQUE,
content NONE
);
"""
if not query.exec(sql):
print(query.lastError())
@staticmethod
def get_connection() -> QSqlDatabase:
con = QSqlDatabase.addDatabase('QSQLITE')
dbname = 'testdb.sqlite'
con.setDatabaseName(dbname)
return con
def init_table(self):
if self.con.open():
self.create_table()
self.con.close()
def init_ui(self):
menubar = self.menuBar()
menu_file = menubar.addMenu('&File')
file_open = QAction('Open', self)
file_open.setShortcut('Ctrl+O')
file_open.setStatusTip('open PDF file')
file_open.triggered.connect(self.show_dialog)
menu_file.addAction(file_open)
toolbar = QToolBar()
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
self.combo = combo = QComboBox()
combo.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred
)
combo.currentTextChanged.connect(self.on_current_text_changed)
toolbar.addWidget(combo)
view = QPdfView(self)
view.setPageMode(QPdfView.PageMode.MultiPage)
view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
self.setCentralWidget(view)
def show_dialog(self):
dialog = QFileDialog()
dialog.setWindowTitle('PDF file selection')
dialog.setNameFilters(['PDF files (*.pdf)'])
if dialog.exec():
filename = dialog.selectedFiles()[0]
basename = os.path.basename(filename)
f = open(filename, 'rb')
with f:
content = f.read()
if self.con.open():
insert_filename_content(basename, content)
self.con.close()
self.update_filelist()
self.combo.setCurrentText(basename)
def update_filelist(self):
list_name_file = list()
if self.con.open():
get_list_file(list_name_file)
self.con.close()
self.combo.clear()
for name_file in list_name_file:
self.combo.addItem(name_file)
def on_current_text_changed(self):
filename = self.combo.currentText()
if len(filename) == 0:
return
if self.con.open():
content = get_content_from_filename(filename)
self.con.close()
if content is not None:
filepath = os.path.join(tempfile.gettempdir(), filename)
with open(filepath, 'wb') as f:
f.write(content)
document = QPdfDocument(self)
document.load(filepath)
view: QWidget | QPdfView = self.centralWidget()
view.setDocument(document)
def main():
app = QApplication()
ex = SQLitePDF()
ex.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()

SQLite と PostgreSQL で変更無しに利用できるクエリ処理部分を qt_db_common_pdf.py に分離しました。

qt_db_common_pdf.py
from PySide6.QtCore import QByteArray
from PySide6.QtSql import QSqlQuery
def get_content_from_filename(filename: str) -> bytes:
byte_array = None
content = None
query = QSqlQuery()
sql = """
SELECT content FROM pdfrepo
WHERE name_file = '%s';
""" % filename
flag = query.exec(sql)
if query.next():
byte_array = query.value(0)
if not flag:
print(query.lastError())
if byte_array is not None:
content = byte_array.data()
return content
def get_list_file(list_file: list):
query = QSqlQuery()
sql = 'SELECT name_file FROM pdfrepo;'
flag = query.exec(sql)
while query.next():
list_file.append(query.value(0))
if not flag:
print(query.lastError())
def insert_filename_content(filename: str, content: bytes):
sql = 'INSERT INTO pdfrepo VALUES(?, ?);'
query = QSqlQuery()
query.prepare(sql)
query.bindValue(0, filename)
query.bindValue(1, QByteArray(content))
if not query.exec():
print(query.lastError())

サンプルの説明

初期化

サンプルを実行すると、データベースに接続するインスタンスを生成します。このとき指定した名前のデータベース・ファイル test.sqlite が存在してければ作成されます。

SQLitePDF クラスの冒頭
class SQLitePDF(QMainWindow):
    app_title = 'SQLite & PDF test'
 
    def __init__(self):
        super().__init__()
        self.con = self.get_connection()
        self.init_table()
           :
           :

データベースに接続するインスタンスの生成は、SQLite と PostgreSQL では異なるので、あとあとのことを考えて get_connection メソッドで処理するようにしました。

get_connection メソッド(SQLite 用)
@staticmethod
def get_connection() -> QSqlDatabase:
    con = QSqlDatabase.addDatabase('QSQLITE')
    dbname = 'testdb.sqlite'
    con.setDatabaseName(dbname)
    return con

その後 init_table メソッドで、使用するデータベースのテーブルの初期化をします。

init_table メソッド
def init_table(self):
    if self.con.open():
        self.create_table()
        self.con.close()

テーブルの初期化処理は create_table メソッドで処理しています。

create_table メソッド(SQLite 用)
@staticmethod
def create_table():
    query = QSqlQuery()
    sql = """
        CREATE TABLE IF NOT EXISTS pdfrepo (
            name_file TEXT UNIQUE,
            content NONE
        );
    """
    if not query.exec(sql):
        print(query.lastError())

なお、get_connectioncreate_table は、敢えてスタティック・メソッドとして SQLitePDF クラス内に残しています。

その後、プログラムは GUI を生成します。

PDF ファイルを読み込んでみる

実行したサンプルのメニューから File » Open をクリックします。

qt_db_sqlite_pdf.py の実行例 (1) - メニューから File » Open

データベースに格納する PDF ファイルを選択します。

qt_db_sqlite_pdf.py の実行例 (2) - PDF ファイルの選択

バイナリーデータをデータベースへ格納

ファイルをバイナリモードで読み込んで、パスを除いたファイル名と読み込んだ内容を、データベースに格納します。

show_dialog メソッド
def show_dialog(self):
    dialog = QFileDialog()
    dialog.setWindowTitle('PDF file selection')
    dialog.setNameFilters(['PDF files (*.pdf)'])
    if dialog.exec():
        filename = dialog.selectedFiles()[0]
        basename = os.path.basename(filename)
        f = open(filename, 'rb')
        with f:
            content = f.read()
 
        if self.con.open():
            insert_filename_content(basename, content)
            self.con.close()
            self.update_filelist()
            self.combo.setCurrentText(basename)

PySide6 でバイナリ・データをそのままクエリに渡してもデータベースに格納できなかったのですが、QByteArray 型にすると格納できました。QByteArray 型にするということは、読み出すときにも QByteArray 型を扱えるようにする必要があるので、「PySide6 あるいは Qt6 のライブラリを使わなければならない」という縛りができてしまいますが、ここではそれを良しとしました。

このクエリを処理する insert_filename_content 関数は PostgreSQL でも利用します。

insert_filename_content 関数
def insert_filename_content(filename: str, content: bytes):
    sql = 'INSERT INTO pdfrepo VALUES(?, ?);'
    query = QSqlQuery()
    query.prepare(sql)
    query.bindValue(0, filename)
    query.bindValue(1, QByteArray(content))
    if not query.exec():
        print(query.lastError())

データベースに書き込んだ内容を読み込み直して QPdfView のインスタンスに表示されます。

qt_db_sqlite_pdf.py の実行例 (3) - 読み込んだ PDF ファイルの表示

データベースにあるファイルリストの取得

QPdfView のインスタンス view は、QMainWindow を継承した SQLitePDF クラスに setCentralWidget メソッドで配置されています。

init_ui メソッドの一部
def init_ui(self):
       :
       :
    combo.currentTextChanged.connect(self.on_current_text_changed)
    toolbar.addWidget(combo)
 
    view = QPdfView(self)
    view.setPageMode(QPdfView.PageMode.MultiPage)
    view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
    self.setCentralWidget(view)

QToolBar にある QComboBox に表示するファイルリストは下記の関数のクエリで取得しています。

このクエリを処理する get_list_file 関数は PostgreSQL でも利用します。

get_list_file 関数
def get_list_file(list_file: list):
    query = QSqlQuery()
    sql = 'SELECT name_file FROM pdfrepo;'
    flag = query.exec(sql)
    while query.next():
        list_file.append(query.value(0))
    if not flag:
        print(query.lastError())

QComboBox のインスタンスに表示されている文字が変更されるとシグナルが発生して、インスタンス・メソッド on_current_text_changed メソッドが実行されます。

on_current_text_changed メソッド
def on_current_text_changed(self):
    filename = self.combo.currentText()
    if len(filename) == 0:
        return
 
    if self.con.open():
        content = get_content_from_filename(filename)
        self.con.close()
 
        if content is not None:
            filepath = os.path.join(tempfile.gettempdir(), filename)
            with open(filepath, 'wb') as f:
                f.write(content)
            document = QPdfDocument(self)
            document.load(filepath)
            view: QWidget | QPdfView = self.centralWidget()
            view.setDocument(document)

このメソッドで、データベースから QComboBox のインスタンスに表示された文字列 = ファイル名に対応するファイルの中身(バイナリ・データ)をデータベースから読み込みます(get_content_from_filename 関数)。

データベースから取り出して元に戻したバイナリにファイル名をつけてテンポラリの領域に保存して、それを QPdfDocument のインスタンスで読み込んで、QPdfView のインスタンス view に渡して表示しています。

【追記】テンポラリ領域に保存しない方法 (2024-01-04)

データベースから取り出したバイナリをファイルに保存せずに QPdfDocument のインスタンスに読み込ませる方が効率的です。あとになって動作確認ができたので、追記します。

on_current_text_changed メソッド(改)
def on_current_text_changed(self):
    filename = self.combo.currentText()
    if len(filename) == 0:
        return
 
    if self.con.open():
        content = get_content_from_filename(filename)
        self.con.close()
 
        if content is not None:
            byte_array = QByteArray(content)
            buffer = QBuffer(byte_array)
            buffer.open(QIODevice.OpenModeFlag.ReadOnly)
            document = QPdfDocument(self)
            document.load(buffer)
            view: QWidget | QPdfView = self.centralWidget()
            view.setDocument(document)

QByteArray 型を QBuffer のインスタンスに渡す関係で、get_content_from_filename 関数でデータベースから取り出した際に bytes 型に戻したのに、再び QByteArray 型にすることになりました。最初にサンプルを作った流れを踏襲したので無駄な処理になってしまっています。🙇🏻

バイナリーデータをデータベースから取り出す

クエリで取り出した QByteArray 型のオブジェクトから data メソッドで元のバイナリ取り出して返しています。

この get_content_from_filename 関数は PostgreSQL でも利用します。

get_content_from_filename 関数
def get_content_from_filename(filename: str) -> bytes:
    byte_array = None
    content = None
    query = QSqlQuery()
    sql = """
        SELECT content FROM pdfrepo
        WHERE name_file = '%s';
    """ % filename
    flag = query.exec(sql)
    if query.next():
        byte_array = query.value(0)
    if not flag:
        print(query.lastError())
    if byte_array is not None:
        content = byte_array.data()
    return content

複数の PDF ファイルの読み込み

複数の PDF ファイルをデータベースに読み込むと、QComboBox のインスタンスにファイル名が列挙されます。どれかを選択すれば、その PDF ファイルが表示されます。

qt_db_sqlite_pdf.py の実行例 (4) - 読み込んだ複数の PDF ファイルから選択

データベースに PostgreSQL を利用したサンプル

以下にサンプル qt_db_postgres_pdf.py を示しました(PostgresPDF クラス)。

PostgreSQL 版サンプルの実行に必要なファイル
  • qt_db_sqlite_pdf.py(SQLite 版のスクリプト)
  • qt_db_common_pdf.py(SQLite 版のスクリプト)
  • qt_db_postgres_pdf.py(メイン)
  • qt_db_postgres_pdf.py(データベース接続用ダイアログ)
qt_db_postgres_pdf.py
import sys
from PySide6.QtSql import QSqlDatabase, QSqlQuery
from PySide6.QtWidgets import QApplication
from qt_db_postgres_dialog import DBInfoDlg
from qt_db_sqlite_pdf import SQLitePDF
class PostgresPDF(SQLitePDF):
app_title = 'PostgreSQL & PDF test'
def __init__(self):
super().__init__()
@staticmethod
def create_table():
query = QSqlQuery()
sql = """
CREATE TABLE IF NOT EXISTS pdfrepo (
name_file character varying(255) UNIQUE,
content bytea
);
"""
if not query.exec(sql):
print(query.lastError())
@staticmethod
def get_connection() -> QSqlDatabase:
con = QSqlDatabase.addDatabase('QPSQL')
dict_info = dict()
dlg = DBInfoDlg(dict_info)
if dlg.exec():
con.setHostName(dict_info['host'])
con.setDatabaseName(dict_info['database'])
con.setUserName(dict_info['user'])
con.setPassword(dict_info['password'])
return con
def main():
app = QApplication()
ex = PostgresPDF()
ex.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()

PostgreSQL データベースへ接続するのに必要な情報を入力するダイアログ、DBInfoDlg クラスを用意しました (qt_db_postgres_dialog.py)。サンプルの起動の度に入力するのは面倒ですが、安易にパスワードを露わにして処理することに躊躇しました。😅

qt_db_postgres_dialog.py
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFrame,
QGridLayout,
QLabel,
QLineEdit,
QSizePolicy,
QVBoxLayout,
QWidget,
)
class Entry(QLineEdit):
def __init__(self, key: str):
super().__init__()
self.key: str = key
self.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred
)
self.setStyleSheet("QLineEdit{background-color:white;}")
def getKey(self) -> str:
return self.key
class DBInfoDlg(QDialog):
def __init__(self, dict_info: dict):
super().__init__()
self.dict_info = dict_info
self.setWindowTitle('DB Info')
vbox = QVBoxLayout()
vbox.setAlignment(Qt.AlignmentFlag.AlignTop)
self.setLayout(vbox)
base = QWidget()
self.gen_entries(base)
vbox.addWidget(base)
dlgbtn = QDialogButtonBox.StandardButton.Ok
bbox = QDialogButtonBox(dlgbtn)
bbox.accepted.connect(self.accept)
vbox.addWidget(bbox)
def gen_entries(self, base):
layout = QGridLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(1)
base.setLayout(layout)
for row, key in enumerate(['host', 'database', 'user', 'password']):
if key == 'password':
self.gen_row(layout, key, row, True)
else:
self.gen_row(layout, key, row)
layout.setColumnStretch(0, 0)
layout.setColumnStretch(1, 1)
def gen_row(self, grid: QGridLayout, key: str, row: int, flag: int = False):
lab = QLabel(key)
lab.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
lab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
grid.addWidget(lab, row, 0)
ent = Entry(key)
if flag:
ent.setEchoMode(QLineEdit.EchoMode.Password)
ent.textChanged.connect(self.entry_changed)
grid.addWidget(ent, row, 1)
def entry_changed(self, content: str):
ent: Entry = self.sender()
key = ent.getKey()
self.dict_info[key] = content

サンプルの説明

さて、メインの PostgresPDF クラスですが、SQLite を利用した前述の SQLitePDF クラスを継承して、PostgresSQL 用にオーバーライドしています。

SQLitePDF を継承した PostgresPDF クラス(PostgreSQL 用)
class PostgresPDF(SQLitePDF):
    app_title = 'PostgreSQL & PDF test'
 
    def __init__(self):
        super().__init__()

オーバーライドしているのは、ウィンドウに表示するタイトル文字列と、スタティック・メソッドにした下記の二つのメソッドです。

なお、テーブルを作成する SQL は、SQLite と PostgreSQL で共通にできるのかもしれませんが、ここでは別々にしました。

create_table メソッド(PostgreSQL 用)
@staticmethod
def create_table():
    query = QSqlQuery()
    sql = """
        CREATE TABLE IF NOT EXISTS pdfrepo (
            name_file character varying(255) UNIQUE,
            content bytea
        );
    """
    if not query.exec(sql):
        print(query.lastError())

データベースとの接続を処理する get_connection メソッドでは、DBInfoDlg クラスのダイアログを表示して、接続に必要な情報を取得しています。

get_connection メソッド(PostgreSQL 用)
@staticmethod
def get_connection() -> QSqlDatabase:
    con = QSqlDatabase.addDatabase('QPSQL')
    dict_info = dict()
    dlg = DBInfoDlg(dict_info)
    if dlg.exec():
        con.setHostName(dict_info['host'])
        con.setDatabaseName(dict_info['database'])
        con.setUserName(dict_info['user'])
        con.setPassword(dict_info['password'])
    return con

サンプル qt_db_postgres_pdf.py を実行すると、最初にデータベースへ接続するための情報を入力するダイアログが表示されます。必要事項を正しく入力して OK ボタンをクリックします。入力情報が間違っていてもなにか警告が出るようにはなっていませんのでご注意ください。🙇🏻

qt_db_postgres_pdf.py の実行例 (1) - データベースへの接続ダイアログ

あとの操作は、SQLite 版 (qt_db_sqlite_pdf.py) と同じです。

qt_db_postgres_pdf.py の実行例 (2) - 読み込んだ PDF ファイルの表示例

まとめ

データベースを利用する規模や状況にもよるのでしょうが、定型のレポートなど、あまり大きくないサイズのファイルをデータベースにバイナリで格納するはアリだと思っているので、PDF を例にしてサンプルを作ってみました。

また、なにかデータベースを利用した GUI アプリを作るのに、まず試しに SQLite で動作確認をしてから PostgreSQL などのデータベース・サーバーに移行する、ということがよくあります。移行の際、あまり大きな変更をしなくとも PostgreSQL へ移行するのに効果的な方法はないものかと模索している一環として、クラスを継承するというアプローチを取ってみました。

参考サイト

  1. bitWalk's: SQLite と PDF ファイル ~ PySide6 [2023-12-20]
  2. QByteArray - Qt for Python
  3. 東京国立博物館 - 調査・研究・貸与 出版・刊行物 東京国立博物館ニュース

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



0 件のコメント: