1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-09 10:10:55 +02:00

Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release
b005542df3 2.43.0
Automatically generated by python-semantic-release
2025-10-30 07:58:54 +00:00
13a9175ba5 feat: add pdf viewer widget 2025-10-30 08:58:11 +01:00
semantic-release
3f8e60a14f 2.42.1
Automatically generated by python-semantic-release
2025-10-28 14:48:23 +00:00
6bc1c3c5f1 fix(rpc_server): raise window, even if minimized 2025-10-28 15:47:37 +01:00
12 changed files with 1336 additions and 11 deletions

View File

@@ -1,6 +1,22 @@
# CHANGELOG
## v2.43.0 (2025-10-30)
### Features
- Add pdf viewer widget
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
## v2.42.1 (2025-10-28)
### Bug Fixes
- **rpc_server**: Raise window, even if minimized
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
## v2.42.0 (2025-10-21)
### Features

View File

@@ -45,6 +45,7 @@ _Widgets = {
"MonacoWidget": "MonacoWidget",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PdfViewerWidget": "PdfViewerWidget",
"PositionIndicator": "PositionIndicator",
"PositionerBox": "PositionerBox",
"PositionerBox2D": "PositionerBox2D",
@@ -3421,6 +3422,137 @@ class MultiWaveform(RPCBase):
"""
class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls."""
@rpc_call
def load_pdf(self, file_path: str):
"""
Load a PDF file into the viewer.
Args:
file_path (str): Path to the PDF file to load.
"""
@rpc_call
def zoom_in(self):
"""
Zoom in the PDF view.
"""
@rpc_call
def zoom_out(self):
"""
Zoom out the PDF view.
"""
@rpc_call
def fit_to_width(self):
"""
Fit PDF to width.
"""
@rpc_call
def fit_to_page(self):
"""
Fit PDF to page.
"""
@rpc_call
def reset_zoom(self):
"""
Reset zoom to 100% (1.0 factor).
"""
@rpc_call
def previous_page(self):
"""
Go to previous page.
"""
@rpc_call
def next_page(self):
"""
Go to next page.
"""
@rpc_call
def toggle_continuous_scroll(self, checked: bool):
"""
Toggle between single page and continuous scroll mode.
Args:
checked (bool): True to enable continuous scroll, False for single page mode.
"""
@property
@rpc_call
def page_spacing(self):
"""
Get the spacing between pages in continuous scroll mode.
"""
@page_spacing.setter
@rpc_call
def page_spacing(self):
"""
Get the spacing between pages in continuous scroll mode.
"""
@property
@rpc_call
def side_margins(self):
"""
Get the horizontal margins (side spacing) around the PDF content.
"""
@side_margins.setter
@rpc_call
def side_margins(self):
"""
Get the horizontal margins (side spacing) around the PDF content.
"""
@rpc_call
def go_to_first_page(self):
"""
Go to the first page.
"""
@rpc_call
def go_to_last_page(self):
"""
Go to the last page.
"""
@rpc_call
def jump_to_page(self, page_number: int):
"""
Jump to a specific page number (1-based index).
"""
@property
@rpc_call
def current_page(self):
"""
Get the current page number (1-based index).
"""
@property
@rpc_call
def current_file_path(self):
"""
Get the current PDF file path.
"""
@current_file_path.setter
@rpc_call
def current_file_path(self):
"""
Get the current PDF file path.
"""
class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits."""

View File

@@ -11,7 +11,7 @@ from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
@@ -129,16 +129,44 @@ class RPCServer:
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
if method == "raise" and hasattr(
obj, "setWindowState"
): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
# and call raise_() and activateWindow()
# This forces gnome to raise the window even if focus stealing is prevented
# 3. Flag for stay on top is removed again to restore the original window state
# 4. Finally, we call show() to ensure the window is visible
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
target_state = state | Qt.WindowActive
if state & Qt.WindowMinimized:
target_state &= ~Qt.WindowMinimized
obj.setWindowState(target_state)
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
obj.showNormal()
if hasattr(obj, "raise_"):
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
obj.raise_()
if hasattr(obj, "activateWindow"):
obj.activateWindow()
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
obj.show()
res = None
else:
res = method_obj(*args, **kwargs)
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]

View File

@@ -0,0 +1,574 @@
import os
from typing import Optional
from qtpy.QtCore import QMargins, Qt, Signal
from qtpy.QtGui import QIntValidator
from qtpy.QtPdf import QPdfDocument
from qtpy.QtPdfWidgets import QPdfView
from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
class PdfViewerWidget(BECWidget, QWidget):
"""A widget to display PDF documents with toolbar controls."""
# Emitted when a PDF document is successfully loaded, providing the file path.
document_ready = Signal(str)
PLUGIN = True
RPC = True
ICON_NAME = "picture_as_pdf"
USER_ACCESS = [
"load_pdf",
"zoom_in",
"zoom_out",
"fit_to_width",
"fit_to_page",
"reset_zoom",
"previous_page",
"next_page",
"toggle_continuous_scroll",
"page_spacing",
"page_spacing.setter",
"side_margins",
"side_margins.setter",
"go_to_first_page",
"go_to_last_page",
"jump_to_page",
"current_page",
"current_file_path",
"current_file_path.setter",
]
def __init__(
self, parent: Optional[QWidget] = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
# Set up the layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create the PDF document and view first
self._pdf_document = QPdfDocument(self)
self.pdf_view = QPdfView()
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
# Create toolbar after PDF components are initialized
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self._setup_toolbar()
# Add widgets to layout
layout.addWidget(self.toolbar)
layout.addWidget(self.pdf_view)
# Current file path and spacing settings
self._current_file_path = None
self._page_spacing = 5 # Default spacing between pages in continuous mode
self._side_margins = 10 # Default side margins (horizontal spacing)
def _setup_toolbar(self):
"""Set up the toolbar with PDF control buttons."""
# Create separate bundles for different control groups
file_bundle = self.toolbar.new_bundle("file_controls")
zoom_bundle = self.toolbar.new_bundle("zoom_controls")
view_bundle = self.toolbar.new_bundle("view_controls")
nav_bundle = self.toolbar.new_bundle("navigation_controls")
# File operations
open_action = MaterialIconAction(
icon_name="folder_open", tooltip="Open PDF File", parent=self
)
open_action.action.triggered.connect(self.open_file_dialog)
self.toolbar.components.add("open_file", open_action)
file_bundle.add_action("open_file")
# Zoom controls
zoom_in_action = MaterialIconAction(icon_name="zoom_in", tooltip="Zoom In", parent=self)
zoom_in_action.action.triggered.connect(self.zoom_in)
self.toolbar.components.add("zoom_in", zoom_in_action)
zoom_bundle.add_action("zoom_in")
zoom_out_action = MaterialIconAction(icon_name="zoom_out", tooltip="Zoom Out", parent=self)
zoom_out_action.action.triggered.connect(self.zoom_out)
self.toolbar.components.add("zoom_out", zoom_out_action)
zoom_bundle.add_action("zoom_out")
fit_width_action = MaterialIconAction(
icon_name="fit_screen", tooltip="Fit to Width", parent=self
)
fit_width_action.action.triggered.connect(self.fit_to_width)
self.toolbar.components.add("fit_width", fit_width_action)
zoom_bundle.add_action("fit_width")
fit_page_action = MaterialIconAction(
icon_name="fullscreen", tooltip="Fit to Page", parent=self
)
fit_page_action.action.triggered.connect(self.fit_to_page)
self.toolbar.components.add("fit_page", fit_page_action)
zoom_bundle.add_action("fit_page")
reset_zoom_action = MaterialIconAction(
icon_name="center_focus_strong", tooltip="Reset Zoom to 100%", parent=self
)
reset_zoom_action.action.triggered.connect(self.reset_zoom)
self.toolbar.components.add("reset_zoom", reset_zoom_action)
zoom_bundle.add_action("reset_zoom")
# View controls
continuous_scroll_action = MaterialIconAction(
icon_name="view_agenda", tooltip="Toggle Continuous Scroll", checkable=True, parent=self
)
continuous_scroll_action.action.toggled.connect(self.toggle_continuous_scroll)
self.toolbar.components.add("continuous_scroll", continuous_scroll_action)
view_bundle.add_action("continuous_scroll")
# Navigation controls
prev_page_action = MaterialIconAction(
icon_name="navigate_before", tooltip="Previous Page", parent=self
)
prev_page_action.action.triggered.connect(self.previous_page)
self.toolbar.components.add("prev_page", prev_page_action)
nav_bundle.add_action("prev_page")
next_page_action = MaterialIconAction(
icon_name="navigate_next", tooltip="Next Page", parent=self
)
next_page_action.action.triggered.connect(self.next_page)
self.toolbar.components.add("next_page", next_page_action)
nav_bundle.add_action("next_page")
# Page jump widget (in navigation bundle)
self._setup_page_jump_widget(nav_bundle)
# Show all bundles
self.toolbar.show_bundles(
["file_controls", "zoom_controls", "view_controls", "navigation_controls"]
)
# Initialize navigation button tooltips for single page mode (default)
self._update_navigation_buttons_for_mode(continuous=False)
# Initialize navigation button states
self._update_navigation_button_states()
def _setup_page_jump_widget(self, nav_bundle):
"""Set up the page jump widget (label + line edit)."""
# Create a container widget for the page jump controls
page_jump_container = QWidget()
page_jump_layout = QHBoxLayout(page_jump_container)
page_jump_layout.setContentsMargins(5, 0, 5, 0)
page_jump_layout.setSpacing(3)
# Page input field
self.page_input = QLineEdit()
self.page_input.setValidator(QIntValidator(1, 100000)) # restrict to 1100000
self.page_input.setFixedWidth(50)
self.page_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.page_input.setPlaceholderText("1")
self.page_input.setToolTip("Enter page number and press Enter")
self.page_input.returnPressed.connect(self._line_edit_jump_to_page)
# Total pages label
self.total_pages_label = QLabel("/ 1")
self.total_pages_label.setStyleSheet("color: #666; font-size: 12px;")
# Add widgets to layout
page_jump_layout.addWidget(self.page_input)
page_jump_layout.addWidget(self.total_pages_label)
# Create a WidgetAction for the page jump controls
# No manual separator needed - bundles are automatically separated
page_jump_action = WidgetAction(
label="Page:", widget=page_jump_container, adjust_size=False, parent=self
)
self.toolbar.components.add("page_jump", page_jump_action)
nav_bundle.add_action("page_jump")
def _line_edit_jump_to_page(self):
"""Jump to the page entered in the line edit."""
page_text = self.page_input.text().strip()
if not page_text:
return
# We validated input to be integer, so safe to convert directly
self.jump_to_page(int(page_text))
def _update_navigation_button_states(self):
"""Update the enabled/disabled state of navigation buttons."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
# No document loaded - disable all navigation
self._set_navigation_enabled(False, False)
self._update_page_display(1, 1)
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
total_pages = self._pdf_document.pageCount()
# Update button states
prev_enabled = current_page > 0
next_enabled = current_page < (total_pages - 1)
self._set_navigation_enabled(prev_enabled, next_enabled)
# Update page display
self._update_page_display(current_page + 1, total_pages)
def _set_navigation_enabled(self, prev_enabled: bool, next_enabled: bool):
"""Set the enabled state of navigation buttons."""
prev_action = self.toolbar.components.get_action("prev_page")
if prev_action and hasattr(prev_action, "action") and prev_action.action:
prev_action.action.setEnabled(prev_enabled)
next_action = self.toolbar.components.get_action("next_page")
if next_action and hasattr(next_action, "action") and next_action.action:
next_action.action.setEnabled(next_enabled)
def _update_page_display(self, current_page: int, total_pages: int):
"""Update the page display in the toolbar."""
if hasattr(self, "page_input"):
self.page_input.setText(str(current_page))
self.page_input.setPlaceholderText(str(current_page))
if hasattr(self, "total_pages_label"):
self.total_pages_label.setText(f"/ {total_pages}")
@SafeProperty(str)
def current_file_path(self):
"""Get the current PDF file path."""
return self._current_file_path
@current_file_path.setter
def current_file_path(self, value: str):
"""
Set the current PDF file path and load the document.
Args:
value (str): Path to the PDF file to load.
"""
if not isinstance(value, str):
raise ValueError("current_file_path must be a string")
self.load_pdf(value)
@SafeProperty(int)
def page_spacing(self):
"""Get the spacing between pages in continuous scroll mode."""
return self._page_spacing
@property
def current_page(self):
"""Get the current page number (1-based index)."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return 0
navigator = self.pdf_view.pageNavigator()
return navigator.currentPage() + 1
@page_spacing.setter
def page_spacing(self, value: int):
"""
Set the spacing between pages in continuous scroll mode.
Args:
value (int): Spacing in pixels (non-negative integer).
"""
if not isinstance(value, int):
raise ValueError("page_spacing must be an integer")
if value < 0:
raise ValueError("page_spacing must be non-negative")
self._page_spacing = value
# If currently in continuous scroll mode, update the spacing immediately
if self.pdf_view.pageMode() == QPdfView.PageMode.MultiPage:
self.pdf_view.setPageSpacing(self._page_spacing)
@SafeProperty(int)
def side_margins(self):
"""Get the horizontal margins (side spacing) around the PDF content."""
return self._side_margins
@side_margins.setter
def side_margins(self, value: int):
"""Set the horizontal margins (side spacing) around the PDF content."""
if not isinstance(value, int):
raise ValueError("side_margins must be an integer")
if value < 0:
raise ValueError("side_margins must be non-negative")
self._side_margins = value
# Update the document margins immediately
# setDocumentMargins takes a QMargins(left, top, right, bottom)
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
self.pdf_view.setDocumentMargins(margins)
def open_file_dialog(self):
"""Open a file dialog to select a PDF file."""
file_path, _ = QFileDialog.getOpenFileName(
self, "Open PDF File", "", "PDF Files (*.pdf);;All Files (*)"
)
if file_path:
self.load_pdf(file_path)
@SafeSlot(str, popup_error=True)
def load_pdf(self, file_path: str):
"""
Load a PDF file into the viewer.
Args:
file_path (str): Path to the PDF file to load.
"""
# Validate file exists
if not os.path.isfile(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
self._current_file_path = file_path
# Disconnect any existing signal connections
try:
self._pdf_document.statusChanged.disconnect(self._on_document_status_changed)
except (TypeError, RuntimeError):
pass
# Connect to statusChanged signal to handle when document is ready
self._pdf_document.statusChanged.connect(self._on_document_status_changed)
# Load the document
self._pdf_document.load(file_path)
# If already ready (synchronous loading), set document immediately
if self._pdf_document.status() == QPdfDocument.Status.Ready:
self._on_document_ready()
@SafeSlot(QPdfDocument.Status)
def _on_document_status_changed(self, status: QPdfDocument.Status):
"""Handle document status changes."""
status = self._pdf_document.status()
if status == QPdfDocument.Status.Ready:
self._on_document_ready()
elif status == QPdfDocument.Status.Error:
raise RuntimeError(f"Failed to load PDF document: {self._current_file_path}")
def _on_document_ready(self):
"""Handle when document is ready to be displayed."""
self.pdf_view.setDocument(self._pdf_document)
# Set initial margins
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
self.pdf_view.setDocumentMargins(margins)
# Connect to page changes to update navigation button states
navigator = self.pdf_view.pageNavigator()
navigator.currentPageChanged.connect(self._on_page_changed)
# Make sure we start at the first page
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
# Update initial navigation state
self._update_navigation_button_states()
self.document_ready.emit(self._current_file_path)
def _on_page_changed(self, _page):
"""Handle page change events to update navigation states."""
self._update_navigation_button_states()
# Toolbar action methods
@SafeSlot()
def zoom_in(self):
"""Zoom in the PDF view."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
current_factor = self.pdf_view.zoomFactor()
new_factor = current_factor * 1.25
self.pdf_view.setZoomFactor(new_factor)
@SafeSlot()
def zoom_out(self):
"""Zoom out the PDF view."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
current_factor = self.pdf_view.zoomFactor()
new_factor = max(current_factor / 1.25, 0.1)
self.pdf_view.setZoomFactor(new_factor)
@SafeSlot()
def fit_to_width(self):
"""Fit PDF to width."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
@SafeSlot()
def fit_to_page(self):
"""Fit PDF to page."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitInView)
@SafeSlot()
def reset_zoom(self):
"""Reset zoom to 100% (1.0 factor)."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
self.pdf_view.setZoomFactor(1.0)
@SafeSlot()
def previous_page(self):
"""Go to previous page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
if current_page == 0:
self._update_navigation_button_states()
return
try:
target_page = current_page - 1
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
except Exception:
try:
# Fallback: Use scroll to approximate position
page_height = self.pdf_view.viewport().height()
self.pdf_view.verticalScrollBar().setValue(
self.pdf_view.verticalScrollBar().value() - page_height
)
except Exception:
pass
# Update navigation button states (in case signal doesn't fire)
self._update_navigation_button_states()
@SafeSlot()
def next_page(self):
"""Go to next page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
max_page = self._pdf_document.pageCount() - 1
if current_page < max_page:
try:
target_page = current_page + 1
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
except Exception:
try:
# Fallback: Use scroll to approximate position
page_height = self.pdf_view.viewport().height()
self.pdf_view.verticalScrollBar().setValue(
self.pdf_view.verticalScrollBar().value() + page_height
)
except Exception:
pass
# Update navigation button states (in case signal doesn't fire)
self._update_navigation_button_states()
@SafeSlot(bool)
def toggle_continuous_scroll(self, checked: bool):
"""
Toggle between single page and continuous scroll mode.
Args:
checked (bool): True to enable continuous scroll, False for single page mode.
"""
if checked:
self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
self.pdf_view.setPageSpacing(self._page_spacing)
self._update_navigation_buttons_for_mode(continuous=True)
tooltip = "Switch to Single Page Mode"
else:
self.pdf_view.setPageMode(QPdfView.PageMode.SinglePage)
self._update_navigation_buttons_for_mode(continuous=False)
tooltip = "Switch to Continuous Scroll Mode"
# Update navigation button states after mode change
self._update_navigation_button_states()
# Update toggle button tooltip to reflect current state
action = self.toolbar.components.get_action("continuous_scroll")
if action and hasattr(action, "action") and action.action:
action.action.setToolTip(tooltip)
def _update_navigation_buttons_for_mode(self, continuous: bool):
"""Update navigation button tooltips based on current mode."""
prev_action = self.toolbar.components.get_action("prev_page")
next_action = self.toolbar.components.get_action("next_page")
if continuous:
prev_actions_tooltip = "Previous Page (use scroll in continuous mode)"
next_actions_tooltip = "Next Page (use scroll in continuous mode)"
else:
prev_actions_tooltip = "Previous Page"
next_actions_tooltip = "Next Page"
if prev_action and hasattr(prev_action, "action") and prev_action.action:
prev_action.action.setToolTip(prev_actions_tooltip)
if next_action and hasattr(next_action, "action") and next_action.action:
next_action.action.setToolTip(next_actions_tooltip)
@SafeSlot()
def go_to_first_page(self):
"""Go to the first page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
@SafeSlot()
def go_to_last_page(self):
"""Go to the last page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
last_page = self._pdf_document.pageCount() - 1
navigator.update(last_page, navigator.currentLocation(), navigator.currentZoom())
@SafeSlot(int)
def jump_to_page(self, page_number: int):
"""Jump to a specific page number (1-based index)."""
if not isinstance(page_number, int):
raise ValueError("page_number must be an integer")
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
raise RuntimeError("No PDF document loaded")
max_page = self._pdf_document.pageCount()
page_number = max(min(page_number, max_page), 1)
target_page = page_number - 1 # Convert to 0-based index
navigator = self.pdf_view.pageNavigator()
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
def cleanup(self):
"""Handle widget close event to prevent segfaults."""
if hasattr(self, "_pdf_document") and self._pdf_document:
self._pdf_document.statusChanged.disconnect()
empty_doc = QPdfDocument(self)
self.pdf_view.setDocument(empty_doc)
if hasattr(self, "toolbar"):
self.toolbar.cleanup()
super().cleanup()
if __name__ == "__main__":
import sys
# from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
# apply_theme("dark")
viewer = PdfViewerWidget()
# viewer.load_pdf("/Path/To/Your/TestDocument.pdf")
viewer.next_page()
# viewer.page_spacing = 0
# viewer.side_margins = 0
viewer.resize(1000, 700)
viewer.show()
sys.exit(app.exec())

View File

@@ -0,0 +1 @@
{'files': ['pdf_viewer.py']}

View File

@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
DOM_XML = """
<ui language='c++'>
<widget class='PdfViewerWidget' name='pdf_viewer_widget'>
</widget>
</ui>
"""
class PdfViewerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PdfViewerWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(PdfViewerWidget.ICON_NAME)
def includeFile(self):
return "pdf_viewer_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "PdfViewerWidget"
def toolTip(self):
return "A widget to display PDF documents with toolbar controls."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer_widget_plugin import (
PdfViewerWidgetPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(PdfViewerWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

View File

@@ -0,0 +1,119 @@
(user.widgets.pdf_viewer_widget)=
# PDF Viewer Widget
````{tab} Overview
The PDF Viewer Widget is a versatile tool designed for displaying and navigating PDF documents within your BEC applications. Directly integrated with the `BEC` framework, it provides a full-featured PDF viewing experience with zoom controls, page navigation, and customizable display options.
## Key Features:
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
- **Full PDF Support**: Display any PDF document with full rendering support through Qt's PDF rendering engine.
- **Navigation Controls**: Built-in toolbar with page navigation, zoom controls, and document status indicators.
- **Customizable Display**: Adjustable page spacing, margins, and zoom levels for optimal viewing experience.
- **Document Management**: Load different PDF files dynamically during runtime with proper error handling.
## User Interface Components:
- **Toolbar**: Contains all navigation and zoom controls
- Previous/Next page buttons
- Page number input field with total page count
- First/Last page navigation buttons
- Zoom in/out buttons
- Fit to width/page buttons
- Reset zoom button
- **PDF View Area**: Main display area for the PDF content
````
````{tab} Examples - CLI
`PdfViewerWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
## Example 1 - Basic PDF Loading
In this example, we demonstrate how to add a `PdfViewerWidget` to a [`BECDockArea`](user.widgets.bec_dock_area) and load a PDF document.
```python
# Add a new dock with PDF viewer widget
dock_area = gui.new()
pdf_viewer = dock_area.new().new(gui.available_widgets.PdfViewerWidget)
# Load a PDF file
pdf_viewer.load_pdf("/path/to/your/document.pdf")
```
## Example 2 - Customizing Display Properties
This example shows how to customize the display properties of the PDF viewer for better presentation.
```python
# Create PDF viewer
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
# Load PDF document
pdf_viewer.load_pdf("/path/to/report.pdf")
pdf_viewer.toggle_continuous_scroll(True) # Enable continuous scroll mode
# Customize display properties
pdf_viewer.page_spacing = 20 # Increase spacing between pages
pdf_viewer.side_margins = 50 # Add horizontal margins
# Navigate to specific page
pdf_viewer.jump_to_page(5) # Go to page 5
```
## Example 3 - Navigation and Zoom Controls
The PDF viewer provides programmatic access to all navigation and zoom functionality.
```python
# Create and load PDF
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
pdf_viewer.load_pdf("/path/to/manual.pdf")
# Navigation examples
pdf_viewer.go_to_first_page() # Go to first page
pdf_viewer.go_to_last_page() # Go to last page
pdf_viewer.jump_to_page(10) # Jump to specific page
# Zoom controls
pdf_viewer.zoom_in() # Increase zoom
pdf_viewer.zoom_out() # Decrease zoom
pdf_viewer.fit_to_width() # Fit document to window width
pdf_viewer.fit_to_page() # Fit entire page to window
pdf_viewer.reset_zoom() # Reset to 100% zoom
# Check current status
current_page = pdf_viewer.current_page
print(f"Currently viewing page {current_page}")
```
## Example 4 - Dynamic Document Loading
This example demonstrates how to switch between different PDF documents dynamically.
```python
# Create PDF viewer
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
# Load first document
pdf_viewer.load_pdf("/path/to/document1.pdf")
# Or simply set the current file path
pdf_viewer.current_file_path = "/path/to/document2.pdf"
# This automatically loads the new document
# Check which file is currently loaded
current_file = pdf_viewer.current_file_path
print(f"Currently viewing: {current_file}")
```
````
````{tab} API
```{eval-rst}
.. autoclass:: bec_widgets.cli.client.PdfViewerWidget
:members:
:show-inheritance:
```
````

View File

@@ -270,6 +270,14 @@ Select DAP model from a list of DAP processes.
Show and filter logs from the BEC Redis server.
```
```{grid-item-card} PDF Viewer Widget
:link: user.widgets.pdf_viewer_widget
:link-type: ref
:img-top: /assets/widget_screenshots/pdf_viewer.png
Display and navigate PDF documents.
```
````
```{toctree}
@@ -307,6 +315,7 @@ dap_combo_box/dap_combo_box.md
games/games.md
log_panel/log_panel.md
signal_label/signal_label.md
pdf_viewer/pdf_viewer_widget.md
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.42.0"
version = "2.43.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [

View File

@@ -0,0 +1,372 @@
import pytest
from qtpy.QtPdf import QPdfDocument
from qtpy.QtPdfWidgets import QPdfView
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
from .client_mocks import mocked_client
@pytest.fixture
def pdf_viewer_widget(qtbot, mocked_client):
"""Create a PDF viewer widget for testing."""
widget = PdfViewerWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.cleanup()
@pytest.fixture
def temp_pdf_file(tmpdir):
"""Create a minimal 3-page PDF file for testing."""
pdf_content = b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 1) Tj ET
endstream
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R >>
endobj
6 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 2) Tj ET
endstream
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R >>
endobj
8 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 3) Tj ET
endstream
endobj
9 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 10
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000125 00000 n
0000000205 00000 n
0000000282 00000 n
0000000362 00000 n
0000000439 00000 n
0000000519 00000 n
0000000596 00000 n
trailer
<< /Size 10 /Root 1 0 R >>
startxref
675
%%EOF
"""
pdf_path = tmpdir.join("test_3page.pdf")
pdf_path.write_binary(pdf_content)
return str(pdf_path)
@pytest.fixture
def temp_pdf_file_2(tmpdir):
"""Create a second minimal temporary PDF file for testing."""
# Create a minimal PDF content for testing
pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 44
>>stream
BT
/F1 12 Tf
100 700 Td
(Second Test PDF) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000307 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
398
%%EOF"""
# Create temporary PDF file using tmpdir
pdf_file = tmpdir.join("test2.pdf")
pdf_file.write_binary(pdf_content)
return str(pdf_file)
def test_initialization(pdf_viewer_widget: PdfViewerWidget):
"""Test that the widget initializes correctly."""
widget = pdf_viewer_widget
# Check basic widget setup
assert widget is not None
assert hasattr(widget, "pdf_view")
assert hasattr(widget, "toolbar")
assert hasattr(widget, "_pdf_document")
# Check initial state
assert widget._current_file_path is None
assert widget._page_spacing == 5
assert widget._side_margins == 10
# Check PDF view setup
assert isinstance(widget.pdf_view, QPdfView)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Check PDF document setup
assert isinstance(widget._pdf_document, QPdfDocument)
def test_toolbar_setup(pdf_viewer_widget: PdfViewerWidget):
"""Test that toolbar is set up with all expected actions."""
widget = pdf_viewer_widget
toolbar = widget.toolbar
# Check that all expected actions exist
expected_actions = [
"open_file",
"zoom_in",
"zoom_out",
"fit_width",
"fit_page",
"reset_zoom",
"continuous_scroll",
"prev_page",
"next_page",
"page_jump",
]
for action_name in expected_actions:
assert toolbar.components.exists(action_name), f"Action {action_name} not found"
def test_load_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file, temp_pdf_file_2):
"""Test loading a PDF file into the viewer."""
widget = pdf_viewer_widget
# Load the temporary PDF file
widget.load_pdf(temp_pdf_file)
qtbot.wait(100) # Wait for loading
# Check that the document is loaded
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
assert widget._pdf_document.pageCount() > 0
assert widget._current_file_path == temp_pdf_file
# Load a second PDF file to test reloading
widget.load_pdf(temp_pdf_file_2)
qtbot.wait(100) # Wait for loading
# Check that the new document is loaded
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
assert widget._pdf_document.pageCount() > 0
assert widget._current_file_path == temp_pdf_file_2
assert widget.current_file_path == temp_pdf_file_2
widget.current_file_path = temp_pdf_file
qtbot.wait(100) # Wait for loading
assert widget.current_file_path == temp_pdf_file
def test_load_invalid_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, tmpdir):
"""Test loading an invalid PDF file into the viewer."""
widget = pdf_viewer_widget
# Try to open a non-existent file
invalid_pdf_file = tmpdir.join("non_existent.pdf")
# Attempt to load the invalid PDF file
with pytest.raises(FileNotFoundError):
widget.load_pdf(str(invalid_pdf_file), _override_slot_params={"raise_error": True})
def test_page_navigation(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test page navigation functionality."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Check initial page
assert widget.current_page == 1
total_pages = widget._pdf_document.pageCount()
assert total_pages >= 1
# Navigate to next page
widget.next_page()
qtbot.wait(300)
assert widget.current_page == 2
# Navigate to previous page
widget.previous_page()
qtbot.wait(300)
assert widget.current_page == 1
# Jump to last page
widget.jump_to_page(total_pages)
qtbot.wait(300)
assert widget.current_page == total_pages
widget.jump_to_page(1)
qtbot.wait(300)
assert widget.current_page == 1
widget.jump_to_page(2)
qtbot.wait(300)
assert widget.current_page == 2
widget.go_to_last_page()
qtbot.wait(300)
assert widget.current_page == total_pages
widget.go_to_first_page()
qtbot.wait(300)
assert widget.current_page == 1
widget.page_input.setText(str(total_pages + 10))
widget.page_input.returnPressed.emit()
qtbot.wait(100)
assert widget.current_page == total_pages
def test_zoom_controls(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test zoom in, zoom out, fit width, fit page, and reset zoom functionality."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Initial zoom mode should be FitToWidth
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Zoom in
initial_zoom = widget.pdf_view.zoomFactor()
widget.zoom_in()
qtbot.wait(100)
assert widget.pdf_view.zoomFactor() > initial_zoom
# Zoom out
zoom_after_in = widget.pdf_view.zoomFactor()
widget.zoom_out()
qtbot.wait(100)
assert widget.pdf_view.zoomFactor() < zoom_after_in
# Fit to page
widget.fit_to_page()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitInView
# Fit to width
widget.fit_to_width()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Reset zoom
widget.reset_zoom()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.Custom
def test_page_spacing_and_margins(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test setting page spacing and side margins."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Set and verify page spacing
widget.page_spacing = 20
assert widget.page_spacing == 20
# Set and verify side margins
widget.side_margins = 30
assert widget.side_margins == 30
def test_toggle_continuous_scroll(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test toggling continuous scroll mode."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Initial mode should be single page
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
# Toggle to continuous scroll
widget.toggle_continuous_scroll(True)
qtbot.wait(100)
assert widget.pdf_view.pageMode() == QPdfView.PageMode.MultiPage
# Toggle back to single page
widget.toggle_continuous_scroll(False)
qtbot.wait(100)
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
widget.jump_to_page(2)
qtbot.wait(100)
assert widget.current_page == 2