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:
Jules
2026-03-21 14:59:06 +01:00
parent e94158aae8
commit c6d3ddcf9d
4 changed files with 209 additions and 99 deletions

View File

@@ -39,14 +39,24 @@ class CropDialog(QDialog):
self.btn_select = QPushButton("Selectionner sur l'apercu")
self.btn_select.setCheckable(True)
self.btn_select.setStyleSheet("""
QPushButton { background: #0066cc; color: white; padding: 8px 16px;
border-radius: 4px; font-weight: bold; }
QPushButton:checked { background: #cc3300; }
QPushButton:hover { background: #0055aa; }
QPushButton:checked:hover { background: #aa2200; }
QPushButton { background: #00b4d8; color: white; padding: 6px 14px;
border-radius: 6px; font-weight: bold; font-size: 11px; }
QPushButton:checked { background: #e74c3c; }
QPushButton:hover { background: #0096b7; }
QPushButton:checked:hover { background: #c0392b; }
""")
self.btn_select.toggled.connect(self._toggle_select_mode)
sel_layout.addWidget(self.btn_select)
self.btn_clear = QPushButton("Effacer")
self.btn_clear.setStyleSheet("""
QPushButton { background: #e8e8e8; color: #555; padding: 6px 14px;
border-radius: 6px; font-size: 11px; border: 1px solid #ccc; }
QPushButton:hover { background: #ddd; }
""")
self.btn_clear.clicked.connect(self._clear_selection)
sel_layout.addWidget(self.btn_clear)
sel_layout.addStretch()
layout.addLayout(sel_layout)
@@ -173,10 +183,19 @@ class CropDialog(QDialog):
if self._viewer:
self._viewer.set_select_mode(checked)
if checked:
self.btn_select.setText("Cliquez et tracez sur l'apercu...")
self.btn_select.setText("Mode selection actif")
else:
self.btn_select.setText("Selectionner sur l'apercu")
def _clear_selection(self):
if self._viewer:
self._viewer.clear_selection()
for spin in self.margin_spins.values():
spin.blockSignals(True)
spin.setValue(0)
spin.blockSignals(False)
self._update_preview()
def _on_selection_changed(self, left, top, right, bottom):
"""Recoit les ratios de selection du viewer et met a jour les marges."""
if not self.rb_margins.isChecked():

View File

@@ -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:

View File

@@ -14,7 +14,7 @@ REPO = "jules/copydev-imposing-tool"
BRANCH = "master"
# Version actuelle (incrementee a chaque release)
VERSION = "2.5.1"
VERSION = "2.5.2"
def get_remote_version():

View File

@@ -1 +1 @@
2.5.1
2.5.2