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技術ブログ オープンソースへ
にほんブログ村

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



2023-12-27

【備忘録】JupyterLab のブラウザを指定

JupyterLab は、ノートブック、コード、データのための最新のウェブベースのインタラクティブな開発環境です。その柔軟なインターフェースにより、ユーザーはデータサイエンス、科学計算、計算報道学、機械学習におけるワークフローを設定し、アレンジすることができます。モジュール式の設計により、機能の拡張や充実が可能です。

Project Jupyter のサイトより引用・翻訳

GNOME デスクトップで、いくつもの画面を切り替えて使用している作業環境で、JupyterLab をデフォルトのブラウザ (Google Chrome) 以外で利用したいと常々思っていたのですが、いつもお世話になっている Stack Overflow の Questions にブラウザの指定についての質問とその回答がありましたので [1]、自分の環境で試してみました。

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

Fedora Workstation 39 x86_64
Python 3.11.6
jupyterlab 4.0.9
jupyterlab_server 2.25.2

JupyterLab を起動する環境で下記のコマンドを実行すると、サーバー設定用のファイル jupyter_server_config.py が保存されたというメッセージが表示されます。

(venv) bitwalk@fedora:~$ jupyter server --generate-config
Writing default config to: /home/bitwalk/.jupyter/jupyter_server_config.py

適当なテキストエディタで jupyter_server_config.py を編集します。

(venv) bitwalk@fedora:~$ vi /home/bitwalk/.jupyter/jupyter_server_config.py

下記のように、使用したいブラウザを c.ServerApp.browser に指定して保存します。ここでは Mozilla Firefox を指定しています。

   :
   :
## Specify what command to use to invoke a web
#                        browser when starting the server. If not specified, the
#                        default browser will be determined by the `webbrowser`
#                        standard library module, which allows setting of the
#                        BROWSER environment variable to override it.
#  Default: ''
# c.ServerApp.browser = ''
c.ServerApp.browser = '/usr/bin/firefox'
   :
   :

JupyterLab を起動して、指定したブラウザ上に表示されるかを確認します。

(venv) bitwalk@fedora:~$ jupyter lab
JupyterLab のブラウザを Firefox に設定した例

参考サイト

  1. How do I change the default browser used by Jupyter Notebook in Windows? - Stack Overflow [2017-12-12]

 

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

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



2023-12-22

QWebEngineView ~ PySide6

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

※ 元のサンプルコードを変更したので、それに合わせて過去記事に修正を加えて、日付を変更して移動しました。

QWebEngineView はウェブブラウザのエンジンのクラスです [1]。このクラスについては、以前、PySide2 のときに簡単なサンプルを紹介しました [2]。しかし、もう少し良いサンプルが、Qt for Python のサイトで紹介されていたので、それを引用して、自分用に扱いやすいように直しています。

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

