#!/usr/bin/env python3 """Le CV adalan en page HTML responsive, ou en PDF A4 portrait imprimé depuis cette page. Module de remplir_cv.py, qui l'appelle quand le fichier de sortie finit par .html ou .pdf : uv run scripts/remplir_cv.py cv.json --langue fr -o "CV_Camille_D - adalan - septembre 2026.html" uv run scripts/remplir_cv.py cv.json --langue fr -o "CV_Camille_D - adalan - septembre 2026.pdf" Le contenu est le même JSON que pour la trame PowerPoint. La page tient en un seul fichier : la feuille de style assets/web/cv.css, les polices assets/polices/*.woff2, le logo et les courbes de niveau y sont intégrés, sans appel à un fichier ou à un site extérieur. Le PDF sort de l'impression de cette page par un navigateur de la famille Chromium : Chrome, Chromium, Edge, Brave, ou le Chromium de Playwright. La variable d'environnement CHROME peut en donner le chemin. Aucune dépendance Python en dehors de la bibliothèque standard. """ import base64 import html import os import re import shutil import subprocess import sys import tempfile import time from pathlib import Path import remplir_cv as rc WEB = rc.ASSETS / "web" POLICES = rc.ASSETS / "polices" POLICES_WEB = [ # famille, graisse, fichier : les graisses et chasses de la charte, figées ("adalan Sans", 340, "adalan-sans-light.woff2"), ("adalan Sans", 420, "adalan-sans.woff2"), ("adalan Sans", 650, "adalan-sans-bold.woff2"), ("adalan Display", 800, "adalan-display.woff2"), ("adalan Heading", 700, "adalan-heading.woff2"), ("adalan Mono", 400, "adalan-mono.woff2"), ("adalan Mono", 500, "adalan-mono-medium.woff2"), ] SIGNATURE = "AI Driven Insight" ICONE_PDF = ('') # Navigateurs de la famille Chromium, dans l'ordre où on les cherche. COMMANDES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "microsoft-edge", "msedge", "brave-browser"] APPLICATIONS = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", ] PLAYWRIGHT = [ # sous le dossier des navigateurs de Playwright "chromium-*/chrome-mac*/*.app/Contents/MacOS/*", "chromium-*/chrome-linux*/chrome", "chromium-*/chrome-win*/chrome.exe", "chromium_headless_shell-*/chrome-*/chrome-headless-shell", "chromium_headless_shell-*/chrome-*/headless_shell", ] def e(texte): """Texte échappé, avec les espaces insécables de la typographie française : avant « : » et dans les guillemets, fines avant « ; ! ? ». L'anglais n'a pas d'espace à ces endroits : il ne change pas.""" texte = re.sub(r"[ \u00a0\u202f]+([:»])", "\u00a0\\1", texte) texte = re.sub(r"«[ \u00a0\u202f]+", "«\u00a0", texte) texte = re.sub(r"[ \u00a0\u202f]+([;!?])", "\u202f\\1", texte) return html.escape(texte, quote=False) # --------------------------------------------------------------------------------------------- # Morceaux de page def avec_libelle(texte): """« Libellé : détail » : le libellé en gras, comme dans la trame.""" libelle, reste = rc.libelle(texte) return (f"{e(libelle.strip())} " if libelle else "") + e(reste) def points(liste): return '" def entete_mission(titre): """Le poste, puis les années et le client : le découpage de la trame.""" poste, client, dates = rc.decouper_titre(titre) morceaux = ([f'{e(dates)}'] if dates else []) + ([e(client)] if client else []) meta = f'

{" · ".join(morceaux)}

' if morceaux else "" return f'

{e(poste)}

{meta}' def svg_en_ligne(chemin, classe, etiquette): """Le logo en SVG dans la page : ses classes deviennent des couleurs, son identifiant disparaît.""" svg = re.sub(r"<\?xml[^>]*\?>", "", chemin.read_text(encoding="utf-8")) couleurs = dict(re.findall(r"\.([\w-]+)\{fill:(#[0-9a-fA-F]{3,6});?\}", svg)) svg = re.sub(r".*?", "", svg, flags=re.S) svg = re.sub(r'class="([\w-]+)"', lambda m: f'fill="{couleurs.get(m.group(1), "currentColor")}"', svg) svg = re.sub(r'\sid="[^"]*"', "", svg) return svg.strip().replace("
{logo}{SIGNATURE}

{e(lib["cv"])} · {e(c["date"])}

{e(c["prenom"])}

{e(c["titre"])}

{e(c["domaines"])} — {e(c["anciennete"])}

""" parcours = f"""

{e(lib["parcours"])}

{e(c["accroche"])}

{e(c["profil"])}

