Files
copydev-imposing-tool/app/pdf_viewer.py
Jules 98049cd3cb V2.4.0 — Theme moderne Catppuccin + recadrage non-modal
- Theme sombre Catppuccin Mocha sur toute l'interface :
  - Menus, toolbars, dialogues, spinbox, combo, boutons
  - Scrollbars custom, bordures arrondies, hover states
  - Panneau vignettes repensé
  - Sections outils avec titre bleu
- Recadrage : dialogue non-modal (Qt.Tool) pour pouvoir
  interagir avec le viewer pendant la selection
- Fond viewer plus sombre (#11111b)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:30:17 +01:00

318 lines
11 KiB
Python

"""Widget de visualisation PDF avec pypdfium2 — V2.3.1 avec selection."""
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel,
QScrollArea, QSizePolicy, QApplication)
from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor,
QMouseEvent, QCursor)
from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint
import pypdfium2 as pdfium
class SelectableImageLabel(QLabel):
"""QLabel avec possibilite de dessiner un rectangle de selection."""
selection_changed = pyqtSignal(float, float, float, float) # left, top, right, bottom en ratio (0-1)
selection_done = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._selecting = False
self._select_mode = False
self._start = QPoint()
self._end = QPoint()
self._rect = QRect()
self._has_selection = False
self._img_rect = QRect() # zone de l'image dans le label
def set_select_mode(self, enabled):
self._select_mode = enabled
self.setCursor(QCursor(Qt.CursorShape.CrossCursor if enabled else Qt.CursorShape.ArrowCursor))
if not enabled:
self._has_selection = False
self.update()
def clear_selection(self):
self._has_selection = False
self.update()
def set_selection_from_margins(self, left_ratio, top_ratio, right_ratio, bottom_ratio):
"""Afficher un rectangle de selection a partir de ratios (0-1)."""
if not self.pixmap():
return
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
# Calculer la position de l'image dans le label (centree)
lbl_w, lbl_h = self.width(), self.height()
off_x = max(0, (lbl_w - img_w) // 2)
off_y = max(0, (lbl_h - img_h) // 2)
x1 = off_x + int(left_ratio * img_w)
y1 = off_y + int(top_ratio * img_h)
x2 = off_x + int(right_ratio * img_w)
y2 = off_y + int(bottom_ratio * img_h)
self._rect = QRect(QPoint(x1, y1), QPoint(x2, y2))
self._has_selection = True
self.update()
def mousePressEvent(self, event: QMouseEvent):
if self._select_mode and event.button() == Qt.MouseButton.LeftButton:
self._selecting = True
self._start = event.pos()
self._end = event.pos()
self._rect = QRect(self._start, self._end).normalized()
self._has_selection = True
self.update()
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent):
if self._selecting:
self._end = event.pos()
self._rect = QRect(self._start, self._end).normalized()
self.update()
self._emit_ratios()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent):
if self._selecting:
self._selecting = False
self._end = event.pos()
self._rect = QRect(self._start, self._end).normalized()
self.update()
self._emit_ratios()
self.selection_done.emit()
else:
super().mouseReleaseEvent(event)
def _emit_ratios(self):
if not self.pixmap():
return
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
lbl_w, lbl_h = self.width(), self.height()
off_x = max(0, (lbl_w - img_w) // 2)
off_y = max(0, (lbl_h - img_h) // 2)
left = max(0, (self._rect.left() - off_x)) / img_w
top = max(0, (self._rect.top() - off_y)) / img_h
right = min(1, (self._rect.right() - off_x)) / img_w
bottom = min(1, (self._rect.bottom() - off_y)) / img_h
left = max(0, min(1, left))
top = max(0, min(1, top))
right = max(0, min(1, right))
bottom = max(0, min(1, bottom))
self.selection_changed.emit(left, top, right, bottom)
def paintEvent(self, event):
super().paintEvent(event)
if self._has_selection and not self._rect.isNull():
painter = QPainter(self)
# Zone sombre en dehors de la selection
painter.setBrush(QColor(0, 0, 0, 100))
painter.setPen(Qt.PenStyle.NoPen)
full = self.rect()
r = self._rect.normalized()
# Haut
painter.drawRect(full.left(), full.top(), full.width(), r.top() - full.top())
# Bas
painter.drawRect(full.left(), r.bottom(), full.width(), full.bottom() - r.bottom())
# Gauche
painter.drawRect(full.left(), r.top(), r.left() - full.left(), r.height())
# Droite
painter.drawRect(r.right(), r.top(), full.right() - r.right(), r.height())
# Cadre de selection
pen = QPen(QColor(0, 120, 255), 2, Qt.PenStyle.DashLine)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(r)
# Dimensions en mm (affichees dans le coin)
painter.setPen(QColor(0, 120, 255))
painter.drawText(r.left() + 4, r.top() - 4,
f"{r.width()}x{r.height()} px")
painter.end()
class PdfViewer(QWidget):
"""Widget d'apercu PDF avec zoom molette, navigation et selection."""
page_changed = pyqtSignal(int, int)
selection_changed = pyqtSignal(float, float, float, float) # ratios
selection_done = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._pdf = None
self._pdf_path = None
self._current_page = 0
self._total_pages = 0
self._zoom = 1.0
self._page_width_pt = 0
self._page_height_pt = 0
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._scroll.setStyleSheet("QScrollArea { background: #11111b; border: none; }")
self._image_label = SelectableImageLabel()
self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._image_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
self._image_label.setStyleSheet("background: #11111b;")
self._image_label.selection_changed.connect(self.selection_changed.emit)
self._image_label.selection_done.connect(self.selection_done.emit)
self._scroll.setWidget(self._image_label)
self._scroll.wheelEvent = self._on_wheel
layout.addWidget(self._scroll, 1)
def set_select_mode(self, enabled):
self._image_label.set_select_mode(enabled)
def clear_selection(self):
self._image_label.clear_selection()
def show_selection(self, left_ratio, top_ratio, right_ratio, bottom_ratio):
self._image_label.set_selection_from_margins(left_ratio, top_ratio, right_ratio, bottom_ratio)
def _on_wheel(self, event: QWheelEvent):
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
self._zoom = min(self._zoom * 1.15, 5.0)
else:
self._zoom = max(self._zoom / 1.15, 0.2)
self._render_page()
else:
delta = event.angleDelta().y()
if delta < 0:
self.next_page()
elif delta > 0:
self.prev_page()
def load_pdf(self, path):
try:
self._pdf_path = path
self._pdf = pdfium.PdfDocument(path)
self._total_pages = len(self._pdf)
self._current_page = 0
self._zoom_fit()
self._render_page()
except Exception as e:
self._image_label.setText(f"Erreur: {e}")
def load_pdf_bytes(self, data):
try:
self._pdf = pdfium.PdfDocument(data)
self._total_pages = len(self._pdf)
self._current_page = 0
self._zoom_fit()
self._render_page()
except Exception as e:
self._image_label.setText(f"Erreur: {e}")
def _render_page(self):
if not self._pdf or self._current_page >= self._total_pages:
return
page = self._pdf[self._current_page]
self._page_width_pt, self._page_height_pt = page.get_size()
scale = 150 / 72 * self._zoom
bitmap = page.render(scale=scale)
pil_image = bitmap.to_pil()
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
pixmap = QPixmap.fromImage(qimage)
self._image_label.setPixmap(pixmap)
self._image_label.adjustSize()
self.page_changed.emit(self._current_page, self._total_pages)
def _zoom_fit(self):
if not self._pdf or self._current_page >= self._total_pages:
return
page = self._pdf[self._current_page]
pw, ph = page.get_size()
vw = self._scroll.viewport().width() - 20
vh = self._scroll.viewport().height() - 20
if vw <= 0 or vh <= 0:
self._zoom = 1.0
return
scale_x = vw / pw
scale_y = vh / ph
self._zoom = min(scale_x, scale_y) * (72 / 150)
def prev_page(self):
if self._current_page > 0:
self._current_page -= 1
self._render_page()
def next_page(self):
if self._current_page < self._total_pages - 1:
self._current_page += 1
self._render_page()
def go_to_page(self, page_index):
if 0 <= page_index < self._total_pages and page_index != self._current_page:
self._current_page = page_index
self._render_page()
def zoom_in(self):
self._zoom = min(self._zoom * 1.25, 5.0)
self._render_page()
def zoom_out(self):
self._zoom = max(self._zoom / 1.25, 0.2)
self._render_page()
def zoom_fit(self):
self._zoom_fit()
self._render_page()
def refresh(self):
self._render_page()
def get_page_thumbnail(self, page_index, width=120):
if not self._pdf or page_index >= self._total_pages:
return None
page = self._pdf[page_index]
pw, ph = page.get_size()
scale = width / pw
bitmap = page.render(scale=scale)
pil_image = bitmap.to_pil()
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
return QPixmap.fromImage(qimage)
@property
def current_page(self):
return self._current_page
@property
def total_pages(self):
return self._total_pages
@property
def zoom_level(self):
return self._zoom
@property
def pdf_path(self):
return self._pdf_path
@property
def page_width_pt(self):
return self._page_width_pt
@property
def page_height_pt(self):
return self._page_height_pt