V2.5.2 — Selection redimensionnable + fix crop + bouton Effacer
- Rectangle de selection redimensionnable par les 8 poignees (angles + cotes) - Curseurs adaptes selon la poignee survolee - Deplacement du rectangle entier en cliquant a l'interieur - Zone sombre en dehors, cadre cyan, poignees blanches - Bouton Effacer dans le dialogue recadrage - Fix remontee des valeurs de selection dans les spinbox Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,147 +1,241 @@
|
||||
"""Widget de visualisation PDF avec pypdfium2 — V2.3.1 avec selection."""
|
||||
"""Widget de visualisation PDF avec pypdfium2 — V2.5 avec selection redimensionnable."""
|
||||
|
||||
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
|
||||
QMouseEvent, QCursor, QBrush)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint, QSize
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
HANDLE_SIZE = 8 # taille des poignees de redimensionnement
|
||||
|
||||
|
||||
class SelectableImageLabel(QLabel):
|
||||
"""QLabel avec possibilite de dessiner un rectangle de selection."""
|
||||
"""QLabel avec rectangle de selection redimensionnable par les angles."""
|
||||
|
||||
selection_changed = pyqtSignal(float, float, float, float) # left, top, right, bottom en ratio (0-1)
|
||||
selection_changed = pyqtSignal(float, float, float, float)
|
||||
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
|
||||
self._rect = QRect()
|
||||
self._drag_mode = None # None, "create", "move", "tl", "tr", "bl", "br", "t", "b", "l", "r"
|
||||
self._drag_start = QPoint()
|
||||
self._drag_rect_start = QRect()
|
||||
|
||||
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()
|
||||
if enabled:
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
else:
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
def clear_selection(self):
|
||||
self._has_selection = False
|
||||
self._rect = QRect()
|
||||
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)
|
||||
|
||||
off_x, off_y = self._img_offset()
|
||||
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._rect = QRect(QPoint(x1, y1), QPoint(x2, y2)).normalized()
|
||||
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 _img_offset(self):
|
||||
if not self.pixmap():
|
||||
return 0, 0
|
||||
pm = self.pixmap()
|
||||
return max(0, (self.width() - pm.width()) // 2), max(0, (self.height() - pm.height()) // 2)
|
||||
|
||||
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 _handle_rects(self):
|
||||
"""Retourne les rectangles des 8 poignees."""
|
||||
if not self._has_selection:
|
||||
return {}
|
||||
r = self._rect.normalized()
|
||||
hs = HANDLE_SIZE
|
||||
return {
|
||||
"tl": QRect(r.left() - hs, r.top() - hs, hs * 2, hs * 2),
|
||||
"tr": QRect(r.right() - hs, r.top() - hs, hs * 2, hs * 2),
|
||||
"bl": QRect(r.left() - hs, r.bottom() - hs, hs * 2, hs * 2),
|
||||
"br": QRect(r.right() - hs, r.bottom() - hs, hs * 2, hs * 2),
|
||||
"t": QRect(r.center().x() - hs, r.top() - hs, hs * 2, hs * 2),
|
||||
"b": QRect(r.center().x() - hs, r.bottom() - hs, hs * 2, hs * 2),
|
||||
"l": QRect(r.left() - hs, r.center().y() - hs, hs * 2, hs * 2),
|
||||
"r": QRect(r.right() - hs, r.center().y() - hs, hs * 2, hs * 2),
|
||||
}
|
||||
|
||||
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()
|
||||
def _cursor_for_handle(self, handle):
|
||||
cursors = {
|
||||
"tl": Qt.CursorShape.SizeFDiagCursor,
|
||||
"br": Qt.CursorShape.SizeFDiagCursor,
|
||||
"tr": Qt.CursorShape.SizeBDiagCursor,
|
||||
"bl": Qt.CursorShape.SizeBDiagCursor,
|
||||
"t": Qt.CursorShape.SizeVerCursor,
|
||||
"b": Qt.CursorShape.SizeVerCursor,
|
||||
"l": Qt.CursorShape.SizeHorCursor,
|
||||
"r": Qt.CursorShape.SizeHorCursor,
|
||||
}
|
||||
return cursors.get(handle, Qt.CursorShape.CrossCursor)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if not self._select_mode or event.button() != Qt.MouseButton.LeftButton:
|
||||
return super().mousePressEvent(event)
|
||||
|
||||
pos = event.pos()
|
||||
self._drag_start = pos
|
||||
self._drag_rect_start = QRect(self._rect)
|
||||
|
||||
# Verifier si on clique sur une poignee
|
||||
if self._has_selection:
|
||||
for name, hr in self._handle_rects().items():
|
||||
if hr.contains(pos):
|
||||
self._drag_mode = name
|
||||
return
|
||||
# Clic dans le rectangle = deplacer
|
||||
if self._rect.normalized().contains(pos):
|
||||
self._drag_mode = "move"
|
||||
return
|
||||
|
||||
# Nouveau rectangle
|
||||
self._drag_mode = "create"
|
||||
self._rect = QRect(pos, pos)
|
||||
self._has_selection = True
|
||||
self.update()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
pos = event.pos()
|
||||
|
||||
if not self._select_mode:
|
||||
return super().mouseMoveEvent(event)
|
||||
|
||||
# Hover : changer le curseur sur les poignees
|
||||
if not self._drag_mode and self._has_selection:
|
||||
for name, hr in self._handle_rects().items():
|
||||
if hr.contains(pos):
|
||||
self.setCursor(self._cursor_for_handle(name))
|
||||
return
|
||||
if self._rect.normalized().contains(pos):
|
||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
||||
return
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
return
|
||||
|
||||
if not self._drag_mode:
|
||||
return
|
||||
|
||||
dx = pos.x() - self._drag_start.x()
|
||||
dy = pos.y() - self._drag_start.y()
|
||||
r = QRect(self._drag_rect_start)
|
||||
|
||||
if self._drag_mode == "create":
|
||||
self._rect = QRect(self._drag_start, pos).normalized()
|
||||
elif self._drag_mode == "move":
|
||||
self._rect = r.translated(dx, dy)
|
||||
elif self._drag_mode == "tl":
|
||||
self._rect = QRect(QPoint(r.left() + dx, r.top() + dy), r.bottomRight()).normalized()
|
||||
elif self._drag_mode == "tr":
|
||||
self._rect = QRect(QPoint(r.left(), r.top() + dy), QPoint(r.right() + dx, r.bottom())).normalized()
|
||||
elif self._drag_mode == "bl":
|
||||
self._rect = QRect(QPoint(r.left() + dx, r.top()), QPoint(r.right(), r.bottom() + dy)).normalized()
|
||||
elif self._drag_mode == "br":
|
||||
self._rect = QRect(r.topLeft(), QPoint(r.right() + dx, r.bottom() + dy)).normalized()
|
||||
elif self._drag_mode == "t":
|
||||
self._rect = QRect(QPoint(r.left(), r.top() + dy), r.bottomRight()).normalized()
|
||||
elif self._drag_mode == "b":
|
||||
self._rect = QRect(r.topLeft(), QPoint(r.right(), r.bottom() + dy)).normalized()
|
||||
elif self._drag_mode == "l":
|
||||
self._rect = QRect(QPoint(r.left() + dx, r.top()), r.bottomRight()).normalized()
|
||||
elif self._drag_mode == "r":
|
||||
self._rect = QRect(r.topLeft(), QPoint(r.right() + dx, r.bottom())).normalized()
|
||||
|
||||
self.update()
|
||||
self._emit_ratios()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._drag_mode:
|
||||
self._drag_mode = None
|
||||
self._emit_ratios()
|
||||
self.selection_done.emit()
|
||||
self.update()
|
||||
else:
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def _emit_ratios(self):
|
||||
if not self.pixmap():
|
||||
if not self.pixmap() or not self._has_selection:
|
||||
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)
|
||||
if img_w <= 0 or img_h <= 0:
|
||||
return
|
||||
off_x, off_y = self._img_offset()
|
||||
r = self._rect.normalized()
|
||||
|
||||
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))
|
||||
left = max(0, min(1, (r.left() - off_x) / img_w))
|
||||
top = max(0, min(1, (r.top() - off_y) / img_h))
|
||||
right = max(0, min(1, (r.right() - off_x) / img_w))
|
||||
bottom = max(0, min(1, (r.bottom() - off_y) / img_h))
|
||||
|
||||
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())
|
||||
if not self._has_selection or self._rect.isNull():
|
||||
return
|
||||
|
||||
# Cadre de selection
|
||||
pen = QPen(QColor(0, 120, 255), 2, Qt.PenStyle.DashLine)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(r)
|
||||
painter = QPainter(self)
|
||||
r = self._rect.normalized()
|
||||
full = self.rect()
|
||||
|
||||
# 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()
|
||||
# Zone sombre en dehors
|
||||
painter.setBrush(QColor(0, 0, 0, 80))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRect(full.left(), full.top(), full.width(), r.top() - full.top())
|
||||
painter.drawRect(full.left(), r.bottom(), full.width(), full.bottom() - r.bottom())
|
||||
painter.drawRect(full.left(), r.top(), r.left() - full.left(), r.height())
|
||||
painter.drawRect(r.right(), r.top(), full.right() - r.right(), r.height())
|
||||
|
||||
# Cadre
|
||||
pen = QPen(QColor(0, 180, 216), 2, Qt.PenStyle.SolidLine)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(r)
|
||||
|
||||
# Poignees
|
||||
painter.setBrush(QColor(255, 255, 255))
|
||||
painter.setPen(QPen(QColor(0, 180, 216), 1))
|
||||
for hr in self._handle_rects().values():
|
||||
painter.drawRect(hr)
|
||||
|
||||
# Dimensions
|
||||
if self.pixmap():
|
||||
pm = self.pixmap()
|
||||
img_w, img_h = pm.width(), pm.height()
|
||||
off_x, off_y = self._img_offset()
|
||||
if img_w > 0 and img_h > 0:
|
||||
w_pct = r.width() / img_w * 100
|
||||
h_pct = r.height() / img_h * 100
|
||||
painter.setPen(QColor(0, 180, 216))
|
||||
painter.drawText(r.left() + 4, r.top() - 6,
|
||||
f"{w_pct:.0f}% x {h_pct:.0f}%")
|
||||
|
||||
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_changed = pyqtSignal(float, float, float, float)
|
||||
selection_done = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -185,7 +279,7 @@ class PdfViewer(QWidget):
|
||||
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):
|
||||
def _on_wheel(self, event):
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
delta = event.angleDelta().y()
|
||||
if delta > 0:
|
||||
@@ -194,10 +288,9 @@ class PdfViewer(QWidget):
|
||||
self._zoom = max(self._zoom / 1.15, 0.2)
|
||||
self._render_page()
|
||||
else:
|
||||
delta = event.angleDelta().y()
|
||||
if delta < 0:
|
||||
if event.angleDelta().y() < 0:
|
||||
self.next_page()
|
||||
elif delta > 0:
|
||||
elif event.angleDelta().y() > 0:
|
||||
self.prev_page()
|
||||
|
||||
def load_pdf(self, path):
|
||||
@@ -246,9 +339,7 @@ class PdfViewer(QWidget):
|
||||
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)
|
||||
self._zoom = min(vw / pw, vh / ph) * (72 / 150)
|
||||
|
||||
def prev_page(self):
|
||||
if self._current_page > 0:
|
||||
|
||||
Reference in New Issue
Block a user