"""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