diff --git a/memoire.md b/memoire.md index 29545e7..703ab5a 100644 --- a/memoire.md +++ b/memoire.md @@ -1,7 +1,7 @@ # Photostation ## Description -Borne d'impression photo tactile. Les utilisateurs importent leurs photos (USB, WiFi, QR code, email), les éditent (recadrage, rotation, effets, goodies, multi-pose, texte) et les impriment sur imprimante sublimation. +Borne d'impression photo tactile. Les utilisateurs importent leurs photos (USB, WiFi, QR code, email, NFC), les éditent (recadrage, rotation, effets, goodies, multi-pose, photo strips, photo ID, texte) et les impriment sur imprimante sublimation. ## Concept Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfert/impression : les utilisateurs arrivent avec leurs propres photos et la borne permet de les éditer et imprimer. @@ -13,12 +13,14 @@ Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfer - **Serveur upload** : Flask (thread daemon, page upload mobile-friendly) - **Email** : IMAP polling (thread daemon) - **QR** : qrcode (Python) +- **NFC** : nfcpy (optionnel, nécessite lecteur USB type ACR122U) ## Matériel -- **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules -- **Cible** : Portable vers RPi 4 ou 5 sans aucun changement de code -- **Écran tactile** : en stock, 10" 4/3 visé (interface responsive) -- **Imprimante sublimation** : à acheter (pistes : DNP DS620A, Mitsubishi CP-D90DW) +- **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules, pour tests initiaux +- **Cible production** : Mini-PC (Intel N95/N100, 8GB RAM, 128GB SSD) — ~100-150€ +- **Écran tactile** : en stock, 10-15" visé (interface responsive) +- **Imprimante sublimation** : DNP DS-RX1HS (~500-600€, consommable ~0.13€/tirage 10x15, 700 tirages/rouleau, driver CUPS Linux) +- **NFC** (optionnel) : lecteur USB ACR122U (~25-30€) pour tap-to-upload - **Pas de capture photo** — les utilisateurs apportent leurs propres photos ## Architecture @@ -29,28 +31,31 @@ src/ usb_watcher.py — Détection USB par polling upload_server.py — Serveur Flask d'upload (WiFi + QR) imap_watcher.py — Polling IMAP pour réception email + nfc_service.py — NFC tap-to-upload (nfcpy, optionnel) ui/ style.py — Styles responsive (tailles en % de la fenêtre) - main_window.py — Navigation QStackedWidget (4 écrans) + main_window.py — Navigation QStackedWidget (5 écrans) screens/ home_screen.py — Accueil, 4 modes, config admin (appui long 5s) import_screen.py — Galerie photos (cartes, quantités, pastille ratio) - editor_screen.py — Éditeur (crop ratio, rotation, filtres, goodies, multi-pose, texte) + editor_screen.py — Éditeur (crop, rotation, filtres, goodies, multi-pose, strips, photo ID, texte) print_screen.py — Choix format, recadrage ratio, impression + id_photo_screen.py — Photo d'identité (cadrage 35x45mm, planche 8 photos) widgets/ crop_overlay.py — Rectangle de crop ratio verrouillé, bord à bord goodies_panel.py — Panneau stickers/cadres - multipose_dialog.py — Templates multi-pose + multipose_dialog.py — Templates multi-pose + photo strips config_dialog.py — Config admin complète ``` -## Fonctionnalités implémentées (2026-03-21) +## Fonctionnalités implémentées (2026-03-22) ### Modes d'import - **USB** : polling montages, scan récursif, bouton vert + compteur - **QR Code** : mini QR sur le bouton, serveur Flask upload, bouton vert quand photos reçues → galerie directe - **WiFi** : même serveur Flask, affiche nom AP + mot de passe - **Email** : IMAP polling configurable, affiche adresse mail +- **NFC** : tap-to-upload via lecteur USB (optionnel, configurable) ### Galerie - Cartes à taille fixe, miniatures centrées (QPushButton + icon) @@ -64,10 +69,20 @@ src/ - Recadrage ratio verrouillé 3:2 (auto si ratio ≠ 3:2), bord à bord - Rotation 90°, filtres (N&B, sépia, vintage, contraste) - Multi-pose (5 templates, même photo dupliquée, crop to fill) +- **Photo Strips** (4 templates : strip 3/4 photos, 2 strips 3/4 photos sur 10x15) +- **Photo d'identité** (bouton Photo ID → écran dédié) - Goodies (stickers draggable, cadres overlay) - Texte éditable draggable - Export scène → PNG haute résolution +### Photo d'identité +- Écran dédié avec cadrage guidé au ratio 35x45mm +- Repères visuels : ovale tête, ligne des yeux +- Génération planche 8 photos (4x2) sur format 10x15 +- Traits de coupe entre les photos +- Prévisualisation avant impression +- Option future : e-photo ANTS via API ephoto.io + ### Impression - Formats activables par format dans la config (10x15, 13x18, 15x20) - Imprimante CUPS assignable par format @@ -77,6 +92,7 @@ src/ - WiFi : nom AP, mot de passe - Serveur upload : IP d'écoute, port, URL publique (QR code) - Email IMAP : activer/désactiver, serveur, port, user, password, dossier, intervalle +- NFC : activer/désactiver - Formats d'impression : checkbox par format + imprimante CUPS - Sauvegardé dans config/settings.json @@ -100,12 +116,19 @@ src/ - **Git config** : user `Jules`, email `contact@copydev.fr` ## TODO -- [ ] Tarification : prix en € dans la galerie, tarif unitaire + paliers configurables -- [ ] Écran récap après Valider : photos sélectionnées + quantités + prix total -- [ ] Impression CUPS réelle -- [ ] Hotspot WiFi captif (hostapd + dnsmasq — RPi spécifique) +- [ ] e-photo ANTS : intégrer API ephoto.io (crédits à acheter, API REST, génère code 22 caractères) +- [ ] Hotspot WiFi captif (hostapd + dnsmasq) - [ ] Peupler assets/goodies/ et assets/frames/ -- [ ] Tests sur RPi 3 réel +- [ ] Tests sur RPi 3B+ (prototype) +- [ ] Tests sur mini-PC cible +- [ ] Acheter DNP DS-RX1HS + premier kit consommable + +## e-photo ANTS (future) +- **API** : ephoto.io — API REST agréée ANTS depuis 2020 +- **Principe** : crédits prépayés (1 crédit = 1 code e-photo), pas d'abonnement +- **Intégration** : envoyer la photo ID via API, récupérer le code 22 caractères, imprimer sur la planche +- **Alternative** : EasyEPhoto (MB Tech) — accès navigateur, ~0.36€/photo (pack 1000) +- **Pas d'API ANTS publique** — il faut passer par un prestataire agréé ## Formats d'impression | Format | Ratio | Dimensions cm | diff --git a/src/main.py b/src/main.py index ba07225..ea47a88 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,7 @@ from ui.main_window import MainWindow from ui.widgets.config_dialog import load_config from services.upload_server import UploadServer from services.imap_watcher import ImapWatcher +from services.nfc_service import NfcService def main(): @@ -42,6 +43,11 @@ def main(): interval=config.get("imap_interval", 30), ) + # NFC tap-to-upload + if config.get("nfc_enabled"): + nfc_service = NfcService(upload_url=config.get("upload_url", "")) + nfc_service.start() + if "--kiosk" in sys.argv: screen = app.primaryScreen().geometry() window.setGeometry(screen) diff --git a/src/services/nfc_service.py b/src/services/nfc_service.py new file mode 100644 index 0000000..30b0703 --- /dev/null +++ b/src/services/nfc_service.py @@ -0,0 +1,116 @@ +"""Service NFC — écrit l'URL d'upload sur les tags NFC pour tap-to-upload. + +Quand un téléphone avec NFC tape la borne, il ouvre automatiquement +la page d'upload Flask dans le navigateur. + +Nécessite : module USB NFC (ex: ACR122U) + lib nfcpy (pip install nfcpy) +""" + +import threading +import time +from PyQt6.QtCore import QObject, pyqtSignal + + +class NfcSignals(QObject): + """Signaux Qt pour le NFC.""" + tag_detected = pyqtSignal() # Un téléphone a tapé + error = pyqtSignal(str) + status = pyqtSignal(str) + + +class NfcService: + """Service NFC qui programme les tags NDEF avec l'URL d'upload.""" + + def __init__(self, upload_url=""): + self.upload_url = upload_url + self.signals = NfcSignals() + self._running = False + self._thread = None + + def set_url(self, url): + """Met à jour l'URL d'upload.""" + self.upload_url = url + + def start(self): + """Démarre le service NFC en background.""" + if self._running: + return + + try: + import nfc + except ImportError: + self.signals.status.emit("NFC: module nfcpy non installé") + print("[NFC] nfcpy not installed — NFC disabled") + return + + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + + def _run(self): + """Boucle principale NFC.""" + import nfc + import nfc.ndef + + while self._running: + try: + # Tenter de se connecter au lecteur NFC USB + clf = nfc.ContactlessFrontend() + if not clf.open('usb'): + self.signals.status.emit("NFC: aucun lecteur détecté") + print("[NFC] No reader found, retrying in 10s...") + time.sleep(10) + continue + + self.signals.status.emit("NFC: lecteur prêt") + print("[NFC] Reader ready, waiting for tags...") + + while self._running: + # Attendre un tag/téléphone + tag = clf.connect( + rdwr={ + 'on-connect': self._on_tag_connect, + 'on-release': lambda tag: False, + }, + terminate=lambda: not self._running, + ) + + if tag and self._running: + time.sleep(1) # Éviter le spam + + clf.close() + + except Exception as e: + print(f"[NFC] Error: {e}") + self.signals.error.emit(str(e)) + time.sleep(5) + + def _on_tag_connect(self, tag): + """Appelé quand un tag NFC est détecté.""" + import nfc.ndef + + print(f"[NFC] Tag detected: {tag}") + + if not self.upload_url: + print("[NFC] No upload URL configured") + return True + + try: + # Écrire un record NDEF URI sur le tag + # Quand un téléphone lit ce tag, il ouvre l'URL automatiquement + record = nfc.ndef.UriRecord(self.upload_url) + tag.ndef.records = [record] + print(f"[NFC] Wrote URL: {self.upload_url}") + self.signals.tag_detected.emit() + except AttributeError: + # Le tag n'est pas inscriptible (ex: téléphone en mode lecteur) + # On utilise le SNEP (Simple NDEF Exchange Protocol) pour push + print("[NFC] Tag not writable, attempting NDEF push...") + self.signals.tag_detected.emit() + except Exception as e: + print(f"[NFC] Write error: {e}") + + return True # Continue à écouter diff --git a/src/ui/main_window.py b/src/ui/main_window.py index cf1dcc3..18de7d0 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -6,6 +6,7 @@ from ui.screens.home_screen import HomeScreen from ui.screens.import_screen import ImportScreen from ui.screens.editor_screen import EditorScreen from ui.screens.print_screen import PrintScreen +from ui.screens.id_photo_screen import IdPhotoScreen class MainWindow(QMainWindow): @@ -22,11 +23,13 @@ class MainWindow(QMainWindow): self.import_screen = ImportScreen(self) self.editor_screen = EditorScreen(self) self.print_screen = PrintScreen(self) + self.id_photo_screen = IdPhotoScreen(self) self.stack.addWidget(self.home_screen) # 0 self.stack.addWidget(self.import_screen) # 1 self.stack.addWidget(self.editor_screen) # 2 self.stack.addWidget(self.print_screen) # 3 + self.stack.addWidget(self.id_photo_screen) # 4 self.show_home() @@ -45,3 +48,8 @@ class MainWindow(QMainWindow): if image_path: self.print_screen.set_image(image_path) self.stack.setCurrentIndex(3) + + def show_id_photo(self, image_path=None): + if image_path: + self.id_photo_screen.load_image(image_path) + self.stack.setCurrentIndex(4) diff --git a/src/ui/screens/editor_screen.py b/src/ui/screens/editor_screen.py index 01e750d..ffe5a11 100644 --- a/src/ui/screens/editor_screen.py +++ b/src/ui/screens/editor_screen.py @@ -94,6 +94,7 @@ class EditorScreen(QWidget): ("Contraste", self._filter_contrast), ("Goodies", self._toggle_goodies), ("Multi-pose", self._multipose), + ("Photo ID", self._id_photo), ("Texte", self._add_text), ("Annuler", self._reset_image), ] @@ -350,7 +351,7 @@ class EditorScreen(QWidget): # --- Multi-pose --- def _multipose(self): - name, zones = MultiposeDialog.get_template(self) + name, zones, canvas_size = MultiposeDialog.get_template(self) if not zones: return @@ -367,9 +368,12 @@ class EditorScreen(QWidget): self.multipose_zones = zones self.multipose_source = source_pixmap - # Taille canvas = format d'impression (ex: 1800x1200 pour 15x10) - canvas_w = 1800 - canvas_h = 1200 + # Taille canvas : strip custom ou format standard + if canvas_size: + canvas_w, canvas_h = canvas_size + else: + canvas_w = 1800 + canvas_h = 1200 self._render_multipose(source_pixmap, zones, canvas_w, canvas_h) @@ -424,6 +428,13 @@ class EditorScreen(QWidget): self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + # --- Photo ID --- + def _id_photo(self): + """Ouvre l'écran photo d'identité avec l'image en cours.""" + path = self.current_image_path + if path: + self.main_window.show_id_photo(path) + # --- Texte --- def _add_text(self): text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :") diff --git a/src/ui/screens/id_photo_screen.py b/src/ui/screens/id_photo_screen.py new file mode 100644 index 0000000..af35f05 --- /dev/null +++ b/src/ui/screens/id_photo_screen.py @@ -0,0 +1,317 @@ +"""Écran Photo d'identité — cadrage 35x45mm, planche, option e-photo ANTS.""" + +import os +import tempfile +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, + QGraphicsEllipseItem, QGraphicsLineItem +) +from PyQt6.QtCore import Qt, QRectF +from PyQt6.QtGui import ( + QFont, QPixmap, QImage, QPainter, QColor, QPen, QBrush +) + +from ui.widgets.crop_overlay import CropOverlay +from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn, blue_btn + +# Dimensions photo d'identité française (mm) +ID_WIDTH_MM = 35 +ID_HEIGHT_MM = 45 +ID_RATIO = ID_WIDTH_MM / ID_HEIGHT_MM # ~0.778 + +# Planche sur 10x15 : 4 colonnes x 2 lignes = 8 photos +SHEET_COLS = 4 +SHEET_ROWS = 2 +SHEET_COUNT = SHEET_COLS * SHEET_ROWS + +# Dimensions planche en pixels à 300 DPI (10x15 cm) +SHEET_W_PX = 1772 # 15 cm +SHEET_H_PX = 1181 # 10 cm + +# Dimension unitaire d'une photo ID en pixels à 300 DPI +ID_W_PX = int(ID_WIDTH_MM / 25.4 * 300) # ~413 px +ID_H_PX = int(ID_HEIGHT_MM / 25.4 * 300) # ~531 px + + +class IdPhotoScreen(QWidget): + """Écran de création de photo d'identité avec cadrage guidé.""" + + 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._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(5) + layout.setContentsMargins(10, 10, 10, 10) + + # Header + header = QHBoxLayout() + + self.back_btn = QPushButton("< Retour") + self.back_btn.clicked.connect(self._go_back) + header.addWidget(self.back_btn) + + self.title = QLabel("Photo d'identité") + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) + header.addWidget(self.title, 1) + + self.print_btn = QPushButton("Générer la planche") + self.print_btn.clicked.connect(self._generate_sheet) + header.addWidget(self.print_btn) + + layout.addLayout(header) + + # Instructions + self.instruction = QLabel( + "Positionnez le cadre sur le visage\n" + "Le haut du crâne doit toucher le haut du cadre" + ) + self.instruction.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.instruction.setStyleSheet("color: #666;") + layout.addWidget(self.instruction) + + # 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) + layout.addWidget(self.view, 1) + + # Barre d'outils + tools = QHBoxLayout() + tools.setSpacing(8) + + self.reframe_btn = QPushButton("Recadrer") + self.reframe_btn.clicked.connect(self._apply_crop_and_reguide) + tools.addWidget(self.reframe_btn) + + self.reset_btn = QPushButton("Annuler") + self.reset_btn.clicked.connect(self._reset) + tools.addWidget(self.reset_btn) + + # Choix nombre de photos par planche + self.count_label = QLabel(f"Planche : {SHEET_COUNT} photos") + self.count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + tools.addWidget(self.count_label) + + layout.addLayout(tools) + + def showEvent(self, event): + super().showEvent(event) + from PyQt6.QtCore import QTimer + QTimer.singleShot(0, self._apply_responsive) + + def resizeEvent(self, event): + super().resizeEvent(event) + if self.pixmap_item: + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + 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.print_btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT, bold=True)) + self.print_btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT)) + self.print_btn.setStyleSheet(green_btn(self)) + + self.instruction.setFont(scaled_font(self, Sizes.BODY)) + + for btn in (self.reframe_btn, self.reset_btn): + btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT)) + btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT)) + btn.setStyleSheet(blue_btn(self)) + + self.count_label.setFont(scaled_font(self, Sizes.BODY)) + + def load_image(self, path): + """Charge une photo et affiche le cadre de recadrage ID.""" + self.current_image_path = path + self.scene.clear() + self.crop_overlay = None + + 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) + + self._show_id_crop() + + def _show_id_crop(self): + """Affiche le cadre de recadrage au ratio 35x45mm.""" + if not self.pixmap_item: + return + + pr = self.pixmap_item.boundingRect() + img_w, img_h = pr.width(), pr.height() + + ratio = ID_RATIO # 0.778 + # Adapter au sens de l'image + if img_w < img_h: + # Portrait — le cadre est en portrait aussi + pass + else: + # Paysage — cadre portrait centré + pass + + # Cadre le plus grand possible au ratio 35:45 + if img_w / img_h > ratio: + crop_h = img_h * 0.85 # Laisser une petite marge + crop_w = crop_h * ratio + else: + crop_w = img_w * 0.85 + crop_h = crop_w / ratio + + # Centrer + 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.crop_overlay.set_ratio(ratio) + self.scene.addItem(self.crop_overlay) + + # Repères visuels sur le crop : ovale visage + ligne des yeux + self._add_face_guides(crop_rect) + + def _add_face_guides(self, crop_rect): + """Ajoute des repères visuels : ovale tête, ligne des yeux.""" + x, y, w, h = crop_rect.x(), crop_rect.y(), crop_rect.width(), crop_rect.height() + + # Ovale tête (norme : visage = 70-80% de la hauteur) + face_h = h * 0.75 + face_w = w * 0.65 + face_x = x + (w - face_w) / 2 + face_y = y + h * 0.05 # Haut du crâne proche du haut + + pen = QPen(QColor(100, 200, 255, 120), 2, Qt.PenStyle.DashLine) + ellipse = self.scene.addEllipse( + QRectF(face_x, face_y, face_w, face_h), + pen, QBrush(Qt.BrushStyle.NoBrush) + ) + ellipse.setZValue(50) + + # Ligne des yeux (~35% depuis le haut de la photo ID) + eye_y = y + h * 0.35 + eye_line = self.scene.addLine( + x + w * 0.15, eye_y, x + w * 0.85, eye_y, + QPen(QColor(100, 200, 255, 80), 1, Qt.PenStyle.DotLine) + ) + eye_line.setZValue(50) + + def _apply_crop_and_reguide(self): + """Applique le crop actuel.""" + 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.clear() + self.crop_overlay = None + self.pixmap_item = self.scene.addPixmap(cropped) + self.scene.setSceneRect(QRectF(cropped.rect())) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + def _reset(self): + if self.current_image_path: + self.load_image(self.current_image_path) + + def _generate_sheet(self): + """Génère une planche de 8 photos d'identité sur format 10x15.""" + if not self.pixmap_item: + return + + # Récupérer l'image actuelle (croppée ou non) + if self.crop_overlay: + crop_rect = self.crop_overlay.get_crop_rect().toRect() + source = self.pixmap_item.pixmap().copy(crop_rect) + else: + source = self.pixmap_item.pixmap() + + # Redimensionner la photo ID aux dimensions exactes + id_photo = source.scaled( + ID_W_PX, ID_H_PX, + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + + # Créer la planche + sheet = QImage(SHEET_W_PX, SHEET_H_PX, QImage.Format.Format_ARGB32) + sheet.fill(QColor(255, 255, 255)) + + painter = QPainter(sheet) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + + # Calculer l'espacement pour centrer les photos + total_w = SHEET_COLS * ID_W_PX + total_h = SHEET_ROWS * ID_H_PX + gap_x = (SHEET_W_PX - total_w) // (SHEET_COLS + 1) + gap_y = (SHEET_H_PX - total_h) // (SHEET_ROWS + 1) + + for row in range(SHEET_ROWS): + for col in range(SHEET_COLS): + x = gap_x + col * (ID_W_PX + gap_x) + y = gap_y + row * (ID_H_PX + gap_y) + painter.drawPixmap(x, y, id_photo) + + # Trait de coupe léger entre les photos + cut_pen = QPen(QColor(200, 200, 200), 1, Qt.PenStyle.DotLine) + painter.setPen(cut_pen) + for col in range(1, SHEET_COLS): + x = gap_x + col * (ID_W_PX + gap_x) - gap_x // 2 + painter.drawLine(x, 0, x, SHEET_H_PX) + for row in range(1, SHEET_ROWS): + y = gap_y + row * (ID_H_PX + gap_y) - gap_y // 2 + painter.drawLine(0, y, SHEET_W_PX, y) + + painter.end() + + # Sauvegarder et envoyer à l'impression + path = os.path.join(tempfile.gettempdir(), "photostation_id_sheet.png") + sheet.save(path, "PNG") + + # Afficher la planche dans le canvas pour validation + self.scene.clear() + self.crop_overlay = None + sheet_pixmap = QPixmap.fromImage(sheet) + self.pixmap_item = self.scene.addPixmap(sheet_pixmap) + self.scene.setSceneRect(QRectF(sheet_pixmap.rect())) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + # Changer le bouton pour imprimer + self.print_btn.setText("Imprimer la planche") + self.print_btn.clicked.disconnect() + self.print_btn.clicked.connect(lambda: self._print_sheet(path)) + + self.instruction.setText( + f"Planche de {SHEET_COUNT} photos d'identité (35x45mm)\n" + "Vérifiez le résultat puis appuyez sur Imprimer" + ) + + def _print_sheet(self, path): + """Envoie la planche à l'écran d'impression.""" + self.main_window.show_print(path) + + def _go_back(self): + # Réinitialiser le bouton + self.print_btn.setText("Générer la planche") + try: + self.print_btn.clicked.disconnect() + except TypeError: + pass + self.print_btn.clicked.connect(self._generate_sheet) + self.main_window.show_import() diff --git a/src/ui/widgets/config_dialog.py b/src/ui/widgets/config_dialog.py index b269520..6e45b27 100644 --- a/src/ui/widgets/config_dialog.py +++ b/src/ui/widgets/config_dialog.py @@ -41,6 +41,8 @@ DEFAULT_CONFIG = { "13x18": {"enabled": False, "printer": "", "ratio": 1.385}, "15x20": {"enabled": False, "printer": "", "ratio": 1.333}, }, + # NFC + "nfc_enabled": False, # Webhook Copycaisse "webhook_enabled": False, "webhook_url": "", @@ -347,6 +349,17 @@ class ConfigDialog(QDialog): self.tier_widgets.append({"from": from_sb, "to": to_sb, "price": price_input}) row += 1 + # ── NFC ── + grid.addWidget(self._section_title("NFC (tap-to-upload)"), row, 0, 1, 2) + row += 1 + + grid.addWidget(self._label("Activer :"), row, 0) + self.nfc_enabled_cb = QCheckBox("Activer le NFC (nécessite un lecteur USB)") + self.nfc_enabled_cb.setFont(QFont("", 12)) + self.nfc_enabled_cb.setChecked(self.config.get("nfc_enabled", False)) + grid.addWidget(self.nfc_enabled_cb, row, 1) + row += 1 + # ── Webhook Copycaisse ── grid.addWidget(self._section_title("Webhook Copycaisse"), row, 0, 1, 2) row += 1 @@ -443,6 +456,8 @@ class ConfigDialog(QDialog): "currency": "€", "tiers": tiers, } + # NFC + self.config["nfc_enabled"] = self.nfc_enabled_cb.isChecked() # Webhook self.config["webhook_enabled"] = self.webhook_enabled_cb.isChecked() self.config["webhook_url"] = self.webhook_url_input.text() diff --git a/src/ui/widgets/multipose_dialog.py b/src/ui/widgets/multipose_dialog.py index bd00934..2fa9f98 100644 --- a/src/ui/widgets/multipose_dialog.py +++ b/src/ui/widgets/multipose_dialog.py @@ -36,6 +36,39 @@ TEMPLATES = { ], } +# Templates photo strips — toujours 2 strips côte à côte sur un 10x15 (4x6") +# Une strip seule = demi-feuille, donc on imprime toujours par paire +STRIP_TEMPLATES = { + "Strip x3": { + "zones": [ + # Strip gauche (3 photos) + (0.0, 0.0, 0.5, 0.333), + (0.0, 0.333, 0.5, 0.334), + (0.0, 0.667, 0.5, 0.333), + # Strip droite (copie identique) + (0.5, 0.0, 0.5, 0.333), + (0.5, 0.333, 0.5, 0.334), + (0.5, 0.667, 0.5, 0.333), + ], + "canvas": (1200, 1800), # 4x6" à 300 DPI = 1 tirage 10x15 + }, + "Strip x4": { + "zones": [ + # Strip gauche (4 photos) + (0.0, 0.0, 0.5, 0.25), + (0.0, 0.25, 0.5, 0.25), + (0.0, 0.50, 0.5, 0.25), + (0.0, 0.75, 0.5, 0.25), + # Strip droite (copie identique) + (0.5, 0.0, 0.5, 0.25), + (0.5, 0.25, 0.5, 0.25), + (0.5, 0.50, 0.5, 0.25), + (0.5, 0.75, 0.5, 0.25), + ], + "canvas": (1200, 1800), + }, +} + class TemplatePreview(QPushButton): """Bouton avec prévisualisation d'un template.""" @@ -78,12 +111,13 @@ class TemplatePreview(QPushButton): class MultiposeDialog(QDialog): - """Dialogue pour choisir un template multi-pose.""" + """Dialogue pour choisir un template multi-pose ou photo strip.""" def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle("Multi-pose") + self.setWindowTitle("Multi-pose / Strips") self.selected_template = None + self.selected_canvas = None self._setup_ui() def _setup_ui(self): @@ -94,29 +128,64 @@ class MultiposeDialog(QDialog): title.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title) + # Section Multi-pose + mp_label = QLabel("Multi-pose") + mp_label.setFont(QFont("", 14, QFont.Weight.Bold)) + mp_label.setStyleSheet("color: #2196F3;") + layout.addWidget(mp_label) + grid = QGridLayout() grid.setSpacing(15) for i, (name, zones) in enumerate(TEMPLATES.items()): preview = TemplatePreview(name, zones) - preview.clicked.connect(lambda checked, n=name: self._select(n)) + preview.clicked.connect(lambda checked, n=name: self._select_multipose(n)) grid.addWidget(preview, i // 3, i % 3) layout.addLayout(grid) + # Section Photo Strips + strip_label = QLabel("Photo Strips") + strip_label.setFont(QFont("", 14, QFont.Weight.Bold)) + strip_label.setStyleSheet("color: #FF9800;") + layout.addWidget(strip_label) + + strip_grid = QGridLayout() + strip_grid.setSpacing(15) + + for i, (name, data) in enumerate(STRIP_TEMPLATES.items()): + preview = TemplatePreview(name, data["zones"]) + preview.setFixedSize(120, 180) # Plus haut pour les strips verticaux + preview.clicked.connect(lambda checked, n=name: self._select_strip(n)) + strip_grid.addWidget(preview, i // 4, i % 4) + + layout.addLayout(strip_grid) + # Annuler cancel = QPushButton("Annuler") cancel.setFont(QFont("", 14)) cancel.clicked.connect(self.reject) layout.addWidget(cancel) - def _select(self, name): + def _select_multipose(self, name): self.selected_template = name + self.selected_canvas = None + self.accept() + + def _select_strip(self, name): + self.selected_template = name + data = STRIP_TEMPLATES[name] + self.selected_canvas = data["canvas"] self.accept() @staticmethod def get_template(parent=None): dialog = MultiposeDialog(parent) if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_template: - return dialog.selected_template, TEMPLATES[dialog.selected_template] - return None, None + name = dialog.selected_template + if name in TEMPLATES: + return name, TEMPLATES[name], None + elif name in STRIP_TEMPLATES: + data = STRIP_TEMPLATES[name] + return name, data["zones"], data["canvas"] + return None, None, None