Formats impression configurables + recadrage au ratio

Config admin :
- Chaque format (10x15, 13x18, 15x20) activable avec checkbox
- Imprimante CUPS assignable par format

Écran impression :
- Boutons de format (seuls les activés apparaissent)
- Cadre de recadrage au ratio du format (3:2, 18:13, 4:3)
- Auto-détection portrait/paysage
- Cadre déplaçable avec poignées tactiles
- Galerie → Valider → écran impression avec print_list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 23:22:16 +01:00
parent 9a643eadfc
commit 371ae4a1f8
3 changed files with 263 additions and 83 deletions

View File

@@ -297,7 +297,7 @@ class ImportScreen(QWidget):
self.summary_label.setStyleSheet("color: #999;") self.summary_label.setStyleSheet("color: #999;")
def _validate(self): def _validate(self):
"""Envoie les photos sélectionnées à l'impression.""" """Envoie les photos sélectionnées à l'écran d'impression."""
to_print = [] to_print = []
for card in self.cards: for card in self.cards:
if card.quantity > 0: if card.quantity > 0:
@@ -306,9 +306,8 @@ class ImportScreen(QWidget):
if not to_print: if not to_print:
return return
# TODO: envoyer à l'écran d'impression avec les quantités self.main_window.print_screen.set_print_list(to_print)
# Pour l'instant, on envoie la première photo self.main_window.show_print()
self.main_window.show_print(to_print[0][0])
def get_print_list(self): def get_print_list(self):
"""Retourne la liste [(path, quantity), ...] pour l'impression.""" """Retourne la liste [(path, quantity), ...] pour l'impression."""

View File

