Files
copydev-imposing-tool/app/pdf_viewer.py
Jules c6d3ddcf9d 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>
2026-03-21 14:59:06 +01:00

409 lines
15 KiB
Python

"""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, 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 rectangle de selection redimensionnable par les angles."""
selection_changed = pyqtSignal(float, float, float, float)
selection_done = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._select_mode = False
self._has_selection = False
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
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):
if not self.pixmap():
return
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
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)).normalized()
self._has_selection = True
self.update()
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 _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.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() or not self._has_selection:
return
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
if img_w <= 0 or img_h <= 0:
return
off_x, off_y = self._img_offset()
r = self._rect.normalized()
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 not self._has_selection or self._rect.isNull():
return
painter = QPainter(self)
r = self._rect.normalized()
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(), 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)
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: #d0d0d0; 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: #d0d0d0;")
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):
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:
if event.angleDelta().y() < 0:
self.next_page()
elif event.angleDelta().y() > 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
self._zoom = min(vw / pw, vh / ph) * (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