La PageSize CUPS w288h432-div2 declare la meme taille de page que w288h432 (confirme par le PPD Mitsubishi) : le K60 coupe la feuille pleine apres impression, il ne duplique pas une image fournie a taille reduite. On envoyait la bande seule (600x1800) sous cette PageSize, donc CUPS l'etirait pour remplir la feuille 1200x1800 puis le K60 la coupait en 2 — d'ou la photo etiree et coupee au lieu de 2 bandes identiques. Ajoute aussi le miroir horizontal sur le preview live (selfie) et un flou de chargement pendant le bref delai de remontee du miroir DSLR apres capture, pour eviter l'impression d'image figee sur la photo precedente.
293 lines
11 KiB
Python
293 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") -> 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 _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")
|
|
|
|
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_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)
|