"""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: #11111b; 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: #11111b;") 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