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;")
|
||||
|
||||
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.setText("IMPRIMER")
|
||||
|
||||
for path, qty in self.print_list:
|
||||
print(f"[Impression] {path} x{qty} → {self.selected_format} cm"
|
||||
f" (printer: {printer or 'défaut'})")
|
||||
# TODO: appel CUPS réel
|
||||
total = sum(q for _, q, _, _ in results)
|
||||
errors = [msg for _, _, ok, msg in results if not ok]
|
||||
|
||||
total = sum(q for _, q in self.print_list)
|
||||
self.price_label.setText(f"Impression lancée — {total} photo{'s' if total > 1 else ''}")
|
||||
self.price_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
|
||||
if errors:
|
||||
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;")
|
||||
|
||||