@@ -1,90 +1,259 @@
"""Écran d'impression — prévisualisation et lancement impression.""" """Écran d'impression — choix du format, recadrage au ratio, impression."""
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QComboBox QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt, QRectF
from PyQt6.QtGui import QFont, QPixmap from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter, QColor
from ui.widgets.config_dialog import load_config
from ui.widgets.crop_overlay import CropOverlay
from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn, blue_btn
# Ratios par format (largeur / hauteur, paysage)
FORMAT_RATIOS = {
"10x15": 1.5, # 15/10
"13x18": 1.385, # 18/13
"15x20": 1.333, # 20/15
}
class PrintScreen(QWidget): class PrintScreen(QWidget):
FORMATS = {
"10x15 cm": (10, 15),
"13x18 cm": (13, 18),
"15x20 cm": (15, 20),
}
def __init__(self, main_window): def __init__(self, main_window):
super().__init__() super().__init__()
self.main_window = main_window self.main_window = main_window
self.image_path = None self.image_path = None
self.pixmap_item = None
self.crop_overlay = None
self.selected_format = None
self.print_list = [] # [(path, quantity), ...]
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(30) layout.setSpacing(8)
# Header # Header
header = QHBoxLayout() header = QHBoxLayout()
back_btn = QPushButton("Retour éditeur") self.back_btn = QPushButton("< Retour")
back_btn.setFont(QFont("", 14)) self.back_btn.clicked.connect(self._go_back)
back_btn.clicked.connect(lambda: self.main_window.show_editor()) header.addWidget(self.back_btn)
header.addWidget(back_btn)
header.addStretch() self.title = QLabel("Choisissez le format et recadrez")
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
header.addWidget(self.title, 1)
header.addSpacing(100)
layout.addLayout(header) layout.addLayout(header)
# Prévisualisation # Boutons de format
self.preview = QLabel() self.format_bar = QHBoxLayout()
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter) self.format_bar.setSpacing(10)
self.preview.setMinimumSize(400, 300) self.format_buttons = {}
self.preview.setStyleSheet("background-color: #333; border-radius: 10px;") layout.addLayout(self.format_bar)
layout.addWidget(self.preview)
# Sélection format # Zone de recadrage (QGraphicsScene)
format_layout = QHBoxLayout() self.scene = QGraphicsScene()
format_label = QLabel("Format :") self.view = QGraphicsView(self.scene)
format_label.setFont(QFont("", 16)) self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
format_layout.addWidget(format_label) 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)
layout.addWidget(self.view, 1)
self.format_combo = QComboBox() # Info recadrage
self.format_combo.setFont(QFont("", 16)) self.info_label = QLabel("Déplacez le cadre pour recadrer votre photo")
self.format_combo.addItems(self.FORMATS.keys()) self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.format_combo.setMinimumHeight(50) self.info_label.setStyleSheet("color: #999;")
format_layout.addWidget(self.format_combo) layout.addWidget(self.info_label)
layout.addLayout(format_layout)
# Bouton imprimer # Bouton imprimer
print_btn = QPushButton("IMPRIMER") self.print_btn = QPushButton("IMPRIMER")
print_btn.setFont(QFont("", 24, QFont.Weight.Bold)) self.print_btn.clicked.connect(self._print)
print_btn.setMinimumSize(300, 80) layout.addWidget(self.print_btn, alignment=Qt.AlignmentFlag.AlignCenter)
print_btn.setStyleSheet("""
QPushButton { def showEvent(self, event):
background-color: #4CAF50; super().showEvent(event)
color: white; from PyQt6.QtCore import QTimer
border-radius: 15px; QTimer.singleShot(0, self._apply_responsive)
}
QPushButton:pressed { background-color: #388E3C; } def resizeEvent(self, event):
""") super().resizeEvent(event)
print_btn.clicked.connect(self._print) self._apply_responsive()
layout.addWidget(print_btn, alignment=Qt.AlignmentFlag.AlignCenter)
def _apply_responsive(self):
self.back_btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT, bold=True))
self.back_btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
self.back_btn.setStyleSheet(back_btn_style(self))
self.title.setFont(scaled_font(self, Sizes.HEADING, bold=True))
self.info_label.setFont(scaled_font(self, Sizes.HINT))
self.print_btn.setFont(scaled_font(self, 4.0, bold=True))
self.print_btn.setMinimumSize(scale(self, 35), scale(self, Sizes.BTN_HEIGHT * 1.5))
self.print_btn.setStyleSheet(green_btn(self))
# Style boutons format
for fmt, btn in self.format_buttons.items():
btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT))
btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
if fmt == self.selected_format:
btn.setStyleSheet(green_btn(self))
else:
btn.setStyleSheet(blue_btn(self))
def set_image(self, path): def set_image(self, path):
"""Charge une image et affiche les formats disponibles."""
self.image_path = path self.image_path = path
pixmap = QPixmap(path) self.print_list = [(path, 1)]
if not pixmap.isNull(): self._load_formats()
scaled = pixmap.scaled( self._load_image()
400, 300,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.preview.setPixmap(scaled)
def _print(self): def set_print_list(self, print_list):
"""Reçoit la liste [(path, qty), ...] depuis la galerie."""
self.print_list = print_list
if print_list:
self.image_path = print_list[0][0]
self._load_formats()
self._load_image()
def _load_formats(self):
"""Charge les formats activés depuis la config."""
# Clear existing buttons
while self.format_bar.count():
item = self.format_bar.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.format_buttons = {}
config = load_config()
formats = config.get("formats", {})
self.selected_format = None
for fmt_name, fmt_conf in formats.items():
if not fmt_conf.get("enabled", False):
continue
btn = QPushButton(f"{fmt_name} cm")
btn.clicked.connect(lambda checked, f=fmt_name: self._select_format(f))
self.format_bar.addWidget(btn)
self.format_buttons[fmt_name] = btn
# Sélectionner le premier par défaut
if self.selected_format is None:
self.selected_format = fmt_name
# Si aucun format activé, activer 10x15 par défaut
if not self.format_buttons:
btn = QPushButton("10x15 cm")
btn.clicked.connect(lambda: self._select_format("10x15"))
self.format_bar.addWidget(btn)
self.format_buttons["10x15"] = btn
self.selected_format = "10x15"
self._apply_responsive()
def _load_image(self):
"""Charge l'image dans la scène et place le cadre de recadrage."""
if not self.image_path: if not self.image_path:
return return
fmt = self.format_combo.currentText() self.scene.clear()
size = self.FORMATS[fmt] self.crop_overlay = None
# TODO: redimensionner selon le format et envoyer à CUPS pixmap = QPixmap(self.image_path)
print(f"Impression {self.image_path} en {fmt} ({size[0]}x{size[1]} cm)") if pixmap.isNull():
return
self.pixmap_item = self.scene.addPixmap(pixmap)
self.scene.setSceneRect(QRectF(pixmap.rect()))
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
self._place_crop_overlay()
def _select_format(self, fmt_name):
"""Change le format sélectionné et met à jour le cadre."""
self.selected_format = fmt_name
self._apply_responsive()
self._place_crop_overlay()
def _place_crop_overlay(self):
"""Place le cadre de recadrage au ratio du format sélectionné."""
if not self.pixmap_item:
return
# Supprimer l'ancien overlay
if self.crop_overlay:
self.scene.removeItem(self.crop_overlay)
self.crop_overlay = None
ratio = FORMAT_RATIOS.get(self.selected_format, 1.5)
img_rect = self.pixmap_item.boundingRect()
img_w = img_rect.width()
img_h = img_rect.height()
# Déterminer orientation : paysage ou portrait selon l'image
img_ratio = img_w / img_h
if img_ratio < 1:
# Image portrait → cadre portrait (inverser le ratio)
ratio = 1.0 / ratio
# Calculer la plus grande zone de crop possible dans l'image
if img_w / img_h > ratio:
# Image plus large que le ratio → hauteur max, largeur ajustée
crop_h = img_h * 0.9
crop_w = crop_h * ratio
else:
# Image plus haute que le ratio → largeur max, hauteur ajustée
crop_w = img_w * 0.9
crop_h = crop_w / ratio
# Centrer le cadre
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(img_rect)
self.scene.addItem(self.crop_overlay)
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
def _go_back(self):
self.main_window.show_import()
def _print(self):
if not self.image_path or not self.selected_format:
return
config = load_config()
formats = config.get("formats", {})
fmt_conf = formats.get(self.selected_format, {})
printer = fmt_conf.get("printer", "")
ratio = FORMAT_RATIOS.get(self.selected_format, 1.5)
# Récupérer le rectangle de crop
if self.crop_overlay:
crop_rect = self.crop_overlay.get_crop_rect().toRect()
else:
crop_rect = None
# Pour chaque photo de la liste
for path, qty in self.print_list:
pixmap = QPixmap(path)
if pixmap.isNull():
continue
# Appliquer le crop si défini (utiliser le même crop relatif)
if crop_rect:
cropped = pixmap.copy(crop_rect)
else:
cropped = pixmap
# TODO: envoyer à CUPS
print(f"[Impression] {path} x{qty} → {self.selected_format} cm"
f" (printer: {printer or 'défaut'},"
f" crop: {crop_rect.width()}x{crop_rect.height() if crop_rect else 'none'})")
# Feedback
total = sum(q for _, q in self.print_list)
self.info_label.setText(f"Impression lancée — {total} photo{'s' if total > 1 else ''}")
self.info_label.setStyleSheet("color: #4CAF50; font-weight: bold;")

View File

@@ -31,9 +31,12 @@ DEFAULT_CONFIG = {
"imap_password": "", "imap_password": "",
"imap_folder": "INBOX", "imap_folder": "INBOX",
"imap_interval": 30, "imap_interval": 30,
# Impression # Formats d'impression (activable + imprimante par format)
"printer_name": "", "formats": {
"print_format": "10x15 cm", "10x15": {"enabled": True, "printer": "", "ratio": 1.5},
"13x18": {"enabled": False, "printer": "", "ratio": 1.385},
"15x20": {"enabled": False, "printer": "", "ratio": 1.333},
},
} }
@@ -211,22 +214,25 @@ class ConfigDialog(QDialog):
grid.addWidget(self.imap_interval_input, row, 1) grid.addWidget(self.imap_interval_input, row, 1)
row += 1 row += 1
# ── Impression ── # ── Formats d'impression ──
grid.addWidget(self._section_title("Impression"), row, 0, 1, 2) grid.addWidget(self._section_title("Formats d'impression"), row, 0, 1, 2)
row += 1 row += 1
grid.addWidget(self._label("Imprimante :"), row, 0) formats = self.config.get("formats", DEFAULT_CONFIG["formats"])
self.printer_input = self._input(self.config["printer_name"], "Nom CUPS (vide = par défaut)") self.format_widgets = {}
grid.addWidget(self.printer_input, row, 1)
row += 1
grid.addWidget(self._label("Format par défaut :"), row, 0) for fmt_name, fmt_conf in formats.items():
self.format_combo = QComboBox() # Checkbox activé
self.format_combo.setFont(QFont("", 12)) cb = QCheckBox(f"{fmt_name} cm")
self.format_combo.setMinimumHeight(35) cb.setFont(QFont("", 12))
self.format_combo.addItems(["10x15 cm", "13x18 cm", "15x20 cm"]) cb.setChecked(fmt_conf.get("enabled", False))
self.format_combo.setCurrentText(self.config["print_format"]) grid.addWidget(cb, row, 0)
grid.addWidget(self.format_combo, row, 1)
# Imprimante associée
printer = self._input(fmt_conf.get("printer", ""), "Imprimante CUPS (vide = défaut)")
grid.addWidget(printer, row, 1)
self.format_widgets[fmt_name] = {"checkbox": cb, "printer": printer}
row += 1 row += 1
scroll.setWidget(content) scroll.setWidget(content)
@@ -272,8 +278,14 @@ class ConfigDialog(QDialog):
self.config["imap_password"] = self.imap_pass_input.text() self.config["imap_password"] = self.imap_pass_input.text()
self.config["imap_folder"] = self.imap_folder_input.text() self.config["imap_folder"] = self.imap_folder_input.text()
self.config["imap_interval"] = self.imap_interval_input.value() self.config["imap_interval"] = self.imap_interval_input.value()
self.config["printer_name"] = self.printer_input.text() # Formats d'impression
self.config["print_format"] = self.format_combo.currentText() formats = self.config.get("formats", dict(DEFAULT_CONFIG["formats"]))
for fmt_name, widgets in self.format_widgets.items():
if fmt_name not in formats:
formats[fmt_name] = {}
formats[fmt_name]["enabled"] = widgets["checkbox"].isChecked()
formats[fmt_name]["printer"] = widgets["printer"].text()
self.config["formats"] = formats
save_config(self.config) save_config(self.config)
self.accept() self.accept()