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 == "paysage" and hauteur > largeur: largeur, hauteur = hauteur, largeur 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, skip_rotate: bool = False) -> Path: """ Pivote si nécessaire, recadre aux dimensions exactes (cover crop), applique le cadre, compense les marges de coupe, 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) if not skip_rotate: if orientation == "portrait" and img.width > img.height: img = img.rotate(90, expand=True) 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) img = _appliquer_offsets_coupe(img) config_imp = charger_config().get("impression", {}) if config_imp.get("rotation_180", False) and not skip_rotate: img = img.rotate(180) 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 _appliquer_offsets_coupe(img): """Décale le contenu de l'image pour compenser les marges de coupe asymétriques. Config impression.offsets_coupe_mm : {haut, bas, gauche, droite} en mm.""" from PIL import Image config = charger_config() offsets = config.get("impression", {}).get("offsets_coupe_mm", {}) if not offsets: return img DPI = 300 mm_px = DPI / 25.4 haut = int(offsets.get("haut", 0) * mm_px) bas = int(offsets.get("bas", 0) * mm_px) gauche = int(offsets.get("gauche", 0) * mm_px) droite = int(offsets.get("droite", 0) * mm_px) if haut == 0 and bas == 0 and gauche == 0 and droite == 0: return img w, h = img.size nouvelle = Image.new("RGB", (w, h), (255, 255, 255)) # Décale : offset positif = l'imprimante coupe trop de ce côté, on décale le contenu vers l'opposé # crop la source pour que le contenu tienne malgré le décalage src_x = max(0, droite - gauche) src_y = max(0, bas - haut) dst_x = max(0, gauche - droite) dst_y = max(0, haut - bas) crop_w = w - abs(gauche - droite) crop_h = h - abs(haut - bas) region = img.crop((src_x, src_y, src_x + crop_w, src_y + crop_h)) nouvelle.paste(region, (dst_x, dst_y)) return nouvelle 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", "") is_strip = "-2up" in format_papier cadre = cadre_override if cadre_override is not None else (None if is_strip else conf_imp.get("cadres_actifs", {}).get(fmt_base)) orientation = "portrait" if is_strip else conf_imp.get("orientations", {}).get(fmt_base, "portrait") if orientation == "paysage" and hauteur > largeur: largeur, hauteur = hauteur, largeur event_id = config.get("evenement", {}).get("event_id") if event_id and cadre_override is None and not is_strip: 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, skip_rotate=is_strip) 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)