{points(c["axes"])}
""" fil = "".join(f'
  • {entete_mission(m["titre"])}

    {e(m["resume"])}

  • ' for m in c["missions"]) missions = f"""

    {e(lib["missions"])}

      {fil}
    """ rubriques = [] for r in c["competences"]: blocs = "".join(f'

    {e(b["titre"])}

    {points(b["points"])}
    ' for b in r.get("blocs", [])) outils = (f'

    {e(lib["outils"].strip())} {e(r["outils"])}

    ' if r.get("outils") else "") rubriques.append(f'

    {e(r["rubrique"])}

    {blocs}' f'{points(r["points"]) if r.get("points") else ""}{outils}
    ') competences = f"""

    {e(lib["profil"])}

    {"".join(rubriques)}
    """ expertise = f"""

    {e(c["expertise"]["titre"])}

    {points(c["expertise"]["points"])}
    """ corps = f"""
    {parcours}{missions}
    {competences}{expertise}
    """ # Le zoom garde les colonnes de la trame : chaque slide du JSON donne deux colonnes, gauche puis # droite, dans l'ordre et l'équilibre choisis pour le PowerPoint. pages = [] for s in c["zoom"]: colonnes = [] for cote in ("gauche", "droite"): cartes = [] for b in s[cote]: if "rubrique" in b: cartes.append(f'

    {e(b["rubrique"])}

    {points(b["points"])}
    ') continue sous_blocs = "".join(f'

    {e(x["titre"])}

    {points(x["points"])}
    ' for x in b.get("blocs", [])) pied = "" if b.get("chiffres") or b.get("environnement"): chiffres = f"

    {avec_libelle(b['chiffres'])}

    " if b.get("chiffres") else "" env = (f'

    {e(lib["environnement"].strip())} {e(b["environnement"])}

    ' if b.get("environnement") else "") pied = f'
    {chiffres}{env}
    ' cartes.append(f"""
    {entete_mission(b["titre"])}

    {e(lib["mission"])} {e(b["mission"])}

    {f'
    {sous_blocs}
    ' if sous_blocs else ""} {points(b["points"]) if b.get("points") else ""} {pied}
    """) colonnes.append(f'
    {"".join(cartes)}
    ') pages.append(f'
    {"".join(colonnes)}
    ') zoom = f"""

    {e(c["prenom"])} · {e(c["titre"])}

    {e(lib["zoom"])}

    {"".join(pages)}
    """ return f'
    {bandeau}{corps}{zoom}\n
    ' def a_propos(langue, logo): """La slide « À propos » de la trame, en section : mêmes textes, repris de remplir_cv.py.""" t = {nom: ["".join(runs) for runs in ps] for nom, ps in rc.TEXTES_A_PROPOS[langue].items()} nom_adalan, suite = rc.TEXTES_A_PROPOS[langue]["apropos:texte"][0] modele = "".join(f'
  • {i}

    {e(t[f"apropos:l{i}"][0])}

    ' f'

    {e(t[f"apropos:d{i}"][0])}

  • ' for i in (1, 2, 3)) equipe = "".join(f'
  • {e(t[f"apropos:e{i}"][0])}

    {e(t[f"apropos:e{i}"][1])}

  • ' for i in (1, 2, 3)) return f"""

    {e(t["apropos:surtitre"][0])}

    {logo}

    {SIGNATURE}

    {e(nom_adalan)}{e(suite)}

    {e(t["apropos:modele"][0])}

      {modele}

    {e(t["apropos:equipe"][0])}

      {equipe}
    """ def feuille_de_style(pied): """cv.css, avec ses polices et ses courbes de niveau dans la page, et le pied des pages imprimées.""" css = (WEB / "cv.css").read_text(encoding="utf-8") topo = base64.b64encode((WEB / "topo-sur-encre.svg").read_bytes()).decode() if 'url("topo-sur-encre.svg")' not in css: raise rc.ErreurCV("assets/web/cv.css n'appelle plus url(\"topo-sur-encre.svg\") : mettre à jour scripts/cv_web.py.") css = css.replace('url("topo-sur-encre.svg")', f'url("data:image/svg+xml;base64,{topo}")') polices = [] for famille, poids, fichier in POLICES_WEB: chemin = POLICES / fichier if not chemin.is_file(): raise rc.ErreurCV(f"Police introuvable : {chemin}. La préparer avec scripts/preparer_polices.py.") donnees = base64.b64encode(chemin.read_bytes()).decode() polices.append(f'@font-face {{ font-family: "{famille}"; font-weight: {poids}; font-style: normal; ' f'font-display: block; src: url("data:font/woff2;base64,{donnees}") format("woff2"); }}') pied = pied.replace("\\", "\\\\").replace('"', '\\"') return "\n".join(polices) + "\n" + css + f'\n@page {{ @bottom-left {{ content: "{pied}"; }} }}\n' def document(contenu, langues, langue): logo = svg_en_ligne(WEB / "logo-label-white-orange.svg", "logo", "adalan") sections = [] for lg in langues: sections.append(cv(contenu[lg], lg, logo)) if lg == ("en" if langue == "en" else "fr"): sections.append(a_propos(lg, logo)) c = contenu[langues[0]] titre = f"CV {c['prenom']}, {c['titre']} · adalan" return f""" {e(titre)} {"".join(sections)} """ # --------------------------------------------------------------------------------------------- # PDF def navigateur(): """Un navigateur de la famille Chromium, ou None.""" if os.environ.get("CHROME"): return os.environ["CHROME"] for commande in COMMANDES: chemin = shutil.which(commande) if chemin: return chemin for chemin in APPLICATIONS: if Path(chemin).is_file(): return chemin dossiers = [os.environ.get("PLAYWRIGHT_BROWSERS_PATH"), "~/Library/Caches/ms-playwright", "~/.cache/ms-playwright", os.path.join(os.environ.get("LOCALAPPDATA", ""), "ms-playwright")] for dossier in filter(None, dossiers): base = Path(dossier).expanduser() for motif in PLAYWRIGHT: for chemin in sorted(base.glob(motif), reverse=True): if chemin.is_file() and os.access(chemin, os.X_OK): return str(chemin) return None def imprimer_pdf(page, sortie): nav = navigateur() if not nav: raise rc.ErreurCV( "Aucun navigateur Chromium trouvé pour écrire le PDF. Installer Google Chrome ou Chromium, " "ou le Chromium de Playwright (uvx playwright install chromium), ou donner son chemin dans la " "variable CHROME. À défaut : produire la page .html, l'ouvrir et l'imprimer en PDF (A4, arrière-plans).") with tempfile.TemporaryDirectory() as dossier: fichier = Path(dossier) / "cv.html" fichier.write_text(page, encoding="utf-8") # Profil jetable, sans trousseau ni gestionnaire de mots de passe : rien ne demande d'autorisation. commande = [nav, "--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-extensions", "--use-mock-keychain", "--password-store=basic", f"--user-data-dir={Path(dossier) / 'profil'}", "--no-pdf-header-footer", "--print-to-pdf-no-header", "--run-all-compositor-stages-before-draw", "--virtual-time-budget=10000", f"--print-to-pdf={sortie}", fichier.as_uri()] if hasattr(os, "geteuid") and os.geteuid() == 0: # conteneur lancé en root commande.insert(1, "--no-sandbox") if sortie.exists(): sortie.unlink() journal = Path(dossier) / "journal.txt" try: with open(journal, "w") as j: # un fichier plutôt qu'un tube : il ne se remplit jamais proc = subprocess.Popen(commande, stdout=j, stderr=subprocess.STDOUT) except OSError as ex: raise rc.ErreurCV(f"Le navigateur n'a pas pu démarrer ({nav}) : {ex}") # Certains navigateurs restent ouverts après l'impression : on les ferme dès que le PDF est complet. taille, debut = -1, time.monotonic() while proc.poll() is None and time.monotonic() - debut < 180: time.sleep(0.5) if sortie.is_file() and sortie.stat().st_size > 0 and sortie.read_bytes().rstrip().endswith(b"%%EOF"): if sortie.stat().st_size == taille: break taille = sortie.stat().st_size if proc.poll() is None: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() messages = journal.read_text(errors="replace").strip() if not sortie.is_file() or not sortie.read_bytes().rstrip().endswith(b"%%EOF"): raise rc.ErreurCV(f"Le navigateur n'a pas écrit le PDF ({nav}) : {messages[-600:] or 'aucun message'}") def pages_pdf(chemin): nombres = [int(n) for n in re.findall(rb"/Type\s*/Pages\b[^>]*?/Count\s+(\d+)", chemin.read_bytes(), re.S)] return max(nombres) if nombres else None # --------------------------------------------------------------------------------------------- def construire(contenu, langue, sortie): """Écrit le CV en .html ou en .pdf ; renvoie les avertissements et le nombre de pages du PDF.""" langues, avis = rc.verifier(contenu, langue) page = document(contenu, langues, langue) sortie = Path(sortie).resolve() sortie.parent.mkdir(parents=True, exist_ok=True) if sortie.suffix.lower() == ".html": sortie.write_text(page, encoding="utf-8") return avis, None imprimer_pdf(page, sortie) return avis, pages_pdf(sortie) if __name__ == "__main__": sys.exit("Lancer scripts/remplir_cv.py avec un fichier de sortie en .html ou en .pdf.")