diff --git a/app/dialogs/crop_dialog.py b/app/dialogs/crop_dialog.py index bdaa897..6349b0a 100644 --- a/app/dialogs/crop_dialog.py +++ b/app/dialogs/crop_dialog.py @@ -1,14 +1,15 @@ -"""Dialogue Recadrer les pages.""" +"""Dialogue Recadrer les pages — avec selection visuelle.""" from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QDoubleSpinBox, QComboBox, QRadioButton, - QDialogButtonBox, QLineEdit) + QDialogButtonBox, QLineEdit, QPushButton) +from PyQt6.QtCore import Qt from app.engine.pdf_utils import mm_to_pt, pt_to_mm class CropDialog(QDialog): def __init__(self, page_width_pt=595, page_height_pt=842, total_pages=1, - current_page=0, parent=None): + current_page=0, viewer=None, parent=None): super().__init__(parent) self.setWindowTitle("Recadrer") self.setMinimumWidth(450) @@ -16,19 +17,40 @@ class CropDialog(QDialog): self._page_h = page_height_pt self._total_pages = total_pages self._current_page = current_page + self._viewer = viewer self._setup_ui() self._update_preview() def _setup_ui(self): layout = QVBoxLayout(self) - # Info page actuelle + # Info page pw_mm = pt_to_mm(self._page_w) ph_mm = pt_to_mm(self._page_h) info = QLabel(f"Page actuelle : {pw_mm:.1f} x {ph_mm:.1f} mm") info.setStyleSheet("color: #888; font-size: 11px;") layout.addWidget(info) + # Bouton selection visuelle + if self._viewer: + sel_layout = QHBoxLayout() + 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; } + """) + self.btn_select.toggled.connect(self._toggle_select_mode) + sel_layout.addWidget(self.btn_select) + sel_layout.addStretch() + layout.addLayout(sel_layout) + + # Connecter les signaux du viewer + self._viewer.selection_changed.connect(self._on_selection_changed) + # Mode grp_mode = QGroupBox("Mode de recadrage") ml = QVBoxLayout(grp_mode) @@ -38,14 +60,14 @@ class CropDialog(QDialog): ml.addWidget(self.rb_margins) ml.addWidget(self.rb_size) layout.addWidget(grp_mode) - self.rb_margins.toggled.connect(self._on_mode_changed) self.rb_size.toggled.connect(self._on_mode_changed) - # Marges de recadrage + # Marges self.grp_margins_input = QGroupBox("Marges a retirer (mm)") mgl = QVBoxLayout(self.grp_margins_input) self.margin_spins = {} + row1 = QHBoxLayout() row1.addStretch() row1.addWidget(QLabel("Haut :")) @@ -53,6 +75,7 @@ class CropDialog(QDialog): self.margin_spins["haut"].setRange(0, 500) self.margin_spins["haut"].setSuffix(" mm") self.margin_spins["haut"].valueChanged.connect(self._update_preview) + self.margin_spins["haut"].valueChanged.connect(self._show_selection_from_margins) row1.addWidget(self.margin_spins["haut"]) row1.addStretch() mgl.addLayout(row1) @@ -63,6 +86,7 @@ class CropDialog(QDialog): self.margin_spins["gauche"].setRange(0, 500) self.margin_spins["gauche"].setSuffix(" mm") self.margin_spins["gauche"].valueChanged.connect(self._update_preview) + self.margin_spins["gauche"].valueChanged.connect(self._show_selection_from_margins) row2.addWidget(self.margin_spins["gauche"]) row2.addStretch() row2.addWidget(QLabel("Droite :")) @@ -70,6 +94,7 @@ class CropDialog(QDialog): self.margin_spins["droite"].setRange(0, 500) self.margin_spins["droite"].setSuffix(" mm") self.margin_spins["droite"].valueChanged.connect(self._update_preview) + self.margin_spins["droite"].valueChanged.connect(self._show_selection_from_margins) row2.addWidget(self.margin_spins["droite"]) mgl.addLayout(row2) @@ -80,6 +105,7 @@ class CropDialog(QDialog): self.margin_spins["bas"].setRange(0, 500) self.margin_spins["bas"].setSuffix(" mm") self.margin_spins["bas"].valueChanged.connect(self._update_preview) + self.margin_spins["bas"].valueChanged.connect(self._show_selection_from_margins) row3.addWidget(self.margin_spins["bas"]) row3.addStretch() mgl.addLayout(row3) @@ -105,7 +131,7 @@ class CropDialog(QDialog): layout.addWidget(self.grp_size_input) self.grp_size_input.setVisible(False) - # Apercu dimension finale + # Apercu self.lbl_preview = QLabel() self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: #0066cc; padding: 8px;") layout.addWidget(self.lbl_preview) @@ -123,7 +149,6 @@ class CropDialog(QDialog): al.addWidget(self.rb_all) al.addWidget(self.rb_even) al.addWidget(self.rb_odd) - range_layout = QHBoxLayout() range_layout.addWidget(self.rb_range) self.edit_range = QLineEdit() @@ -132,7 +157,6 @@ class CropDialog(QDialog): range_layout.addWidget(self.edit_range) al.addLayout(range_layout) layout.addWidget(grp_apply) - self.rb_range.toggled.connect(self.edit_range.setEnabled) # Boutons @@ -143,6 +167,54 @@ class CropDialog(QDialog): buttons.rejected.connect(self.reject) layout.addWidget(buttons) + def _toggle_select_mode(self, checked): + if self._viewer: + self._viewer.set_select_mode(checked) + if checked: + self.btn_select.setText("Cliquez et tracez sur l'apercu...") + else: + self.btn_select.setText("Selectionner sur l'apercu") + + 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(): + return + pw_mm = pt_to_mm(self._page_w) + ph_mm = pt_to_mm(self._page_h) + + # Les ratios representent la zone SELECTIONNEE (a garder) + # Les marges sont ce qu'on ENLEVE + margin_left = left * pw_mm + margin_right = (1 - right) * pw_mm + margin_top = top * ph_mm + margin_bottom = (1 - bottom) * ph_mm + + # Mettre a jour sans declencher les signaux en boucle + for spin in self.margin_spins.values(): + spin.blockSignals(True) + self.margin_spins["gauche"].setValue(margin_left) + self.margin_spins["droite"].setValue(margin_right) + self.margin_spins["haut"].setValue(margin_top) + self.margin_spins["bas"].setValue(margin_bottom) + for spin in self.margin_spins.values(): + spin.blockSignals(False) + + self._update_preview() + + def _show_selection_from_margins(self): + """Afficher le rectangle de selection sur le viewer a partir des marges.""" + if not self._viewer or not self.rb_margins.isChecked(): + return + pw_mm = pt_to_mm(self._page_w) + ph_mm = pt_to_mm(self._page_h) + + left = self.margin_spins["gauche"].value() / pw_mm if pw_mm > 0 else 0 + top = self.margin_spins["haut"].value() / ph_mm if ph_mm > 0 else 0 + right = 1 - (self.margin_spins["droite"].value() / pw_mm) if pw_mm > 0 else 1 + bottom = 1 - (self.margin_spins["bas"].value() / ph_mm) if ph_mm > 0 else 1 + + self._viewer.show_selection(left, top, right, bottom) + def _on_mode_changed(self): self.grp_margins_input.setVisible(self.rb_margins.isChecked()) self.grp_size_input.setVisible(self.rb_size.isChecked()) @@ -168,7 +240,6 @@ class CropDialog(QDialog): self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: #0066cc; padding: 8px;") def _parse_range(self): - """Parser la plage de pages. Retourne une liste d'indices (0-based).""" text = self.edit_range.text().strip() if not text: return [] @@ -192,16 +263,30 @@ class CropDialog(QDialog): pass return sorted(set(indices)) + def closeEvent(self, event): + """Nettoyer le mode selection a la fermeture.""" + if self._viewer: + self._viewer.set_select_mode(False) + self._viewer.clear_selection() + super().closeEvent(event) + + def reject(self): + if self._viewer: + self._viewer.set_select_mode(False) + self._viewer.clear_selection() + super().reject() + def get_settings(self): - # Mode + if self._viewer: + self._viewer.set_select_mode(False) + self._viewer.clear_selection() + if self.rb_margins.isChecked(): - mode = "margins" crop_top = mm_to_pt(self.margin_spins["haut"].value()) crop_bottom = mm_to_pt(self.margin_spins["bas"].value()) crop_left = mm_to_pt(self.margin_spins["gauche"].value()) crop_right = mm_to_pt(self.margin_spins["droite"].value()) else: - mode = "size" final_w = mm_to_pt(self.spin_final_w.value()) final_h = mm_to_pt(self.spin_final_h.value()) crop_left = (self._page_w - final_w) / 2 @@ -209,7 +294,6 @@ class CropDialog(QDialog): crop_top = (self._page_h - final_h) / 2 crop_bottom = crop_top - # Pages cibles if self.rb_current.isChecked(): pages = [self._current_page] elif self.rb_all.isChecked(): diff --git a/app/main_window.py b/app/main_window.py index b9ab56a..361f93f 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -610,6 +610,7 @@ class MainWindow(QMainWindow): page_width_pt=w, page_height_pt=h, total_pages=len(reader.pages), current_page=self._viewer.current_page, + viewer=self._viewer, parent=self ) if dlg.exec(): diff --git a/app/pdf_viewer.py b/app/pdf_viewer.py index f32cfb4..62c2e2a 100644 --- a/app/pdf_viewer.py +++ b/app/pdf_viewer.py @@ -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 diff --git a/app/updater.py b/app/updater.py index eb29f38..4ad2819 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.3.0" +VERSION = "2.3.1" def get_remote_version(): diff --git a/version.txt b/version.txt index 276cbf9..2bf1c1c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.3.0 +2.3.1