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