- Bouton "Selectionner sur l'apercu" dans le dialogue Recadrer - Tracer un rectangle a la souris directement sur le PDF - Zone sombre en dehors de la selection, cadre bleu en pointilles - Les marges se mettent a jour automatiquement depuis la selection - Les marges saisies manuellement affichent le cadre sur l'apercu - Synchronisation bidirectionnelle selection <-> valeurs - Nettoyage auto du mode selection a la fermeture du dialogue Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Widget de visualisation PDF avec pypdfium2 — V2.3.1 avec selection."""
|
|
|
|
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
|
|
import pypdfium2 as pdfium
|
|
|
|
|
|
class SelectableImageLabel(QLabel):
|
|
"""QLabel avec possibilite de dessiner un rectangle de selection."""
|
|
|
|
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)
|
|
self._pdf = None
|
|
self._pdf_path = None
|
|
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):
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
self._scroll = QScrollArea()
|
|
self._scroll.setWidgetResizable(True)
|
|
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self._scroll.setStyleSheet("QScrollArea { background: #1e1e1e; border: none; }")
|
|
|
|
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)
|
|
|
|
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):
|
|
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
delta = event.angleDelta().y()
|
|
if delta > 0:
|
|
self._zoom = min(self._zoom * 1.15, 5.0)
|
|
else:
|
|
self._zoom = max(self._zoom / 1.15, 0.2)
|
|
self._render_page()
|
|
else:
|
|
delta = event.angleDelta().y()
|
|
if delta < 0:
|
|
self.next_page()
|
|
elif delta > 0:
|
|
self.prev_page()
|
|
|
|
def load_pdf(self, path):
|
|
try:
|
|
self._pdf_path = path
|
|
self._pdf = pdfium.PdfDocument(path)
|
|
self._total_pages = len(self._pdf)
|
|
self._current_page = 0
|
|
self._zoom_fit()
|
|
self._render_page()
|
|
except Exception as e:
|
|
self._image_label.setText(f"Erreur: {e}")
|
|
|
|
def load_pdf_bytes(self, data):
|
|
try:
|
|
self._pdf = pdfium.PdfDocument(data)
|
|
self._total_pages = len(self._pdf)
|
|
self._current_page = 0
|
|
self._zoom_fit()
|
|
self._render_page()
|
|
except Exception as e:
|
|
self._image_label.setText(f"Erreur: {e}")
|
|
|
|
def _render_page(self):
|
|
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):
|
|
if not self._pdf or self._current_page >= self._total_pages:
|
|
return
|
|
page = self._pdf[self._current_page]
|
|
pw, ph = page.get_size()
|
|
vw = self._scroll.viewport().width() - 20
|
|
vh = self._scroll.viewport().height() - 20
|
|
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)
|
|
|
|
def prev_page(self):
|
|
if self._current_page > 0:
|
|
self._current_page -= 1
|
|
self._render_page()
|
|
|
|
def next_page(self):
|
|
if self._current_page < self._total_pages - 1:
|
|
self._current_page += 1
|
|
self._render_page()
|
|
|
|
def go_to_page(self, page_index):
|
|
if 0 <= page_index < self._total_pages and page_index != self._current_page:
|
|
self._current_page = page_index
|
|
self._render_page()
|
|
|
|
def zoom_in(self):
|
|
self._zoom = min(self._zoom * 1.25, 5.0)
|
|
self._render_page()
|
|
|
|
def zoom_out(self):
|
|
self._zoom = max(self._zoom / 1.25, 0.2)
|
|
self._render_page()
|
|
|
|
def zoom_fit(self):
|
|
self._zoom_fit()
|
|
self._render_page()
|
|
|
|
def refresh(self):
|
|
self._render_page()
|
|
|
|
def get_page_thumbnail(self, page_index, width=120):
|
|
if not self._pdf or page_index >= self._total_pages:
|
|
return None
|
|
page = self._pdf[page_index]
|
|
pw, ph = page.get_size()
|
|
scale = width / pw
|
|
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)
|
|
return QPixmap.fromImage(qimage)
|
|
|
|
@property
|
|
def current_page(self):
|
|
return self._current_page
|
|
|
|
@property
|
|
def total_pages(self):
|
|
return self._total_pages
|
|
|
|
@property
|
|
def zoom_level(self):
|
|
return self._zoom
|
|
|
|
@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
|