Files
ArtemiaandClaude Opus 5.5 503aa66dc0 Polices libres Noto Sans / Noto Sans Mono, vérifiées et installées par l'assistant
Segoe UI et Consolas (polices Microsoft, non redistribuables) provoquaient des avertissements Typst sous Linux et macOS. Le gabarit utilise désormais Noto Sans et Noto Sans Mono. L'assistant vérifie leur présence (quarto typst fonts) et sinon les installe : paquets système sous Linux, ou téléchargement depuis le dépôt officiel Noto dans le dossier de polices de l'utilisateur (Windows, macOS, et secours Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:00:53 +02:00

365 lines
15 KiB
Python

"""Assistant de génération des supports de la formation (Windows, macOS, Linux).
Lancé par Generer-documents.bat / Generer-documents.command / generer-documents.sh.
N'utilise que la bibliothèque standard de Python.
Étapes : vérifier (ou installer) Quarto, préparer l'environnement Python des schémas,
demander quoi générer, lancer Quarto, ouvrir le dossier des documents produits.
"""
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
import webbrowser
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
MODULES = RACINE / "modules"
SORTIE = RACINE / "_output"
VENV = RACINE / ".venv"
EXIGENCES = RACINE / "schemas" / "requirements.txt"
PAGE_QUARTO = "https://quarto.org/docs/get-started/"
SYSTEME = platform.system() # "Windows", "Darwin" (macOS) ou "Linux"
FORMATS = [ # (libellé, option --to de Quarto, extension produite)
("PDF", "typst", ".pdf"),
("LibreOffice (.odt)", "odt", ".odt"),
("Site web (HTML)", "html", ".html"),
]
# --- Dialogue ----------------------------------------------------------------
def titre(texte):
print("\n" + "=" * 64 + f"\n {texte}\n" + "=" * 64)
def oui_non(question, defaut=True):
suffixe = " [O/n] " if defaut else " [o/N] "
while True:
reponse = input(question + suffixe).strip().lower()
if not reponse:
return defaut
if reponse in ("o", "oui", "y", "yes"):
return True
if reponse in ("n", "non", "no"):
return False
print(" Répondez par o (oui) ou n (non).")
def choisir(question, options, defaut=1):
"""Affiche une liste numérotée et renvoie l'index (0-based) choisi."""
print(f"\n{question}")
for i, option in enumerate(options, 1):
print(f" {i:>2}. {option}" + (" (par défaut)" if i == defaut else ""))
while True:
reponse = input(f"Votre choix [{defaut}] : ").strip()
if not reponse:
return defaut - 1
if reponse.isdigit() and 1 <= int(reponse) <= len(options):
return int(reponse) - 1
print(f" Tapez un nombre entre 1 et {len(options)}.")
def attendre(message="Appuyez sur Entrée pour continuer..."):
try:
input(message)
except EOFError:
pass
# --- Quarto --------------------------------------------------------------------
def trouver_quarto():
trouve = shutil.which("quarto")
if trouve:
return trouve
candidats = {
"Windows": [Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Quarto/bin/quarto.exe",
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs/Quarto/bin/quarto.exe"],
"Darwin": [Path("/Applications/quarto/bin/quarto"), Path("/usr/local/bin/quarto"),
Path("/opt/homebrew/bin/quarto")],
"Linux": [Path("/opt/quarto/bin/quarto"), Path("/usr/local/bin/quarto"),
Path.home() / ".local/bin/quarto"],
}.get(SYSTEME, [])
for chemin in candidats:
if chemin.is_file():
return str(chemin)
return None
def telecharger_quarto(motif):
"""Télécharge le fichier d'installation de la dernière version de Quarto dont le nom contient `motif`."""
with urllib.request.urlopen("https://api.github.com/repos/quarto-dev/quarto-cli/releases/latest",
timeout=30) as reponse:
version = json.load(reponse)
fichier = next(a for a in version["assets"] if a["name"].endswith(motif))
destination = Path(tempfile.gettempdir()) / fichier["name"]
print(f" Téléchargement de {fichier['name']} ({fichier['size'] // 1_000_000} Mo)...")
urllib.request.urlretrieve(fichier["browser_download_url"], destination)
return destination
def installer_quarto():
try:
if SYSTEME == "Windows":
if shutil.which("winget"):
subprocess.run(["winget", "install", "-e", "--id", "Posit.Quarto",
"--accept-source-agreements", "--accept-package-agreements"])
else:
subprocess.run(["msiexec", "/i", str(telecharger_quarto("win.msi"))])
elif SYSTEME == "Darwin":
if shutil.which("brew"):
subprocess.run(["brew", "install", "--cask", "quarto"])
else:
print(" L'installateur va s'ouvrir : suivez ses étapes puis revenez ici.")
subprocess.run(["open", "-W", str(telecharger_quarto("macos.pkg"))])
elif SYSTEME == "Linux" and shutil.which("apt-get"):
arch = "arm64" if platform.machine().lower() in ("aarch64", "arm64") else "amd64"
paquet = telecharger_quarto(f"linux-{arch}.deb")
print(" Votre mot de passe peut être demandé (installation système).")
subprocess.run(["sudo", "apt-get", "install", "-y", str(paquet)])
elif SYSTEME == "Linux" and shutil.which("pacman"):
return installer_quarto_arch()
else:
return False
except Exception as erreur: # réseau indisponible, fichier introuvable…
print(f" L'installation automatique a échoué : {erreur}")
return False
return True
def installer_quarto_arch():
"""Arch Linux et dérivées : dépôts officiels si le paquet y existe, sinon AUR via yay (ou paru)."""
for nom in ("quarto-cli", "quarto"):
if subprocess.run(["pacman", "-Si", nom], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
print(" Votre mot de passe peut être demandé (installation système).")
subprocess.run(["sudo", "pacman", "-S", "--needed", nom])
return True
paquet_aur = "quarto-cli-bin" # binaire officiel empaqueté, à jour (quarto-cli compile depuis les sources)
for assistant_aur in ("yay", "paru"):
if shutil.which(assistant_aur):
print(f" Quarto n'est disponible que dans l'AUR : installation de « {paquet_aur} » avec {assistant_aur}.")
print(" Validez les questions posées ; votre mot de passe sera demandé à la fin.")
# Jamais avec sudo : yay et paru s'exécutent en utilisateur normal et appellent sudo eux-mêmes.
subprocess.run([assistant_aur, "-S", "--needed", paquet_aur])
return True
print(" Sous Arch, Quarto n'est disponible que dans l'AUR (paquet « quarto-cli-bin »),")
print(" et aucun assistant AUR (yay ou paru) n'a été trouvé sur cet ordinateur.")
print(" Installez yay (https://github.com/Jguer/yay) puis relancez, ou installez le paquet à la main.")
return False
def verifier_quarto():
titre("1/4 Quarto (outil de mise en page)")
quarto = trouver_quarto()
if quarto:
print(f" Quarto est installé : {quarto}")
return quarto
print(" Quarto n'est pas installé. Il est indispensable pour produire les documents.")
if oui_non(" L'installer automatiquement maintenant ?"):
installer_quarto()
quarto = trouver_quarto()
while not quarto:
print(f"\n Installez Quarto depuis la page qui va s'ouvrir : {PAGE_QUARTO}")
webbrowser.open(PAGE_QUARTO)
attendre(" Une fois l'installation terminée, appuyez sur Entrée...")
quarto = trouver_quarto()
if not quarto and not oui_non(" Quarto reste introuvable. Réessayer ?"):
raise SystemExit(1)
print(f" Quarto est prêt : {quarto}")
return quarto
# --- Polices -----------------------------------------------------------------------
DEPOT_NOTO = "https://github.com/notofonts/notofonts.github.io/raw/main/fonts"
POLICES = { # famille attendue par _quarto.yml -> fichiers à installer si elle manque
"Noto Sans": [f"NotoSans/hinted/ttf/NotoSans-{s}.ttf" for s in
("Regular", "Italic", "SemiBold", "SemiBoldItalic", "Bold", "BoldItalic")],
"Noto Sans Mono": [f"NotoSansMono/hinted/ttf/NotoSansMono-{s}.ttf" for s in ("Regular", "Bold")],
}
PAQUETS_POLICES = { # gestionnaire Linux -> commande d'installation des deux familles
"apt-get": ["sudo", "apt-get", "install", "-y", "fonts-noto-core", "fonts-noto-mono"],
"pacman": ["sudo", "pacman", "-S", "--needed", "noto-fonts"],
"dnf": ["sudo", "dnf", "install", "-y", "google-noto-sans-fonts", "google-noto-sans-mono-fonts"],
"zypper": ["sudo", "zypper", "install", "-y", "noto-sans-fonts", "noto-sans-mono-fonts"],
}
def polices_manquantes(quarto):
"""Familles absentes, d'après la liste des polices réellement visibles par Typst."""
resultat = subprocess.run([quarto, "typst", "fonts"], capture_output=True, text=True,
encoding="utf-8", errors="replace")
sortie = resultat.stdout + resultat.stderr # Quarto écrit la liste sur la sortie d'erreur
familles = {ligne.strip() for ligne in sortie.splitlines()}
return [f for f in POLICES if f not in familles]
def dossier_polices_utilisateur():
if SYSTEME == "Windows":
return Path(os.environ["LOCALAPPDATA"]) / "Microsoft/Windows/Fonts"
if SYSTEME == "Darwin":
return Path.home() / "Library/Fonts"
return Path.home() / ".local/share/fonts"
def telecharger_polices(familles):
"""Installe les polices pour l'utilisateur courant (aucun droit administrateur nécessaire)."""
dossier = dossier_polices_utilisateur()
dossier.mkdir(parents=True, exist_ok=True)
for famille in familles:
for chemin in POLICES[famille]:
cible = dossier / Path(chemin).name
print(f" Téléchargement de {cible.name}...")
urllib.request.urlretrieve(f"{DEPOT_NOTO}/{chemin}", cible)
if SYSTEME == "Windows": # déclarer la police pour l'utilisateur (comme « Installer » dans l'Explorateur)
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", 0,
winreg.KEY_SET_VALUE) as cle:
winreg.SetValueEx(cle, f"{cible.stem} (TrueType)", 0, winreg.REG_SZ, str(cible))
if SYSTEME == "Linux" and shutil.which("fc-cache"):
subprocess.run(["fc-cache", "-f", str(dossier)], stdout=subprocess.DEVNULL)
def verifier_polices(quarto):
titre("2/4 Polices (Noto Sans et Noto Sans Mono)")
manquantes = polices_manquantes(quarto)
if not manquantes:
print(" Les polices sont installées.")
return
print(f" Polices manquantes : {', '.join(manquantes)}.")
print(" Sans elles, les documents seraient produits avec une police de remplacement.")
if not oui_non(" Les installer maintenant ?"):
return
gestionnaire = next((g for g in PAQUETS_POLICES if SYSTEME == "Linux" and shutil.which(g)), None)
try:
if gestionnaire:
print(" Votre mot de passe peut être demandé (installation système).")
subprocess.run(PAQUETS_POLICES[gestionnaire])
if not gestionnaire or polices_manquantes(quarto):
telecharger_polices(polices_manquantes(quarto))
except Exception as erreur:
print(f" L'installation des polices a échoué : {erreur}")
reste = polices_manquantes(quarto)
print(" Polices installées." if not reste else
f" Toujours introuvables : {', '.join(reste)} (les documents seront produits quand même).")
# --- Environnement Python des schémas ----------------------------------------------
def python_du_venv():
return VENV / ("Scripts/python.exe" if SYSTEME == "Windows" else "bin/python")
def preparer_venv():
titre("3/4 Environnement Python (schémas)")
empreinte = hashlib.sha256(EXIGENCES.read_bytes()).hexdigest()
temoin = VENV / ".empreinte-requirements"
if python_du_venv().exists() and temoin.exists() and temoin.read_text().strip() == empreinte:
print(" Déjà prêt.")
return True
try:
if not python_du_venv().exists():
print(" Création de l'environnement virtuel .venv ...")
subprocess.run([sys.executable, "-m", "venv", str(VENV)], check=True)
print(" Installation des bibliothèques (la première fois, cela peut prendre quelques minutes)...")
subprocess.run([str(python_du_venv()), "-m", "pip", "install", "--disable-pip-version-check",
"-q", "-r", str(EXIGENCES)], check=True)
except subprocess.CalledProcessError:
print(" Impossible de préparer l'environnement Python (connexion Internet ?).")
print(" Ce n'est pas bloquant : les documents peuvent être produits avec les schémas existants.")
return False
temoin.write_text(empreinte)
print(" Environnement prêt.")
return True
# --- Génération --------------------------------------------------------------------
def lister_supports():
supports = []
for qmd in sorted(MODULES.glob("*.qmd")):
m = re.search(r'^title:\s*"?(.*?)"?\s*$', qmd.read_text(encoding="utf-8"), re.M)
supports.append((qmd, m.group(1) if m else qmd.stem))
return supports
def ouvrir_dossier(dossier):
try:
if SYSTEME == "Windows":
os.startfile(dossier)
elif SYSTEME == "Darwin":
subprocess.run(["open", str(dossier)])
else:
subprocess.run(["xdg-open", str(dossier)])
except Exception:
pass
def generer(quarto, venv_ok):
titre("4/4 Génération des documents")
i_format = choisir("Quel format ?", [f[0] for f in FORMATS])
libelle, cible, extension = FORMATS[i_format]
supports = lister_supports()
i_portee = choisir("Quels supports ?", ["Tous les supports"] + [t for _, t in supports])
if venv_ok and oui_non("\nRégénérer aussi les schémas (utile seulement si vous les avez modifiés) ?",
defaut=False):
subprocess.run([str(python_du_venv()), str(RACINE / "schemas" / "generer.py")], cwd=RACINE)
debut = time.time()
if i_portee == 0:
print(f"\nProduction de tous les supports en {libelle} : patientez...\n")
commandes = [[quarto, "render", "--to", cible]]
else:
qmd = supports[i_portee - 1][0]
print(f"\nProduction de « {supports[i_portee - 1][1]} » en {libelle}...\n")
commandes = [[quarto, "render", str(qmd.relative_to(RACINE)), "--to", cible]]
for commande in commandes:
if subprocess.run(commande, cwd=RACINE).returncode != 0:
print("\n*** La génération a échoué. Le message d'erreur se trouve juste au-dessus. ***")
return False
produits = sorted(p for p in SORTIE.rglob(f"*{extension}") if p.stat().st_mtime >= debut - 1)
titre("Terminé")
for p in produits:
print(f" {p.relative_to(RACINE)}")
print(f"\n {len(produits)} document(s) dans le dossier : {SORTIE}")
ouvrir_dossier(SORTIE / "modules" if (SORTIE / "modules").is_dir() else SORTIE)
return True
def main():
print("Assistant de génération — Formation ESP32-S3 avec Arduino Framework")
if sys.version_info < (3, 9):
print("Python 3.9 ou plus récent est nécessaire.")
raise SystemExit(1)
quarto = verifier_quarto()
verifier_polices(quarto)
venv_ok = preparer_venv()
while True:
generer(quarto, venv_ok)
if not oui_non("\nGénérer d'autres documents ?", defaut=False):
break
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nInterrompu.")
except SystemExit:
raise
except Exception as erreur:
print(f"\nErreur inattendue : {erreur}")
raise SystemExit(1)