- _log_impression() ecrit chaque demande dans data/impressions.jsonl (timestamp, photo, copies, format, succes, event_id, detail CUPS) - Delai entre copies passe de 1s a 3s pour fiabiliser multi-copies Mitsubishi Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
14 KiB
Python
363 lines
14 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 == "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") or "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)
|
|
|
|
rembobinage = conf_imp.get("rembobinage_ruban", False)
|
|
|
|
try:
|
|
jobs = []
|
|
for copie in range(1, copies + 1):
|
|
job_ok = False
|
|
for tentative in range(1, 4):
|
|
cmd = [
|
|
"lp",
|
|
"-d", imprimante,
|
|
"-o", f"PageSize={page_size}",
|
|
"-o", "StpiShrinkOutput=Crop",
|
|
]
|
|
if rembobinage:
|
|
cmd.extend(["-o", "StpiDecklist=true"])
|
|
cmd.append(str(chemin_print))
|
|
|
|
code, out, err = _run(cmd, timeout=30)
|
|
|
|
if code == 0:
|
|
job = out.strip()
|
|
log.info(f"Impression copie {copie}/{copies} lancée : {job} ({format_papier} {largeur}x{hauteur}px)")
|
|
jobs.append(job)
|
|
job_ok = True
|
|
break
|
|
|
|
log.warning(f"Impression copie {copie}/{copies} é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)
|
|
|
|
if not job_ok:
|
|
return {
|
|
"succes": False,
|
|
"erreur": ERREUR_IMPRESSION,
|
|
"message": f"Impression copie {copie}/{copies} échouée après 3 tentatives",
|
|
}
|
|
|
|
if copie < copies:
|
|
time.sleep(3)
|
|
|
|
log.info(f"Impression terminée : {copies} copie(s), jobs={jobs}")
|
|
return {"succes": True, "job": ", ".join(jobs)}
|
|
finally:
|
|
for tmp in tmp_a_supprimer:
|
|
tmp.unlink(missing_ok=True)
|