"""Widget de visualisation PDF avec pypdfium2 — V3 avec selection + regles.""" from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QScrollArea, QSizePolicy, QApplication) from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor, QMouseEvent, QCursor, QBrush, QFont) from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint, QSize import pypdfium2 as pdfium HANDLE_SIZE = 8 # taille des poignees de redimensionnement RULER_SIZE = 24 # largeur/hauteur des regles en pixels 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 RulerWidget(QWidget): """Regle graduee en mm (horizontale ou verticale).""" def __init__(self, orientation=Qt.Orientation.Horizontal, parent=None): super().__init__(parent) self._orientation = orientation self._page_size_pt = 0 # taille de la page en points self._zoom = 1.0 self._offset = 0 # decalage du scroll self._img_offset = 0 # offset de l'image dans le label if orientation == Qt.Orientation.Horizontal: self.setFixedHeight(RULER_SIZE) else: self.setFixedWidth(RULER_SIZE) def update_params(self, page_size_pt, zoom, offset, img_offset): self._page_size_pt = page_size_pt self._zoom = zoom self._offset = offset self._img_offset = img_offset self.update() def paintEvent(self, event): if self._page_size_pt <= 0: return painter = QPainter(self) painter.fillRect(self.rect(), QColor(245, 245, 245)) pen = QPen(QColor(120, 120, 120), 1) painter.setPen(pen) font = QFont("Segoe UI", 7) painter.setFont(font) # pixels par mm px_per_pt = 150 / 72 * self._zoom px_per_mm = px_per_pt * (72 / 25.4) page_size_mm = self._page_size_pt * 25.4 / 72 # Position de depart de la page dans le widget page_start = self._img_offset - self._offset horiz = self._orientation == Qt.Orientation.Horizontal # Determiner le pas des graduations if px_per_mm >= 4: step_mm = 5 sub_step_mm = 1 elif px_per_mm >= 2: step_mm = 10 sub_step_mm = 5 else: step_mm = 20 sub_step_mm = 10 # Dessiner les graduations mm_val = 0 while mm_val <= page_size_mm: px = page_start + mm_val * px_per_mm if horiz: if px < 0 or px > self.width(): mm_val += sub_step_mm continue else: if px < 0 or px > self.height(): mm_val += sub_step_mm continue is_major = (mm_val % step_mm == 0) tick_len = RULER_SIZE * 0.6 if is_major else RULER_SIZE * 0.3 if horiz: x = int(px) painter.drawLine(x, RULER_SIZE - int(tick_len), x, RULER_SIZE) if is_major and mm_val > 0: painter.drawText(x + 2, RULER_SIZE - int(tick_len) - 1, str(int(mm_val))) else: y = int(px) painter.drawLine(RULER_SIZE - int(tick_len), y, RULER_SIZE, y) if is_major and mm_val > 0: painter.save() painter.translate(RULER_SIZE - int(tick_len) - 2, y + 2) painter.rotate(-90) painter.drawText(0, 0, str(int(mm_val))) painter.restore() mm_val += sub_step_mm # Ligne de bord if horiz: painter.drawLine(0, RULER_SIZE - 1, self.width(), RULER_SIZE - 1) else: painter.drawLine(RULER_SIZE - 1, 0, RULER_SIZE - 1, self.height()) painter.end() class PdfViewer(QWidget): """Widget d'apercu PDF avec zoom molette, navigation, selection et regles.""" 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._rulers_visible = False self._bleed_info = None # dict avec top/bottom/left/right en pt self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Regle horizontale self._h_ruler = RulerWidget(Qt.Orientation.Horizontal) self._h_ruler.setVisible(False) # Zone centrale : regle verticale + scroll center_layout = QHBoxLayout() center_layout.setContentsMargins(0, 0, 0, 0) center_layout.setSpacing(0) self._v_ruler = RulerWidget(Qt.Orientation.Vertical) self._v_ruler.setVisible(False) 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 self._scroll.horizontalScrollBar().valueChanged.connect(self._update_rulers) self._scroll.verticalScrollBar().valueChanged.connect(self._update_rulers) center_layout.addWidget(self._v_ruler) center_layout.addWidget(self._scroll, 1) # Coin superieur gauche (vide) quand regles actives h_ruler_layout = QHBoxLayout() h_ruler_layout.setContentsMargins(0, 0, 0, 0) h_ruler_layout.setSpacing(0) self._ruler_corner = QWidget() self._ruler_corner.setFixedSize(RULER_SIZE, RULER_SIZE) self._ruler_corner.setStyleSheet("background: #f5f5f5;") self._ruler_corner.setVisible(False) h_ruler_layout.addWidget(self._ruler_corner) h_ruler_layout.addWidget(self._h_ruler, 1) layout.addLayout(h_ruler_layout) layout.addLayout(center_layout) def set_rulers_visible(self, visible): self._rulers_visible = visible self._h_ruler.setVisible(visible) self._v_ruler.setVisible(visible) self._ruler_corner.setVisible(visible) if visible: self._update_rulers() def rulers_visible(self): return self._rulers_visible def set_bleed_info(self, bleed_info): """Definir les infos de bleed pour l'overlay rouge. None pour desactiver.""" self._bleed_info = bleed_info self._render_page() 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 _update_rulers(self): if not self._rulers_visible or not self._pdf: return # Calculer l'offset de l'image dans le scroll area pm = self._image_label.pixmap() if not pm: return img_w, img_h = pm.width(), pm.height() label_w = self._image_label.width() label_h = self._image_label.height() img_off_x = max(0, (label_w - img_w) // 2) img_off_y = max(0, (label_h - img_h) // 2) scroll_x = self._scroll.horizontalScrollBar().value() scroll_y = self._scroll.verticalScrollBar().value() self._h_ruler.update_params(self._page_width_pt, self._zoom, scroll_x, img_off_x) self._v_ruler.update_params(self._page_height_pt, self._zoom, scroll_y, img_off_y) 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) # Overlay bleed si actif if self._bleed_info: from PyQt6.QtGui import QPainter as _P px_per_pt = scale bt = self._bleed_info.get("top", 0) * px_per_pt bb = self._bleed_info.get("bottom", 0) * px_per_pt bl = self._bleed_info.get("left", 0) * px_per_pt br = self._bleed_info.get("right", 0) * px_per_pt img_w = qimage.width() img_h = qimage.height() painter = _P(qimage) painter.setBrush(QColor(255, 0, 0, 50)) painter.setPen(QPen(QColor(255, 0, 0, 120), 1, Qt.PenStyle.DashLine)) # Haut if bt > 0: painter.drawRect(0, 0, img_w, int(bt)) # Bas if bb > 0: painter.drawRect(0, img_h - int(bb), img_w, int(bb)) # Gauche if bl > 0: painter.drawRect(0, int(bt), int(bl), img_h - int(bt) - int(bb)) # Droite if br > 0: painter.drawRect(img_w - int(br), int(bt), int(br), img_h - int(bt) - int(bb)) painter.end() pixmap = QPixmap.fromImage(qimage) self._image_label.setPixmap(pixmap) self._image_label.adjustSize() self.page_changed.emit(self._current_page, self._total_pages) if self._rulers_visible: self._update_rulers() 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