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>
BIN
assets/frames/gold_frame.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/frames/white_frame.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/goodies/camera.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
assets/goodies/heart.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/goodies/smile.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
assets/goodies/star.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
assets/goodies/thumbsup.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
162
src/services/printer.py
Normal 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
|
||||||
@@ -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: #4CAF50; font-weight: bold;")
|
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;")
|
||||||
|
|||||||