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)}, "10x15-2up": {"cups": "w288h432-div2", "px": (600, 1800)}, # 1 strip 5x15cm, printer duplique "15x20-2up": {"cups": "w432h576-div2", "px": (900, 2400)}, # 1 strip 7.5x20cm, printer duplique } 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") -> 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) 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 _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") 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) tmp_cree = chemin_print != chemin_photo 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: if tmp_cree: chemin_print.unlink(missing_ok=True)