From c6d3ddcf9da75716bd470b9309266b7d883d2f34 Mon Sep 17 00:00:00 2001 From: Jules Date: Sat, 21 Mar 2026 14:59:06 +0100 Subject: [PATCH] =?UTF-8?q?V2.5.2=20=E2=80=94=20Selection=20redimensionnab?= =?UTF-8?q?le=20+=20fix=20crop=20+=20bouton=20Effacer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/dialogs/crop_dialog.py | 31 ++++- app/pdf_viewer.py | 273 ++++++++++++++++++++++++------------- app/updater.py | 2 +- version.txt | 2 +- 4 files changed, 209 insertions(+), 99 deletions(-) diff --git a/app/dialogs/crop_dialog.py b/app/dialogs/crop_dialog.py index 8f77488..239a6d1 100644 --- a/app/dialogs/crop_dialog.py +++ b/app/dialogs/crop_dialog.py @@ -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(): diff --git a/app/pdf_viewer.py b/app/pdf_viewer.py index fabc979..a6d2d0e 100644 --- a/app/pdf_viewer.py +++ b/app/pdf_viewer.py @@ -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: diff --git a/app/updater.py b/app/updater.py index d0f62d8..958f498 100644 --- a/app/updater.py +++ b/app/updater.py @@ -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(): diff --git a/version.txt b/version.txt index 73462a5..f225a78 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.5.1 +2.5.2