Impression CUPS réelle + stickers/cadres de démo

Service printer :
- crop_to_ratio() : recadrage auto au ratio du format
- prepare_for_print() : crop + resize aux pixels exacts (300 DPI)
- print_image() : envoi via lp à CUPS avec le bon nombre de copies
- list_printers() : détection des imprimantes disponibles

Print screen :
- Impression dans un thread (pas de freeze UI)
- Feedback : "Envoi à l'imprimante..." → "Impression terminée" / erreur
- Gestion des erreurs CUPS

Assets de démo :
- 5 stickers (heart, star, smile, thumbsup, camera)
- 2 cadres (gold, white)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:03:40 +01:00
parent 1583021af1
commit 378a9a44d5
9 changed files with 197 additions and 8 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/goodies/camera.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
assets/goodies/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/goodies/smile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
assets/goodies/star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
assets/goodies/thumbsup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

162
src/services/printer.py Normal file
View File

@@ -0,0 +1,162 @@
"""Service d'impression CUPS — prépare les images et envoie les jobs."""
import os
import subprocess
import tempfile
from PIL import Image
# DPI pour l'impression sublimation
PRINT_DPI = 300
# Dimensions en pixels à 300 DPI
FORMAT_PIXELS = {
"10x15": (1181, 1772), # 10cm x 15cm
"13x18": (1535, 2126), # 13cm x 18cm
"15x20": (1772, 2362), # 15cm x 20cm
}
FORMAT_RATIOS = {
"10x15": 1.5,
"13x18": 1.385,
"15x20": 1.333,
}
def list_printers():
"""Liste les imprimantes CUPS disponibles."""
try:
result = subprocess.run(
["lpstat", "-p", "-d"],
capture_output=True, text=True, timeout=5
)
printers = []
default = ""
for line in result.stdout.splitlines():
if line.startswith("printer "):
name = line.split()[1]
printers.append(name)
if "system default destination:" in line:
default = line.split(":")[-1].strip()
return printers, default
except (FileNotFoundError, subprocess.TimeoutExpired):
return [], ""
def crop_to_ratio(image, ratio):
"""Crop centré au ratio donné (largeur/hauteur en paysage)."""
w, h = image.size
img_ratio = w / h
# Adapter le ratio au sens de l'image
if w < h:
target_ratio = 1.0 / ratio
else:
target_ratio = ratio
if img_ratio > target_ratio:
# Image trop large → crop horizontal
new_w = int(h * target_ratio)
offset = (w - new_w) // 2
return image.crop((offset, 0, offset + new_w, h))
else:
# Image trop haute → crop vertical
new_h = int(w / target_ratio)
offset = (h - new_h) // 2
return image.crop((0, offset, w, offset + new_h))
def prepare_for_print(image_path, format_name, crop_rect=None):
"""Prépare une image pour l'impression : crop, redimensionnement, export.
Args:
image_path: chemin de l'image source
format_name: "10x15", "13x18", "15x20"
crop_rect: (x, y, w, h) en pixels de l'image source, ou None pour auto
Returns:
chemin du fichier temporaire prêt à imprimer
"""
img = Image.open(image_path)
# Appliquer le crop manuel si fourni
if crop_rect:
x, y, w, h = crop_rect
img = img.crop((x, y, x + w, y + h))
else:
# Auto-crop au ratio
ratio = FORMAT_RATIOS.get(format_name, 1.5)
img = crop_to_ratio(img, ratio)
# Redimensionner aux dimensions exactes du format
target = FORMAT_PIXELS.get(format_name, (1181, 1772))
# Orienter selon l'image
if img.width < img.height:
# Portrait
target = (min(target), max(target))
else:
# Paysage
target = (max(target), min(target))
img = img.resize(target, Image.Resampling.LANCZOS)
# Sauvegarder en JPEG haute qualité
output = os.path.join(tempfile.gettempdir(), f"photostation_print_{os.getpid()}.jpg")
img.save(output, "JPEG", quality=95, dpi=(PRINT_DPI, PRINT_DPI))
return output
def print_image(image_path, format_name, printer="", copies=1, crop_rect=None):
"""Prépare et imprime une image.
Args:
image_path: chemin de l'image source
format_name: "10x15", "13x18", "15x20"
printer: nom CUPS (vide = défaut)
copies: nombre de copies
crop_rect: (x, y, w, h) ou None
Returns:
(success, message)
"""
try:
# Préparer l'image
print_file = prepare_for_print(image_path, format_name, crop_rect)
# Construire la commande lp
cmd = ["lp"]
if printer:
cmd.extend(["-d", printer])
cmd.extend(["-n", str(copies)])
# Options d'impression
cmd.extend(["-o", "fit-to-page"])
cmd.extend(["-o", f"resolution={PRINT_DPI}dpi"])
cmd.append(print_file)
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
# Extraire l'ID du job
job_info = result.stdout.strip()
print(f"[Impression] OK: {job_info}")
return True, job_info
else:
error = result.stderr.strip() or result.stdout.strip()
print(f"[Impression] Erreur: {error}")
return False, error
except FileNotFoundError:
return False, "Commande lp non trouvée — CUPS installé ?"
except subprocess.TimeoutExpired:
return False, "Timeout impression"
except Exception as e:
return False, str(e)
finally:
# Nettoyage
try:
if 'print_file' in locals():
os.unlink(print_file)
except OSError:
pass

View File

@@ -321,15 +321,42 @@ class PrintScreen(QWidget):
self.price_label.setStyleSheet("color: #F44336; font-weight: bold;") self.price_label.setStyleSheet("color: #F44336; font-weight: bold;")
def _do_print(self, printer=""): def _do_print(self, printer=""):
"""Lance l'impression réelle.""" """Lance l'impression réelle via CUPS."""
from services.printer import print_image
import threading
self.print_btn.setEnabled(False)
self.print_btn.setText("Impression en cours...")
self.price_label.setText("Envoi à l'imprimante...")
self.price_label.setStyleSheet("color: #FF9800; font-weight: bold;")
def run_print():
results = []
for path, qty in self.print_list:
success, msg = print_image(
path, self.selected_format,
printer=printer, copies=qty
)
results.append((path, qty, success, msg))
# Retour au thread principal
from PyQt6.QtCore import QTimer
from functools import partial
QTimer.singleShot(0, partial(self._print_done, results))
threading.Thread(target=run_print, daemon=True).start()
def _print_done(self, results):
"""Callback après impression."""
self.print_btn.setEnabled(True) self.print_btn.setEnabled(True)
self.print_btn.setText("IMPRIMER") self.print_btn.setText("IMPRIMER")
for path, qty in self.print_list: total = sum(q for _, q, _, _ in results)
print(f"[Impression] {path} x{qty} → {self.selected_format} cm" errors = [msg for _, _, ok, msg in results if not ok]
f" (printer: {printer or 'défaut'})")
# TODO: appel CUPS réel
total = sum(q for _, q in self.print_list) if errors:
self.price_label.setText(f"Impression lancée — {total} photo{'s' if total > 1 else ''}") self.price_label.setText(f"Erreur : {errors[0]}")
self.price_label.setStyleSheet("color: #F44336; font-weight: bold;")
else:
self.price_label.setText(f"Impression terminée — {total} photo{'s' if total > 1 else ''}")
self.price_label.setStyleSheet("color: #4CAF50; font-weight: bold;") self.price_label.setStyleSheet("color: #4CAF50; font-weight: bold;")