Photo strips, photo d'identité, NFC tap-to-upload

- Photo strips : 2 templates (3 ou 4 photos), toujours 2 strips côte à côte sur un 10x15
- Photo d'identité : écran dédié, cadrage 35x45mm guidé (ovale + ligne yeux), planche 8 photos sur 10x15
- NFC : service nfcpy optionnel, écrit URL upload sur tag NDEF, configurable dans admin
- Config : option NFC on/off ajoutée
- Matériel cible : mini-PC (plus RPi), imprimante DNP DS-RX1HS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 20:15:03 +01:00
parent 06da30c258
commit b39308af95
8 changed files with 589 additions and 24 deletions

View File

@@ -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 |

View File

@@ -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)

116
src/services/nfc_service.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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 :")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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