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 = QPushButton("Selectionner sur l'apercu")
self.btn_select.setCheckable(True) self.btn_select.setCheckable(True)
self.btn_select.setStyleSheet(""" self.btn_select.setStyleSheet("""
QPushButton { background: #0066cc; color: white; padding: 8px 16px; QPushButton { background: #00b4d8; color: white; padding: 6px 14px;
border-radius: 4px; font-weight: bold; } border-radius: 6px; font-weight: bold; font-size: 11px; }
QPushButton:checked { background: #cc3300; } QPushButton:checked { background: #e74c3c; }
QPushButton:hover { background: #0055aa; } QPushButton:hover { background: #0096b7; }
QPushButton:checked:hover { background: #aa2200; } QPushButton:checked:hover { background: #c0392b; }
""") """)
self.btn_select.toggled.connect(self._toggle_select_mode) self.btn_select.toggled.connect(self._toggle_select_mode)
sel_layout.addWidget(self.btn_select) 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() sel_layout.addStretch()
layout.addLayout(sel_layout) layout.addLayout(sel_layout)
@@ -173,10 +183,19 @@ class CropDialog(QDialog):
if self._viewer: if self._viewer:
self._viewer.set_select_mode(checked) self._viewer.set_select_mode(checked)
if checked: if checked:
self.btn_select.setText("Cliquez et tracez sur l'apercu...") self.btn_select.setText("Mode selection actif")
else: else:
self.btn_select.setText("Selectionner sur l'apercu") 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): def _on_selection_changed(self, left, top, right, bottom):
"""Recoit les ratios de selection du viewer et met a jour les marges.""" """Recoit les ratios de selection du viewer et met a jour les marges."""
if not self.rb_margins.isChecked(): if not self.rb_margins.isChecked():

View File

@@ -1,139 +1,233 @@
"""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, from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel,
QScrollArea, QSizePolicy, QApplication) QScrollArea, QSizePolicy, QApplication)
from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor, from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor,
QMouseEvent, QCursor) QMouseEvent, QCursor, QBrush)
from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint, QSize
import pypdfium2 as pdfium import pypdfium2 as pdfium
HANDLE_SIZE = 8 # taille des poignees de redimensionnement
class SelectableImageLabel(QLabel): 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() selection_done = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._selecting = False
self._select_mode = False self._select_mode = False
self._start = QPoint()
self._end = QPoint()
self._rect = QRect()
self._has_selection = False 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): def set_select_mode(self, enabled):
self._select_mode = enabled self._select_mode = enabled
self.setCursor(QCursor(Qt.CursorShape.CrossCursor if enabled else Qt.CursorShape.ArrowCursor)) if enabled:
if not enabled: self.setCursor(Qt.CursorShape.CrossCursor)
self._has_selection = False else:
self.update() self.setCursor(Qt.CursorShape.ArrowCursor)
def clear_selection(self): def clear_selection(self):
self._has_selection = False self._has_selection = False
self._rect = QRect()
self.update() self.update()
def set_selection_from_margins(self, left_ratio, top_ratio, right_ratio, bottom_ratio): 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(): if not self.pixmap():
return return
pm = self.pixmap() pm = self.pixmap()
img_w, img_h = pm.width(), pm.height() img_w, img_h = pm.width(), pm.height()
# Calculer la position de l'image dans le label (centree) off_x, off_y = self._img_offset()
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) x1 = off_x + int(left_ratio * img_w)
y1 = off_y + int(top_ratio * img_h) y1 = off_y + int(top_ratio * img_h)
x2 = off_x + int(right_ratio * img_w) x2 = off_x + int(right_ratio * img_w)
y2 = off_y + int(bottom_ratio * img_h) 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._has_selection = True
self.update() self.update()
def mousePressEvent(self, event: QMouseEvent): def _img_offset(self):
if self._select_mode and event.button() == Qt.MouseButton.LeftButton: if not self.pixmap():
self._selecting = True return 0, 0
self._start = event.pos() pm = self.pixmap()
self._end = event.pos() return max(0, (self.width() - pm.width()) // 2), max(0, (self.height() - pm.height()) // 2)
self._rect = QRect(self._start, self._end).normalized()
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 _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._has_selection = True
self.update() self.update()
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent): def mouseMoveEvent(self, event):
if self._selecting: pos = event.pos()
self._end = event.pos()
self._rect = QRect(self._start, self._end).normalized() 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.update()
self._emit_ratios() self._emit_ratios()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent): def mouseReleaseEvent(self, event):
if self._selecting: if self._drag_mode:
self._selecting = False self._drag_mode = None
self._end = event.pos()
self._rect = QRect(self._start, self._end).normalized()
self.update()
self._emit_ratios() self._emit_ratios()
self.selection_done.emit() self.selection_done.emit()
self.update()
else: else:
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)
def _emit_ratios(self): def _emit_ratios(self):
if not self.pixmap(): if not self.pixmap() or not self._has_selection:
return return
pm = self.pixmap() pm = self.pixmap()
img_w, img_h = pm.width(), pm.height() img_w, img_h = pm.width(), pm.height()
lbl_w, lbl_h = self.width(), self.height() if img_w <= 0 or img_h <= 0:
off_x = max(0, (lbl_w - img_w) // 2) return
off_y = max(0, (lbl_h - img_h) // 2) off_x, off_y = self._img_offset()
r = self._rect.normalized()
left = max(0, (self._rect.left() - off_x)) / img_w left = max(0, min(1, (r.left() - off_x) / img_w))
top = max(0, (self._rect.top() - off_y)) / img_h top = max(0, min(1, (r.top() - off_y) / img_h))
right = min(1, (self._rect.right() - off_x)) / img_w right = max(0, min(1, (r.right() - off_x) / img_w))
bottom = min(1, (self._rect.bottom() - off_y)) / img_h bottom = max(0, min(1, (r.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) self.selection_changed.emit(left, top, right, bottom)
def paintEvent(self, event): def paintEvent(self, event):
super().paintEvent(event) super().paintEvent(event)
if self._has_selection and not self._rect.isNull(): if not self._has_selection or self._rect.isNull():
return
painter = QPainter(self) 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() r = self._rect.normalized()
# Haut full = self.rect()
# 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(), full.top(), full.width(), r.top() - full.top())
# Bas
painter.drawRect(full.left(), r.bottom(), full.width(), full.bottom() - r.bottom()) 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()) 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()) painter.drawRect(r.right(), r.top(), full.right() - r.right(), r.height())
# Cadre de selection # Cadre
pen = QPen(QColor(0, 120, 255), 2, Qt.PenStyle.DashLine) pen = QPen(QColor(0, 180, 216), 2, Qt.PenStyle.SolidLine)
painter.setPen(pen) painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(r) painter.drawRect(r)
# Dimensions en mm (affichees dans le coin) # Poignees
painter.setPen(QColor(0, 120, 255)) painter.setBrush(QColor(255, 255, 255))
painter.drawText(r.left() + 4, r.top() - 4, painter.setPen(QPen(QColor(0, 180, 216), 1))
f"{r.width()}x{r.height()} px") 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() painter.end()
@@ -141,7 +235,7 @@ class PdfViewer(QWidget):
"""Widget d'apercu PDF avec zoom molette, navigation et selection.""" """Widget d'apercu PDF avec zoom molette, navigation et selection."""
page_changed = pyqtSignal(int, int) page_changed = pyqtSignal(int, int)
selection_changed = pyqtSignal(float, float, float, float) # ratios selection_changed = pyqtSignal(float, float, float, float)
selection_done = pyqtSignal() selection_done = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -185,7 +279,7 @@ class PdfViewer(QWidget):
def show_selection(self, left_ratio, top_ratio, right_ratio, bottom_ratio): 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) 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: if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y() delta = event.angleDelta().y()
if delta > 0: if delta > 0:
@@ -194,10 +288,9 @@ class PdfViewer(QWidget):
self._zoom = max(self._zoom / 1.15, 0.2) self._zoom = max(self._zoom / 1.15, 0.2)
self._render_page() self._render_page()
else: else:
delta = event.angleDelta().y() if event.angleDelta().y() < 0:
if delta < 0:
self.next_page() self.next_page()
elif delta > 0: elif event.angleDelta().y() > 0:
self.prev_page() self.prev_page()
def load_pdf(self, path): def load_pdf(self, path):
@@ -246,9 +339,7 @@ class PdfViewer(QWidget):
if vw <= 0 or vh <= 0: if vw <= 0 or vh <= 0:
self._zoom = 1.0 self._zoom = 1.0
return return
scale_x = vw / pw self._zoom = min(vw / pw, vh / ph) * (72 / 150)
scale_y = vh / ph
self._zoom = min(scale_x, scale_y) * (72 / 150)
def prev_page(self): def prev_page(self):
if self._current_page > 0: if self._current_page > 0:

View File

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

View File

@@ -1 +1 @@
2.5.1 2.5.2