Files
photobooth/backend/printer.py
Jules 9ce8fc0e55 Gestion evenements + cadres par event + fix cadre paysage + dossier non-imprimees
- CRUD evenements : creer, activer, editer, supprimer dans panneau admin
- Cadres specifiques par evenement (aucun/disponible/defaut/impose)
- Fix cadre paysage deformé : rotation auto si orientation ne correspond pas
- Photos non imprimees vont dans sous-dossier "non_imprimees" (USB/FTP)
  au lieu d'etre ignorees ; booth recoit toujours toutes les photos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:16:35 +02:00

302 lines
11 KiB
Python

import logging
import subprocess
import tempfile
import time
from pathlib import Path
from backend.config import charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.printer")
# PageSize CUPS + dimensions exactes à 300 DPI pour la Mitsubishi CP-K60DW-S
# Le resize vers ces dimensions exactes évite toute bordure blanche
FORMATS = {
"10x15": {"cups": "w288h432", "px": (1200, 1800)},
"15x15": {"cups": "w432h432", "px": (1800, 1800)},
"15x20": {"cups": "w432h576", "px": (1800, 2400)},
# "-div2" declare en CUPS la MEME taille de page que le format plein (le PPD le confirme :
# PaperDimension identique pour w288h432 et w288h432-div2). Le K60 ne duplique pas une
# image fournie a taille reduite : il imprime la feuille entiere telle qu'envoyee puis la
# coupe en 2 apres impression. "px" = taille d'une bande seule, "px_page" = feuille entiere
# a envoyer a lp (bande dupliquee cote a cote par _dupliquer_page).
"10x15-2up": {"cups": "w288h432-div2", "px": (600, 1800), "px_page": (1200, 1800)},
"15x20-2up": {"cups": "w432h576-div2", "px": (900, 2400), "px_page": (1800, 2400)},
}
ERREUR_FICHIER = "fichier_introuvable"
ERREUR_IMPRIMANTE = "imprimante_indisponible"
ERREUR_BOURRAGE = "bourrage_papier"
ERREUR_PAPIER = "plus_de_papier"
ERREUR_IMPRESSION = "erreur_impression"
def _run(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -1, "", f"commande introuvable : {cmd[0]}"
def preparer_apercu(chemin: Path, largeur: int, hauteur: int,
orientation: str = "paysage") -> Path:
"""
Génère un aperçu JPEG recadré aux bonnes dimensions (pour le frontend).
orientation : 'portrait' pivote le DSLR paysage de 90° avant recadrage.
"""
from PIL import Image, ImageOps
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img)
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
img = _crop_to(img, largeur, hauteur)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=85)
return Path(tmp.name)
def _crop_to(img, largeur: int, hauteur: int):
"""Cover crop centre vers les dimensions exactes."""
from PIL import Image
iw, ih = img.size
ratio_cible = largeur / hauteur
ratio_src = iw / ih
if ratio_src > ratio_cible:
new_w = int(ih * ratio_cible)
left = (iw - new_w) // 2
img = img.crop((left, 0, left + new_w, ih))
elif ratio_src < ratio_cible:
new_h = int(iw / ratio_cible)
top = (ih - new_h) // 2
img = img.crop((0, top, iw, top + new_h))
return img.resize((largeur, hauteur), Image.LANCZOS)
def _preparer_image(chemin: Path, largeur: int, hauteur: int,
format_papier: str | None = None, cadre: str | None = None,
orientation: str = "paysage", event_id: str | None = None) -> Path:
"""
Pivote si nécessaire, recadre aux dimensions exactes (cover crop),
applique le cadre, sauvegarde en temp.
"""
try:
from PIL import Image, ImageOps
except ImportError:
log.warning("PIL absent — image envoyée telle quelle, risque de bordures")
return chemin
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img) # respecte l'orientation EXIF
# Portrait demandé mais DSLR paysage → rotation 90°
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
# Paysage demandé mais image portrait (ex. prise avec DSLR tourné) → rotation -90°
elif orientation == "paysage" and img.height > img.width:
img = img.rotate(-90, expand=True)
img = _crop_to(img, largeur, hauteur)
if cadre and format_papier:
img = appliquer_cadre_impression(img, format_papier, cadre, event_id=event_id)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}")
return Path(tmp.name)
def _dupliquer_page(chemin: Path, taille_page: tuple[int, int]) -> Path:
"""Colle la bande 2 fois cote a cote pour remplir la feuille entiere.
Necessaire pour les formats "-div2" : le K60 coupe la feuille imprimee en 2
apres impression, il ne duplique pas une image fournie a taille reduite.
"""
from PIL import Image
img = Image.open(chemin)
page = Image.new("RGB", taille_page, (255, 255, 255))
page.paste(img, (0, 0))
page.paste(img, (img.width, 0))
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
page.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
return Path(tmp.name)
def _statut(nom: str) -> str:
"""Retourne 'idle', 'printing', 'stopped' ou 'inconnue'."""
code, out, _ = _run(["lpstat", "-p", nom])
if code != 0:
return "inconnue"
out = out.lower()
if "disabled" in out or "stopped" in out:
return "stopped"
if "printing" in out or "processing" in out:
return "printing"
if "idle" in out or "inactive" in out:
return "idle"
return "inconnue"
def _reactiver(nom: str) -> bool:
"""Réactive l'imprimante via cupsenable (sudoers sans mot de passe requis)."""
code, _, err = _run(["sudo", "cupsenable", nom], timeout=5)
if code == 0:
log.info(f"Imprimante {nom} réactivée")
return True
log.error(f"Impossible de réactiver {nom} : {err}")
return False
def _detecter_erreur_physique(nom: str) -> str | None:
"""Interroge l'état physique via le backend dyesub."""
try:
r = subprocess.run(
["/usr/lib/cups/backend/gutenprint53+usb", "-s"],
capture_output=True, text=True, timeout=10,
)
combined = (r.stdout + r.stderr).lower()
except Exception:
return None
if "jam" in combined:
return ERREUR_BOURRAGE
if "media out" in combined or "no media" in combined or "remaining: 0/" in combined:
return ERREUR_PAPIER
return None
def lister_imprimantes() -> list[dict]:
"""Liste les imprimantes CUPS disponibles."""
code, out, _ = _run(["lpstat", "-p"])
if code != 0 or not out.strip():
return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
imprimantes = []
for ligne in out.splitlines():
if not ligne.startswith("printer "):
continue
parties = ligne.split()
if len(parties) < 2:
continue
nom = parties[1]
arretee = "disabled" in ligne.lower() or "stopped" in ligne.lower()
imprimantes.append({
"nom": nom,
"statut": "arretee" if arretee else "prete",
})
return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
def imprimer(
chemin_photo: Path,
imprimante: str | None = None,
copies: int | None = None,
format_papier: str | None = None,
cadre_override: str | None = None,
) -> dict:
"""
Imprime une photo sans bordure blanche.
L'image est recadrée et redimensionnée aux dimensions exactes (300 DPI)
avant envoi à CUPS.
Retourne {"succes": True} ou {"succes": False, "erreur": CODE, "message": str}
"""
config = charger_config()
conf_imp = config.get("impression", {})
if imprimante is None:
imprimante = conf_imp.get("imprimante", "Mitsubishi")
if copies is None:
copies = conf_imp.get("copies", 1)
if format_papier is None:
format_papier = conf_imp.get("format", "15x20")
fmt = FORMATS.get(format_papier, FORMATS["15x20"])
page_size = fmt["cups"]
largeur, hauteur = fmt["px"]
fmt_base = format_papier.replace("-2up", "")
cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base)
orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait")
event_id = config.get("evenement", {}).get("event_id")
if event_id and cadre_override is None:
from backend.evenements import cadre_actif_pour_impression
ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base)
if ev_mode == "impose" and ev_cadre:
cadre = ev_cadre
elif ev_mode == "defaut" and ev_cadre and not cadre:
cadre = ev_cadre
if not chemin_photo.exists():
log.error(f"Fichier introuvable : {chemin_photo}")
return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"}
chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation, event_id=event_id)
tmp_a_supprimer = [chemin_print] if chemin_print != chemin_photo else []
px_page = fmt.get("px_page")
if px_page:
chemin_page = _dupliquer_page(chemin_print, px_page)
tmp_a_supprimer.append(chemin_page)
chemin_print = chemin_page
if _statut(imprimante) == "stopped":
log.warning(f"Imprimante {imprimante} arrêtée — réactivation")
if not _reactiver(imprimante):
return {
"succes": False,
"erreur": ERREUR_IMPRIMANTE,
"message": "Imprimante arrêtée, réactivation impossible (vérifiez sudo cupsenable)",
}
time.sleep(1)
try:
for tentative in range(1, 4):
cmd = [
"lp",
"-d", imprimante,
"-n", str(copies),
"-o", f"PageSize={page_size}",
"-o", "StpiShrinkOutput=Crop",
str(chemin_print),
]
code, out, err = _run(cmd, timeout=30)
if code == 0:
job = out.strip()
log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})")
return {"succes": True, "job": job}
log.warning(f"Impression échouée (tentative {tentative}/3) : {err.strip()}")
if tentative < 3:
if _statut(imprimante) == "stopped":
erreur = _detecter_erreur_physique(imprimante)
if erreur == ERREUR_BOURRAGE:
return {
"succes": False,
"erreur": ERREUR_BOURRAGE,
"message": "Bourrage papier — retirez le papier coincé puis réessayez",
}
if erreur == ERREUR_PAPIER:
return {
"succes": False,
"erreur": ERREUR_PAPIER,
"message": "Plus de papier — rechargez le rouleau",
}
_reactiver(imprimante)
time.sleep(2)
return {
"succes": False,
"erreur": ERREUR_IMPRESSION,
"message": "Impression échouée après 3 tentatives",
}
finally:
for tmp in tmp_a_supprimer:
tmp.unlink(missing_ok=True)