"""Écran éditeur — recadrage, goodies, effets, multi-pose, texte.""" import os import tempfile from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, QGraphicsTextItem, QFileDialog, QInputDialog ) from PyQt6.QtCore import Qt, QRectF from PyQt6.QtGui import ( QFont, QPixmap, QImage, QPainter, QColor, QTransform ) from ui.widgets.crop_overlay import CropOverlay from ui.widgets.goodies_panel import GoodiesPanel from ui.widgets.multipose_dialog import MultiposeDialog, TEMPLATES class EditorScreen(QWidget): def __init__(self, main_window): super().__init__() self.main_window = main_window self.current_image_path = None self.pixmap_item = None self.crop_overlay = None self.is_cropping = False self.multipose_zones = None self.multipose_images = {} self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) layout.setSpacing(5) layout.setContentsMargins(5, 5, 5, 5) # Header header = QHBoxLayout() back_btn = QPushButton("Retour") back_btn.setFont(QFont("", 14)) back_btn.clicked.connect(self._go_back) header.addWidget(back_btn) title = QLabel("Éditeur") title.setFont(QFont("", 24, QFont.Weight.Bold)) title.setAlignment(Qt.AlignmentFlag.AlignCenter) header.addWidget(title, 1) print_btn = QPushButton("Imprimer") print_btn.setFont(QFont("", 14)) print_btn.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; border-radius: 10px; padding: 10px 20px; } QPushButton:pressed { background-color: #388E3C; } """) print_btn.clicked.connect(self._go_print) header.addWidget(print_btn) layout.addLayout(header) # Zone centrale : goodies panel + canvas center = QHBoxLayout() # Panneau goodies (caché par défaut) self.goodies_panel = GoodiesPanel() self.goodies_panel.sticker_selected.connect(self._place_sticker) self.goodies_panel.frame_selected.connect(self._apply_frame) self.goodies_panel.hide() center.addWidget(self.goodies_panel) # Canvas self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.RenderHint.Antialiasing) self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) self.view.setStyleSheet("background-color: #333; border: none;") self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) center.addWidget(self.view, 1) layout.addLayout(center, 1) # Barre d'outils tools = QHBoxLayout() tools.setSpacing(8) tool_buttons = [ ("Recadrer", self._toggle_crop), ("Rotation", self._rotate), ("N&B", self._filter_grayscale), ("Sépia", self._filter_sepia), ("Vintage", self._filter_vintage), ("Contraste", self._filter_contrast), ("Goodies", self._toggle_goodies), ("Multi-pose", self._multipose), ("Texte", self._add_text), ("Annuler", self._reset_image), ] for label, callback in tool_buttons: btn = QPushButton(label) btn.setFont(QFont("", 12)) btn.setMinimumHeight(50) btn.setStyleSheet(""" QPushButton { background-color: #555; color: white; border-radius: 8px; padding: 8px 12px; } QPushButton:pressed { background-color: #777; } """) btn.clicked.connect(callback) tools.addWidget(btn) layout.addLayout(tools) def load_image(self, path): self.current_image_path = path self.multipose_zones = None self.multipose_images = {} self._cancel_crop() self.scene.clear() pixmap = QPixmap(path) self.pixmap_item = self.scene.addPixmap(pixmap) self.scene.setSceneRect(QRectF(pixmap.rect())) self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) # Auto-afficher le cadre de recadrage si ratio ≠ 3:2 w, h = pixmap.width(), pixmap.height() if w > 0 and h > 0: ratio = max(w, h) / min(w, h) if abs(ratio - 1.5) > 0.075: self._show_ratio_crop() def resizeEvent(self, event): super().resizeEvent(event) if self.pixmap_item: self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) # --- Export --- def _export_scene(self): """Exporte la scène complète en QImage haute résolution.""" rect = self.scene.sceneRect() image = QImage(int(rect.width()), int(rect.height()), QImage.Format.Format_ARGB32) image.fill(QColor(255, 255, 255)) painter = QPainter(image) painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) self.scene.render(painter) painter.end() return image def _export_to_file(self): """Exporte la scène dans un fichier temporaire et retourne le chemin.""" image = self._export_scene() path = os.path.join(tempfile.gettempdir(), "photostation_export.png") image.save(path, "PNG") return path # --- Cadre ratio suggéré --- def _show_ratio_crop(self): """Affiche un cadre de recadrage au ratio 3:2 en suggestion.""" if not self.pixmap_item: return self.is_cropping = True self.view.setDragMode(QGraphicsView.DragMode.NoDrag) pr = self.pixmap_item.boundingRect() img_w, img_h = pr.width(), pr.height() ratio = 1.5 # Adapter au sens de l'image if img_w < img_h: ratio = 1.0 / ratio # Plus grande zone possible if img_w / img_h > ratio: crop_h = img_h * 0.95 crop_w = crop_h * ratio else: crop_w = img_w * 0.95 crop_h = crop_w / ratio x = (img_w - crop_w) / 2 y = (img_h - crop_h) / 2 crop_rect = QRectF(x, y, crop_w, crop_h) self.crop_overlay = CropOverlay(crop_rect) self.crop_overlay.set_bounds(pr) self.scene.addItem(self.crop_overlay) # --- Navigation --- def _go_back(self): self._cancel_crop() self.main_window.show_import() def _go_print(self): path = self._export_to_file() self.main_window.show_print(path) # --- Recadrage --- def _toggle_crop(self): if self.is_cropping: self._apply_crop() else: self._start_crop() def _start_crop(self): if not self.pixmap_item: return self.is_cropping = True self.view.setDragMode(QGraphicsView.DragMode.NoDrag) pr = self.pixmap_item.boundingRect() # Crop initial = 80% centré margin_x = pr.width() * 0.1 margin_y = pr.height() * 0.1 crop_rect = QRectF(margin_x, margin_y, pr.width() - 2 * margin_x, pr.height() - 2 * margin_y) self.crop_overlay = CropOverlay(crop_rect) self.crop_overlay.set_bounds(pr) self.scene.addItem(self.crop_overlay) def _apply_crop(self): if not self.crop_overlay or not self.pixmap_item: return crop_rect = self.crop_overlay.get_crop_rect() pixmap = self.pixmap_item.pixmap() cropped = pixmap.copy(crop_rect.toRect()) self.scene.removeItem(self.crop_overlay) self.crop_overlay = None self.is_cropping = False self.scene.clear() self.pixmap_item = self.scene.addPixmap(cropped) self.scene.setSceneRect(QRectF(cropped.rect())) self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) def _cancel_crop(self): if self.crop_overlay: self.scene.removeItem(self.crop_overlay) self.crop_overlay = None self.is_cropping = False # --- Rotation --- def _rotate(self): if not self.pixmap_item: return pixmap = self.pixmap_item.pixmap() transform = QTransform().rotate(90) rotated = pixmap.transformed(transform, Qt.TransformationMode.SmoothTransformation) self.scene.clear() self.pixmap_item = self.scene.addPixmap(rotated) self.scene.setSceneRect(QRectF(rotated.rect())) self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) # --- Filtres --- def _apply_pil_filter(self, pil_func): """Applique un filtre Pillow sur l'image actuelle du canvas.""" if not self.pixmap_item: return from PIL import Image # QPixmap -> PIL pixmap = self.pixmap_item.pixmap() qimg = pixmap.toImage().convertToFormat(QImage.Format.Format_RGBA8888) width, height = qimg.width(), qimg.height() ptr = qimg.bits() ptr.setsize(width * height * 4) pil_img = Image.frombytes("RGBA", (width, height), bytes(ptr)) # Appliquer le filtre result = pil_func(pil_img) # PIL -> QPixmap data = result.convert("RGBA").tobytes("raw", "RGBA") new_qimg = QImage(data, result.width, result.height, QImage.Format.Format_RGBA8888) new_pixmap = QPixmap.fromImage(new_qimg) self.scene.clear() self.pixmap_item = self.scene.addPixmap(new_pixmap) self.scene.setSceneRect(QRectF(new_pixmap.rect())) self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) def _filter_grayscale(self): from PIL import ImageOps self._apply_pil_filter(lambda img: ImageOps.grayscale(img).convert("RGBA")) def _filter_sepia(self): from PIL import ImageOps def sepia(img): gray = ImageOps.grayscale(img) return ImageOps.colorize(gray, "#704214", "#C0A080").convert("RGBA") self._apply_pil_filter(sepia) def _filter_vintage(self): from PIL import ImageOps, ImageEnhance def vintage(img): rgb = img.convert("RGB") rgb = ImageEnhance.Color(rgb).enhance(0.6) rgb = ImageEnhance.Contrast(rgb).enhance(1.2) rgb = ImageEnhance.Brightness(rgb).enhance(0.9) return rgb.convert("RGBA") self._apply_pil_filter(vintage) def _filter_contrast(self): from PIL import ImageEnhance def contrast(img): rgb = img.convert("RGB") rgb = ImageEnhance.Contrast(rgb).enhance(1.5) return rgb.convert("RGBA") self._apply_pil_filter(contrast) # --- Goodies --- def _toggle_goodies(self): self.goodies_panel.setVisible(not self.goodies_panel.isVisible()) def _place_sticker(self, path): """Ajoute un sticker draggable sur le canvas.""" pixmap = QPixmap(path) if pixmap.isNull(): return # Taille sticker = 20% de la largeur de la scène scene_w = self.scene.sceneRect().width() if scene_w > 0: target_w = int(scene_w * 0.2) pixmap = pixmap.scaledToWidth(target_w, Qt.TransformationMode.SmoothTransformation) item = self.scene.addPixmap(pixmap) item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsMovable, True) item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsSelectable, True) # Centrer sr = self.scene.sceneRect() item.setPos(sr.center().x() - pixmap.width() / 2, sr.center().y() - pixmap.height() / 2) def _apply_frame(self, path): """Applique un cadre par-dessus l'image (en overlay).""" if not self.pixmap_item: return frame_pixmap = QPixmap(path) if frame_pixmap.isNull(): return sr = self.scene.sceneRect() scaled = frame_pixmap.scaled( int(sr.width()), int(sr.height()), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation ) frame_item = self.scene.addPixmap(scaled) frame_item.setPos(sr.topLeft()) frame_item.setZValue(100) # --- Multi-pose --- def _multipose(self): name, zones = MultiposeDialog.get_template(self) if not zones: return # Utilise la photo en cours (ou l'image éditée du canvas) source_pixmap = None if self.pixmap_item: source_pixmap = self.pixmap_item.pixmap() elif self.current_image_path: source_pixmap = QPixmap(self.current_image_path) if not source_pixmap or source_pixmap.isNull(): return self.multipose_zones = zones self.multipose_source = source_pixmap # Taille canvas = format d'impression (ex: 1800x1200 pour 15x10) canvas_w = 1800 canvas_h = 1200 self._render_multipose(source_pixmap, zones, canvas_w, canvas_h) def _crop_to_fill(self, pixmap, target_w, target_h): """Crop centré pour remplir exactement target_w x target_h.""" src_w = pixmap.width() src_h = pixmap.height() src_ratio = src_w / src_h target_ratio = target_w / target_h if src_ratio > target_ratio: # Image trop large → crop horizontal new_w = int(src_h * target_ratio) x_offset = (src_w - new_w) // 2 cropped = pixmap.copy(x_offset, 0, new_w, src_h) else: # Image trop haute → crop vertical new_h = int(src_w / target_ratio) y_offset = (src_h - new_h) // 2 cropped = pixmap.copy(0, y_offset, src_w, new_h) return cropped.scaled(target_w, target_h, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) def _render_multipose(self, source_pixmap, zones, canvas_w, canvas_h): """Dessine le multi-pose : même image dupliquée, crop to fill.""" self.scene.clear() self.pixmap_item = None # Canvas blanc white = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32) white.fill(QColor(255, 255, 255)) self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(white)) self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h)) gap = 6 # espace entre les zones for i, (zx, zy, zw, zh) in enumerate(zones): rx = int(zx * canvas_w) + gap ry = int(zy * canvas_h) + gap rw = int(zw * canvas_w) - gap * 2 rh = int(zh * canvas_h) - gap * 2 if rw <= 0 or rh <= 0: continue # Crop to fill : l'image remplit toute la zone filled = self._crop_to_fill(source_pixmap, rw, rh) item = self.scene.addPixmap(filled) item.setPos(rx, ry) self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) # --- Texte --- def _add_text(self): text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :") if not ok or not text: return item = QGraphicsTextItem(text) item.setFont(QFont("", 32)) item.setDefaultTextColor(QColor(255, 255, 255)) item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsMovable, True) item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True) item.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) sr = self.scene.sceneRect() item.setPos(sr.center().x() - 100, sr.center().y() - 20) self.scene.addItem(item) # --- Reset --- def _reset_image(self): if self.current_image_path: self.load_image(self.current_image_path)