Fedora Workstation 39 x86_64
Python 3.11.6
PySide6 6.6.1
qt_webengineview.py
# https://doc.qt.io/qtforpython/examples/example_webenginewidgets__simplebrowser.html
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 WebEngineWidgets Example"""
import sys
from PySide6.QtCore import QUrl, Signal
from PySide6.QtWebEngineCore import QWebEnginePage
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import (
QApplication,
QLineEdit,
QMainWindow,
QPushButton,
QStyle,
QToolBar,
)
class WebToolBar(QToolBar):
Back = Signal()
Forward = Signal()
Load = Signal(str)
def __init__(self):
super().__init__()
self.address = None
self.init_ui()
def init_ui(self):
but_back = QPushButton()
icon_back = self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack)
but_back.setIcon(icon_back)
but_back.clicked.connect(self.back)
self.addWidget(but_back)
but_forward = QPushButton()
icon_forward = self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowForward)
but_forward.setIcon(icon_forward)
but_forward.clicked.connect(self.forward)
self.addWidget(but_forward)
self.address = address = QLineEdit()
address.returnPressed.connect(self.load)
self.addWidget(address)
def back(self):
self.Back.emit()
def forward(self):
self.Forward.emit()
def load(self):
lineedit: QLineEdit = self.sender()
self.Load.emit(lineedit.text())
def setURL(self, url: QUrl):
self.address.setText(url.toString())
class Example(QMainWindow):
def __init__(self):
super().__init__()
self.toolbar = None
self.view = None
url = QUrl('https://www.qt.io/')
self.init_ui(url)
self.resize(1000, 800)
def init_ui(self, url_init: QUrl):
self.toolbar = toolbar = WebToolBar()
toolbar.Back.connect(self.back)
toolbar.Forward.connect(self.forward)
toolbar.Load.connect(self.load)
self.addToolBar(toolbar)
self.view = view = QWebEngineView()
self.setCentralWidget(view)
toolbar.setURL(url_init)
view.load(url_init)
view.page().titleChanged.connect(self.setWindowTitle)
view.page().urlChanged.connect(self.url_changed)
def load(self, url_str: str):
url = QUrl.fromUserInput(url_str)
if url.isValid():
self.view.load(url)
def back(self):
self.view.page().triggerAction(QWebEnginePage.WebAction.Back)
def forward(self):
self.view.page().triggerAction(QWebEnginePage.WebAction.Forward)
def url_changed(self, url: QUrl):
self.toolbar.setURL(url)
def main():
app = QApplication()
ex = Example()
ex.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
qt_webengineview.py の実行例

参考サイト

  1. QWebEngineView - Qt for Python
  2. bitWalk's: QWebEngineView を使う (PySide2) [2021-03-28]
  3. Simple Browser Example - Qt for Python

 

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

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



2023-12-21

KDE Plasma 6 Beta 2 リリース

KDE Plasma は、主に Linux 向けに開発されているデスクトップ環境です。現在リリースされている KDE Plasma 5 は、Qt 5 ライブラリを利用していますが、その後継である Qt 6 を利用した KDE Plasma 6 がリリースに向けて開発中です。

KDE プロジェクトは、12 月 20 日(現地時間)に、現在開発中の次期 KDE Plasma 6 の Beta 2 (5.91.90) のリリースをアナウンスしました [1]

KDE プロジェクトがリリースしている Linux ディストロ KDE neon の unstable 版 [2] を更新したところ、壁紙の右下の文字列が KDE Plasma 6 Beta 2 になりました。

GNOME Boxes 上にインストールした neon-unstable-20231126-1118.iso を最新にアップデート(12/21 時点)

参考サイト

  1. KDE's 6th Megarelease - Beta 2 - KDE Community [2023-12-20]
  2. KDE neon

 

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

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



2023-12-20

SQLite と PDF ファイル ~ PySide6

SQLite は、パブリックドメインの軽量なリレーショナルデータベース管理システム (RDBMS) です。他の多くのデータベース管理システムとは対照的に、サーバとしてではなくアプリケーションに組み込んで利用するデータベースです。 一般的な RDBMS と違い、API は単純にライブラリを呼び出すだけであり、データの保存に単一のファイルを使用することが特徴です。

Wikipedia より引用、編集
今回のテーマ

選択した PDF ファイルを読み込んで SQLite のデータベースに格納します。そして、データベースから読み込んだ内容を(一旦保存して) QPdfView クラスのウィジェットで開く、という GUI サンプルを紹介します。

  • 本ブログの過去記事 [1] を PySide2 から PySide6 に変更して、PySide6 / Qt6 の機能をなるべく利用できるような内容に見直しました。

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

Fedora Workstation 39 x86_64
Python 3.11.6
PySide6 6.6.1

少し長いサンプルになってしまいましたが、以下にサンプル qt_sqlite_pdf.py を示しました。

qt_sqlite_pdf.py
import base64
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,
)
def create_table():
query = QSqlQuery()
sql = """
CREATE TABLE IF NOT EXISTS file (
name_file TEXT UNIQUE,
content NONE
);
"""
if not query.exec(sql):
print(query.lastError())
def get_content_from_filename(filename: str) -> bytes:
content = None
query = QSqlQuery()
sql = """
SELECT content FROM file
WHERE name_file = "%s";
""" % filename
query.exec(sql)
if query.next():
content_str = query.value(0)
content = base64.b64decode(content_str.encode())
return content
def get_list_file(list_file: list):
query = QSqlQuery()
sql = 'SELECT name_file FROM file;'
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_str: str):
sql = 'INSERT INTO file VALUES(?, ?);'
query = QSqlQuery()
query.prepare(sql)
query.bindValue(0, filename)
query.bindValue(1, content_str)
"""
note: This does not work!
query.bindValue(
1, content,
type=QSql.ParamTypeFlag.In | QSql.ParamTypeFlag.Binary
)
"""
if not query.exec():
print(query.lastError())
class Example(QMainWindow):
def __init__(self):
super().__init__()
self.dbname = 'test.sqlite'
self.con = QSqlDatabase.addDatabase('QSQLITE')
self.con.setDatabaseName(self.dbname)
self.init_table()
self.combo = None
self.init_ui()
self.update_filelist()
self.setWindowTitle('SQLite & PDF Test')
self.resize(600, 800)
def init_table(self):
if self.con.open():
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 = QComboBox()
self.combo.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred
)
self.combo.currentTextChanged.connect(self.on_current_text_changed)
toolbar.addWidget(self.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 = base64.b64encode(f.read())
content_str: str = content.decode()
if self.con.open():
insert_filename_content(basename, content_str)
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 = Example()
ex.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()

サンプルの説明

初期化

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

class Example(QMainWindow):
    def __init__(self):
        super().__init__()
        self.dbname = 'test.sqlite'
        self.con = QSqlDatabase.addDatabase('QSQLITE')
        self.con.setDatabaseName(self.dbname)
        self.init_table()
           :
           :

その後、使用するデータベースのテーブルの初期化をします。

def init_table(self):
    if self.con.open():
        create_table()
        self.con.close()

テーブルの初期化処理は下記のように単純なものです。

def create_table():
    query = QSqlQuery()
    sql = """
        CREATE TABLE IF NOT EXISTS file (
            name_file TEXT UNIQUE,
            content NONE
        );
    """
    if not query.exec(sql):
        print(query.lastError())

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

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

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

qt_sqlite_pdf.py の実行例 (1)

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

qt_sqlite_pdf.py の実行例 (2)

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

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 = base64.b64encode(f.read())
            content_str: str = content.decode()
 
        if self.con.open():
            insert_filename_content(basename, content_str)
            self.con.close()
            self.update_filelist()
            self.combo.setCurrentText(basename)

PySide2 を利用した過去記事 [1] では、sqlite3 モジュールを利用して、なにも考えずにバイナリをデータベースに格納できたのですが、PySide6 では QSqlQuery のインスタンスで同じようにしたところ、バイナリを書き込むことができませんでした。

PySide6 ではクエリのタイプにバイナリを指定できるのですが、これでは解決できなかったので、やむなく Python の標準ライブラリで利用できる Base64 でエンコードした byte 型のデータ列を文字列にデコードして格納しています。

def insert_filename_content(filename: str, content_str: str):
    sql = 'INSERT INTO file VALUES(?, ?);'
    query = QSqlQuery()
    query.prepare(sql)
    query.bindValue(0, filename)
    query.bindValue(1, content_str)
    """
    note: This does not work!
    query.bindValue(
        1, content,
        type=QSql.ParamTypeFlag.In | QSql.ParamTypeFlag.Binary
    )
    """
    if not query.exec():
        print(query.lastError())

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

qt_sqlite_pdf.py の実行例 (3)

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

def init_ui(self):
       :
       :
    self.combo.currentTextChanged.connect(self.on_current_text_changed)
    toolbar.addWidget(self.combo)
 
    view = QPdfView(self)
    view.setPageMode(QPdfView.PageMode.MultiPage)
    view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
    self.setCentralWidget(view)

QComboBox のインスタンスに表示されている文字が変更されるとシグナルが発生して、インスタンス・メソッド 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 のインスタンスに表示された文字列 = ファイル名に対応するファイルの中身をデータベースから読み込みます。

クエリの処理は下記のようなっています。ここで、取り出した文字列を byte 型にエンコードして、Base64 のデコードをして元のバイナリに戻しています(こうやって説明すると、なんだか面倒くさいことをしていると思います、今後の改善課題です)。

def get_content_from_filename(filename: str) -> bytes:
    content = None
    query = QSqlQuery()
    sql = """
        SELECT content FROM file
        WHERE name_file = "%s";
    """ % filename
    query.exec(sql)
    if query.next():
        content_str = query.value(0)
        content = base64.b64decode(content_str.encode())
    return content

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

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

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

qt_sqlite_pdf.py の実行例 (4)

既知の問題

PySide6 / Qt6 の PDF を扱うモジュールをまだ使い慣れていないためか、標準出力に下記のような警告が出ます。特定の PDF ファイルで警告が出るのですが、それが PDF ファイルの問題なのか特定できていません。対応方法が判ったら追記します。

qt.pdf.links: skipping link with invalid page number
qt.pdf.links: skipping link with invalid page number
qt.pdf.links: skipping link with invalid page number
    :
    :
    :

参考サイト

  1. bitWalk's: 【備忘録】SQLite のデータベースでバイナリデータを扱う [2021-02-18]
  2. QPdfDocument - Qt for Python
  3. QPdfView - Qt for Python

 

 

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

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