V2.3.1 — Recadrage avec selection visuelle

- Bouton "Selectionner sur l'apercu" dans le dialogue Recadrer
- Tracer un rectangle a la souris directement sur le PDF
- Zone sombre en dehors de la selection, cadre bleu en pointilles
- Les marges se mettent a jour automatiquement depuis la selection
- Les marges saisies manuellement affichent le cadre sur l'apercu
- Synchronisation bidirectionnelle selection <-> valeurs
- Nettoyage auto du mode selection a la fermeture du dialogue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jules
2026-03-21 14:19:44 +01:00
parent e7a27018e5
commit 1f8f6bb89d
5 changed files with 263 additions and 35 deletions

View File

@@ -1,16 +1,148 @@
"""Widget de visualisation PDF avec pypdfium2 — V2."""
"""Widget de visualisation PDF avec pypdfium2 — V2.3.1 avec selection."""
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel,
QScrollArea, QSizePolicy, QApplication)
from PyQt6.QtGui import QImage, QPixmap, QWheelEvent
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
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 PdfViewer(QWidget):
"""Widget d'apercu PDF avec zoom molette et navigation fluide."""
class SelectableImageLabel(QLabel):
"""QLabel avec possibilite de dessiner un rectangle de selection."""
page_changed = pyqtSignal(int, int) # page_num (0-based), total_pages
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)
@@ -19,6 +151,8 @@ class PdfViewer(QWidget):
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):
@@ -31,19 +165,27 @@ class PdfViewer(QWidget):
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._scroll.setStyleSheet("QScrollArea { background: #1e1e1e; border: none; }")
self._image_label = QLabel()
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: #1e1e1e;")
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)
# Intercepter la molette sur le scroll area
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):
"""Molette = pages, Ctrl+Molette = zoom."""
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
@@ -59,7 +201,6 @@ class PdfViewer(QWidget):
self.prev_page()
def load_pdf(self, path):
"""Charger un fichier PDF."""
try:
self._pdf_path = path
self._pdf = pdfium.PdfDocument(path)
@@ -71,7 +212,6 @@ class PdfViewer(QWidget):
self._image_label.setText(f"Erreur: {e}")
def load_pdf_bytes(self, data):
"""Charger un PDF depuis des bytes."""
try:
self._pdf = pdfium.PdfDocument(data)
self._total_pages = len(self._pdf)
@@ -82,25 +222,21 @@ class PdfViewer(QWidget):
self._image_label.setText(f"Erreur: {e}")
def _render_page(self):
"""Afficher la page courante."""
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):
"""Ajuster le zoom pour que la page tienne dans la zone visible."""
if not self._pdf or self._current_page >= self._total_pages:
return
page = self._pdf[self._current_page]
@@ -145,7 +281,6 @@ class PdfViewer(QWidget):
self._render_page()
def get_page_thumbnail(self, page_index, width=120):
"""Generer une miniature pour le panneau de vignettes."""
if not self._pdf or page_index >= self._total_pages:
return None
page = self._pdf[page_index]
@@ -172,3 +307,11 @@ class PdfViewer(QWidget):
@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