#!/usr/bin/env python3 # /// script # requires-python = ">=3.9" # dependencies = ["python-pptx>=1.0"] # /// """Écrit le CV adalan à partir d'un fichier JSON : PowerPoint, PDF A4 ou page HTML, selon l'extension du fichier de sortie. uv run scripts/remplir_cv.py cv.json --langue fr -o "CV_Camille_D - adalan - septembre 2026.pptx" uv run scripts/remplir_cv.py cv.json --langue fr -o "CV_Camille_D - adalan - septembre 2026.pdf" uv run scripts/remplir_cv.py cv.json --langue fr -o "CV_Camille_D - adalan - septembre 2026.html" --langue fr couverture, synthèse, zoom, « À propos » --langue en les mêmes parties en anglais, « About us » en dernier --langue bilingue la version française, « À propos », puis la version anglaise Le JSON a une clé "fr", une clé "en", ou les deux. Son format est décrit dans references/contenu.md, et assets/exemple.json en donne un exemple complet. Le .pdf et le .html sont écrits par cv_web.py, à partir du même contenu. Pour le .pptx, le script ne rédige rien. Il place le texte dans la trame sans toucher à sa mise en forme : chaque paragraphe copie un paragraphe modèle de la trame, qu'il retrouve parce que la trame est assets/exemple.json mis en page. Il ajoute autant de slides de zoom qu'il en faut, écrit la slide « À propos » dans la bonne langue, numérote les pages, retire la vignette et les métadonnées de la trame, puis signale ce qui demande une relecture : ordre des missions, titres différents entre la synthèse et le zoom, versions française et anglaise qui ne se correspondent pas, colonne probablement trop pleine, texte d'exemple resté en place. Dépendance : python-pptx, pour le .pptx seulement. Avec uv, elle s'installe à la volée ; sinon : pip install python-pptx. Le PDF demande un navigateur de la famille Chromium (voir cv_web.py). """ import argparse import copy import json import math import re import sys from datetime import datetime, timezone from pathlib import Path try: from pptx import Presentation except ImportError: # inutile pour le PDF et le HTML Presentation = None A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" ASSETS = Path(__file__).resolve().parent.parent / "assets" TRAME = ASSETS / "trame-cv.pptx" EXEMPLE = ASSETS / "exemple.json" # Place des slides dans la trame, à partir de 0 : la version française, « À propos », la version anglaise. SLIDES = { "fr": {"couverture": 0, "synthese": 1, "zoom": 2}, "en": {"couverture": 4, "synthese": 5, "zoom": 6}, } A_PROPOS = 3 NOMBRE_DE_SLIDES = 7 # Zones de texte remplies par le script, repérées par le nom de leur forme (volet Sélection de PowerPoint). # Les paragraphes d'une zone copient les modèles pris dans ses formes de la trame. ZONES = { "couverture": ("couverture", ["cv:couverture"]), "initiale": ("couverture", ["cv:initiale"]), "entete": ("synthese", ["cv:entete"]), "parcours": ("synthese", ["cv:parcours"]), "expertise": ("synthese", ["cv:expertise"]), "competences": ("synthese", ["cv:competences"]), "missions": ("synthese", ["cv:missions"]), "zoom": ("zoom", ["cv:gauche", "cv:droite"]), } PAGE = "cv:page" # pagination « 02 / 05 », absente de la couverture LANGUE_XML = {"fr": "fr-FR", "en": "en-GB"} # Textes fixes du CV, communs à la trame (dessiner_trame.py), au PDF et au HTML (cv_web.py). LIBELLES = { "fr": {"cv": "Curriculum vitae", "mission": "Mission :", "environnement": "Environnement : ", "outils": "Outils : ", "parcours": "Parcours professionnel", "profil": "Profil", "missions": "Synthèse des projets & des missions", "synthese": "Synthèse", "zoom": "Zoom sur des projets & des missions", "imprimer": "Enregistrer en PDF"}, "en": {"cv": "Curriculum vitae", "mission": "Assignment:", "environnement": "Environment: ", "outils": "Tools: ", "parcours": "Career", "profil": "Profile", "missions": "Project & assignment summary", "synthese": "Summary", "zoom": "Focus on projects & assignments", "imprimer": "Save as PDF"}, } # Slide « À propos », d'après le skill presentation-adalan. Par forme : ses paragraphes, et dans # chacun le texte de ses runs successifs, dont ils gardent la mise en forme (« adalan » en gras). TEXTES_A_PROPOS = { "fr": { "apropos:surtitre": [["À propos de nous"]], "apropos:texte": [["adalan", " est né à Paris le 1er octobre 2023, en hommage à Ada Lovelace et Alan Turing. " "Il réunit des consultant(e)s seniors formé(e)s et outillé(e)s en IA, et une Factory qui conçoit, " "développe et déploie des applications et des agents IA. Nos valeurs : expertise, bienveillance, " "intelligence collective, engagement, impact social."]], "apropos:modele": [["Notre modèle"]], "apropos:l1": [["ambition"]], "apropos:d1": [["Grandir ensemble, en partageant nos outils, nos savoir-faire et nos valeurs"]], "apropos:l2": [["activités"]], "apropos:d2": [["Consulting et Factory : des experts augmentés, des outils qui tournent"]], "apropos:l3": [["piliers"]], "apropos:d3": [["Une expertise projet, une expertise métier, et l'IA"]], "apropos:equipe": [["Notre équipe"]], "apropos:e1": [["Développement commercial et réseau"], ["Nicolas"]], "apropos:e2": [["Factory"], ["Etienne, Olivier"]], "apropos:e3": [["Produits internes et verticales"], ["Julien"]], "cv:pied": [["À propos de nous"]], }, "en": { "apropos:surtitre": [["About us"]], "apropos:texte": [["adalan", " was founded on 1 October 2023 in Paris, in honour of Ada Lovelace and Alan Turing. " "It brings together senior consultants trained and equipped in AI, and a Factory that designs, " "builds and deploys applications and agents. Our values: expertise, kindness, collective " "intelligence, commitment, social impact."]], "apropos:modele": [["Our model"]], "apropos:l1": [["ambition"]], "apropos:d1": [["Growing together, by sharing our tools, our know-how and our values"]], "apropos:l2": [["activities"]], "apropos:d2": [["Consulting and Factory: augmented experts, tools that run"]], "apropos:l3": [["pillars"]], "apropos:d3": [["Project expertise, business expertise, and AI"]], "apropos:equipe": [["Our team"]], "apropos:e1": [["Business development and network"], ["Nicolas"]], "apropos:e2": [["Factory"], ["Etienne, Olivier"]], "apropos:e3": [["Internal products and verticals"], ["Julien"]], "cv:pied": [["About us"]], }, } # Le texte de l'exemple (assets/exemple.json) nomme des sociétés « Exemple » : le retrouver dans un CV # veut dire qu'un morceau de l'exemple est resté. MARQUE_EXEMPLE = "Exemple" # Relations retirées du CV : une vignette montrerait la couverture de la trame dans l'aperçu du fichier, # les autres portent des noms, des adresses ou des identifiants internes. RELATIONS_RETIREES = ( "/thumbnail", "/custom-properties", "/classificationlabels", "/webextensiontaskpanes", "/authors", "/commentAuthors", "/customXml", "/presentationmetadata", ) # Estimation de la hauteur du texte, pour signaler une zone trop pleine quand aucun rendu n'est possible : # largeur moyenne d'un caractère, espaces compris, en fraction de la taille, mesurée sur l'exemple. CHASSE_MOYENNE = { "adalan Sans": 0.47, "adalan Sans Light": 0.465, "adalan Display": 0.53, "adalan Heading": 0.51, "adalan Mono": 0.62, "adalan Mono Medium": 0.62, } CHASSE_GRAS = 1.04 # adalan Sans en gras PERTE_COUPURE = 1.06 # place perdue en fin de ligne, quand un mot passe à la ligne suivante EMU_PAR_CM = 360000 class ErreurCV(Exception): pass # --------------------------------------------------------------------------------------------- # Contenu : vérification du JSON def verifier_contenu(c, langue): """Renvoie la liste des erreurs de structure du contenu d'une langue.""" erreurs = [] def texte(obj, cle, chemin): valeur = obj.get(cle) if isinstance(obj, dict) else None if not isinstance(valeur, str) or not valeur.strip(): erreurs.append(f"{chemin}.{cle} : texte attendu") def textes(obj, cle, chemin, vide_permis=False): valeur = obj.get(cle) if isinstance(obj, dict) else None if not isinstance(valeur, list) or (not valeur and not vide_permis) \ or not all(isinstance(x, str) and x.strip() for x in valeur): erreurs.append(f"{chemin}.{cle} : liste de textes attendue") def liste(obj, cle, chemin): valeur = obj.get(cle) if isinstance(obj, dict) else None if not isinstance(valeur, list) or not valeur: erreurs.append(f"{chemin}.{cle} : liste non vide attendue") return [] return valeur def blocs(obj, chemin): for i, b in enumerate(obj.get("blocs", [])): texte(b, "titre", f"{chemin}.blocs[{i}]") textes(b, "points", f"{chemin}.blocs[{i}]") p = langue if not isinstance(c, dict): return [f"{p} : objet attendu"] for cle in ("prenom", "titre", "domaines", "anciennete", "accroche", "date", "profil"): texte(c, cle, p) textes(c, "axes", p) if isinstance(c.get("expertise"), dict): texte(c["expertise"], "titre", f"{p}.expertise") textes(c["expertise"], "points", f"{p}.expertise") else: erreurs.append(f"{p}.expertise : objet attendu, avec « titre » et « points »") for i, r in enumerate(liste(c, "competences", p)): chemin = f"{p}.competences[{i}]" texte(r, "rubrique", chemin) if not any(k in r for k in ("blocs", "points", "outils")): erreurs.append(f"{chemin} : « blocs », « points » ou « outils » attendu") blocs(r, chemin) if "points" in r: textes(r, "points", chemin) if "outils" in r: texte(r, "outils", chemin) for i, m in enumerate(liste(c, "missions", p)): texte(m, "titre", f"{p}.missions[{i}]") texte(m, "resume", f"{p}.missions[{i}]") for i, s in enumerate(liste(c, "zoom", p)): for cote in ("gauche", "droite"): for j, b in enumerate(liste(s, cote, f"{p}.zoom[{i}]")): chemin = f"{p}.zoom[{i}].{cote}[{j}]" if isinstance(b, dict) and "rubrique" in b: texte(b, "rubrique", chemin) textes(b, "points", chemin) else: for cle in ("titre", "mission"): texte(b, cle, chemin) if not isinstance(b, dict) or not ("blocs" in b or "points" in b): erreurs.append(f"{chemin} : « blocs » ou « points » attendu") continue blocs(b, chemin) if "points" in b: textes(b, "points", chemin) for cle in ("chiffres", "environnement"): if cle in b: texte(b, cle, chemin) return erreurs def annees(titre): """(fin, début) d'un titre « … (2018-2022) » ; une mission en cours, « (2023-aujourd'hui) », finit en 9999.""" m = re.search(r"\(([^()]*)\)\s*$", titre) trouvees = [int(a) for a in re.findall(r"\d{4}", m.group(1))] if m else [] if not trouvees: return None debut = trouvees[0] if len(trouvees) > 1: return trouvees[1], debut en_cours = re.search(r"[-–]|depuis|since", m.group(1), re.IGNORECASE) return (9999 if en_cours else debut), debut def controles_contenu(c, langue): """Ce qui mérite une relecture dans le contenu d'une langue.""" avis = [] if " " in c["prenom"].strip(): avis.append(f"{langue} : « {c['prenom']} » semble contenir un nom de famille ; le CV ne donne que le prénom.") dates = [(m["titre"], annees(m["titre"])) for m in c["missions"]] sans_date = [t for t, a in dates if a is None] if sans_date: avis.append(f"{langue} : pas d'années entre parenthèses en fin de titre pour : " + " ; ".join(sans_date)) datees = [(t, a) for t, a in dates if a is not None] for (t1, a1), (t2, a2) in zip(datees, datees[1:]): if a2 > a1: avis.append(f"{langue} : missions hors de l'ordre antichronologique : « {t2} » vient après « {t1} ».") titres = {m["titre"] for m in c["missions"]} for s in c["zoom"]: for b in s["gauche"] + s["droite"]: if "titre" in b and b["titre"] not in titres: avis.append(f"{langue} : le zoom cite « {b['titre']} », absent de la synthèse ; le titre et les dates doivent y être identiques.") if MARQUE_EXEMPLE in json.dumps(c, ensure_ascii=False): avis.append(f"{langue} : le contenu contient encore « {MARQUE_EXEMPLE} », un reste de assets/exemple.json.") return avis def controles_alignement(fr, en): """Les versions française et anglaise doivent avoir la même structure.""" def forme(c): return ( len(c["axes"]), len(c["expertise"]["points"]), len(c["competences"]), len(c["missions"]), [(len(s["gauche"]), len(s["droite"])) for s in c["zoom"]], [annees(m["titre"]) for m in c["missions"]], ) if forme(fr) != forme(en): return ["fr et en ne se correspondent pas : même nombre d'axes, de points d'expertise, de rubriques, " "de missions et de blocs de zoom attendu, avec les mêmes années."] return [] # --------------------------------------------------------------------------------------------- # Suites de paragraphes : (rôle, [texte de chaque run]) dans l'ordre où ils s'affichent. # Un run vide disparaît du CV ; dans la trame, chaque paragraphe garde tous ses runs. def libelle(texte): """« Libellé : détail » donne [« Libellé : », « détail »], le libellé en gras ; sinon ["", texte].""" m = re.match(r"^(\S[^:]{0,69}?\s?:)\s(.*)$", texte, re.S) return [m.group(1) + " ", m.group(2)] if m else ["", texte] def decouper_titre(titre): """« Poste | Client — Ville (2022-2026) » donne (poste, « Client — Ville », « 2022-2026 »).""" dates = "" m = re.search(r"\s*\(([^()]*\d{4}[^()]*)\)\s*$", titre) if m: dates, titre = m.group(1).strip(), titre[:m.start()] poste, _, client = titre.partition("|") return poste.strip(), client.strip(), dates def suite_titre(titre): """Le poste, puis la ligne des dates et du client.""" poste, client, dates = decouper_titre(titre) suite = [("poste", [poste])] if dates or client: suite.append(("contexte", [dates, (" · " if dates and client else "") + client])) return suite def suite_couverture(c, lib): return [("surtitre", [f"{lib['cv']} · {c['date']}"]), ("prenom", [c["prenom"]]), ("titre", [c["titre"]]), ("domaines", [f"{c['domaines']} — {c['anciennete']}"])] def suite_initiale(c): return [("initiale", [c["prenom"].strip()[:1].upper()])] def suite_entete(c): return [("domaines", [c["domaines"]]), ("nom", [f"{c['prenom']}, ", c["titre"]]), ("accroche", [c["accroche"]])] def suite_parcours(c): return [("profil", [c["profil"]])] + [("axe", libelle(a)) for a in c["axes"]] def suite_expertise(c): return [("titre", [c["expertise"]["titre"]])] + [("point", libelle(x)) for x in c["expertise"]["points"]] def suite_competences(c, lib): suite = [] for r in c["competences"]: suite.append(("rubrique", [r["rubrique"]])) for b in r.get("blocs", []): suite.append(("bloc", [b["titre"]])) suite += [("point", libelle(x)) for x in b["points"]] suite += [("point", libelle(x)) for x in r.get("points", [])] if r.get("outils"): suite.append(("outils", [lib["outils"], r["outils"]])) return suite def suite_missions(c): suite = [] for m in c["missions"]: suite += suite_titre(m["titre"]) + [("resume", [m["resume"]])] return suite def suite_colonne(blocs, lib): suite = [] for b in blocs: if "rubrique" in b: suite.append(("rubrique", [b["rubrique"]])) suite += [("point", libelle(x)) for x in b["points"]] continue suite += suite_titre(b["titre"]) suite.append(("texte", [lib["mission"] + " ", b["mission"]])) for bloc in b.get("blocs", []): suite.append(("bloc", [bloc["titre"]])) suite += [("point", libelle(x)) for x in bloc["points"]] suite += [("point", libelle(x)) for x in b.get("points", [])] if b.get("chiffres"): suite.append(("texte", libelle(b["chiffres"]))) if b.get("environnement"): suite.append(("environnement", [lib["environnement"], b["environnement"]])) return suite def suites_slide(c, lib, zoom): """Les suites de chaque forme d'une langue, la slide de zoom donnée par zoom ({"gauche", "droite"}).""" return { "cv:couverture": suite_couverture(c, lib), "cv:initiale": suite_initiale(c), "cv:entete": suite_entete(c), "cv:parcours": suite_parcours(c), "cv:expertise": suite_expertise(c), "cv:competences": suite_competences(c, lib), "cv:missions": suite_missions(c), "cv:gauche": suite_colonne(zoom["gauche"], lib), "cv:droite": suite_colonne(zoom["droite"], lib), } # --------------------------------------------------------------------------------------------- # Trame : formes, prototypes, remplissage def toutes_les_formes(formes): for f in formes: yield f if f.shape_type == 6: # groupe yield from toutes_les_formes(f.shapes) def forme(slide, nom, obligatoire=True): for f in toutes_les_formes(slide.shapes): if f.name == nom: return f if obligatoire: raise ErreurCV(f"La trame ne correspond plus au script : forme « {nom} » introuvable sur une slide.") return None def paragraphes(tf): return tf._txBody.findall(A + "p") def texte_de(p): return "".join(t.text or "" for t in p.iter(A + "t")) def espace_avant(p, garder): pPr = p.find(A + "pPr") el = pPr.find(A + "spcBef") if pPr is not None else None if el is not None and not garder: pPr.remove(el) def prototypes(slides_langue, exemple, lib): """Pour chaque zone, le paragraphe modèle de chaque rôle, pris dans la trame. La trame est l'exemple mis en page : chaque paragraphe de ses formes correspond, dans l'ordre, à la suite que le script tire de assets/exemple.json, et garde tous ses runs. Le premier paragraphe d'une forme n'a pas d'espace avant : on prend de préférence une autre occurrence du rôle comme modèle.""" if len(exemple["zoom"]) != 1: raise ErreurCV("assets/exemple.json doit tenir sur une seule slide de zoom, comme la trame.") attendues = suites_slide(exemple, lib, exemple["zoom"][0]) protos = {} for zone, (cle, noms) in ZONES.items(): candidats = {} for nom in noms: ps = paragraphes(forme(slides_langue[cle], nom).text_frame) suite = attendues[nom] if len(ps) != len(suite): raise ErreurCV(f"La trame ne correspond plus à assets/exemple.json : la forme « {nom} » a " f"{len(ps)} paragraphes, {len(suite)} attendus. Relancer scripts/dessiner_trame.py.") for i, (p, (role, runs)) in enumerate(zip(ps, suite)): if texte_de(p) != "".join(runs) or len(p.findall(A + "r")) != len(runs): raise ErreurCV(f"La trame ne correspond plus à assets/exemple.json : forme « {nom} », " f"paragraphe {i} (« {texte_de(p)[:40]} »), {len(runs)} run(s) « {''.join(runs)[:40]} » " f"attendu(s). Relancer scripts/dessiner_trame.py.") if role not in candidats or (candidats[role][0] == 0 and i > 0): candidats[role] = (i, p) protos[zone] = {role: copy.deepcopy(p) for role, (_, p) in candidats.items()} return protos def remplir(tf, suite, protos, zone): """Remplace les paragraphes de tf par la suite [(rôle, runs)], chacun copié de son prototype.""" corps = tf._txBody for p in paragraphes(tf): corps.remove(p) for i, (role, runs) in enumerate(suite): if role not in protos: raise ErreurCV(f"La trame n'a pas de paragraphe modèle « {role} » pour la zone « {zone} » : " "assets/exemple.json doit en contenir un. Relancer scripts/dessiner_trame.py.") p = copy.deepcopy(protos[role]) for br in p.findall(A + "br"): p.remove(br) for r, texte in zip(p.findall(A + "r"), runs): if texte: r.find(A + "t").text = texte else: p.remove(r) espace_avant(p, garder=i > 0) corps.append(p) def ecrire_runs(f, paragraphes_runs): """Un texte par run existant, les runs en trop retirés : slide « À propos », pagination.""" ps = paragraphes(f.text_frame) if len(ps) != len(paragraphes_runs): raise ErreurCV(f"La trame ne correspond plus au script : forme « {f.name} », " f"{len(paragraphes_runs)} paragraphes attendus, {len(ps)} trouvés.") for p, textes in zip(ps, paragraphes_runs): runs = p.findall(A + "r") if len(runs) < len(textes): raise ErreurCV(f"La trame ne correspond plus au script : forme « {f.name} », " f"{len(textes)} runs attendus, {len(runs)} trouvés.") for r, t in zip(runs, textes): r.find(A + "t").text = t for r in runs[len(textes):]: p.remove(r) # --------------------------------------------------------------------------------------------- # Slides : supprimer, déplacer, dupliquer def sld_id(prs, slide): for el in prs.slides._sldIdLst: if int(el.get("id")) == slide.slide_id: return el raise ErreurCV("Slide introuvable dans la présentation.") def supprimer(prs, slide): el = sld_id(prs, slide) prs.part.drop_rel(el.rId) prs.slides._sldIdLst.remove(el) def deplacer_apres(prs, slide, reference): """Place slide juste après reference ; sans reference, en dernier.""" lst = prs.slides._sldIdLst el = sld_id(prs, slide) lst.remove(el) if reference is None: lst.append(el) else: sld_id(prs, reference).addnext(el) def dupliquer(prs, source): """Copie une slide (formes, images, fond) juste après elle, sans ses notes.""" nouvelle = prs.slides.add_slide(source.slide_layout) arbre = nouvelle.shapes._spTree for f in list(nouvelle.shapes): arbre.remove(f._element) for el in source.shapes._spTree.iterchildren(): if el.tag.split("}")[1] not in ("nvGrpSpPr", "grpSpPr"): arbre.append(copy.deepcopy(el)) fond = source._element.cSld.bg if fond is not None: nouvelle._element.cSld.insert(0, copy.deepcopy(fond)) liens = {} for rId, rel in source.part.rels.items(): if rel.reltype.endswith(("/notesSlide", "/slideLayout")): continue if rel.is_external: liens[rId] = nouvelle.part.relate_to(rel.target_ref, rel.reltype, is_external=True) else: liens[rId] = nouvelle.part.relate_to(rel.target_part, rel.reltype) for el in nouvelle._element.iter(): for attr, valeur in el.attrib.items(): if attr.startswith(R) and valeur in liens: el.set(attr, liens[valeur]) deplacer_apres(prs, nouvelle, source) return nouvelle # --------------------------------------------------------------------------------------------- # Estimation du remplissage des zones def points(p, balise): """Espacement en points d'un paragraphe (spcBef, spcAft) ; 0 s'il n'y en a pas.""" pPr = p.find(A + "pPr") el = pPr.find(A + balise) if pPr is not None else None pts = el.find(A + "spcPts") if el is not None else None return int(pts.get("val")) / 100 if pts is not None else 0 def hauteur_cm(suite, protos, largeur_cm): """Hauteur approximative d'une suite de paragraphes dans une largeur donnée.""" total_pt = 0 for i, (role, runs) in enumerate(suite): p = protos[role] pPr = p.find(A + "pPr") retrait_cm = int(pPr.get("marL", 0)) / EMU_PAR_CM if pPr is not None else 0 largeur_pt = 0 taille = 10 for r, texte in zip(p.findall(A + "r"), runs): rPr = r.find(A + "rPr") taille = int(rPr.get("sz", 1000)) / 100 police = rPr.find(A + "latin") chasse = CHASSE_MOYENNE.get(police.get("typeface") if police is not None else "", 0.55) if rPr.get("b") == "1": chasse *= CHASSE_GRAS largeur_pt += len(texte) * (chasse * taille + int(rPr.get("spc", 0)) / 100) lignes = max(1, math.ceil(largeur_pt * PERTE_COUPURE / ((largeur_cm - retrait_cm) / 2.54 * 72))) interligne = points(p, "lnSpc") or taille * 1.2 total_pt += (points(p, "spcBef") if i else 0) + points(p, "spcAft") + lignes * interligne return total_pt * 2.54 / 72 def place_cm(f): """Largeur et hauteur utiles d'une forme de texte, marges intérieures déduites.""" corps = f.text_frame._txBody.find(A + "bodyPr") marge = {k: int(corps.get(k, d)) for k, d in (("lIns", 91440), ("rIns", 91440), ("tIns", 45720), ("bIns", 45720))} return ((f.width - marge["lIns"] - marge["rIns"]) / EMU_PAR_CM, (f.height - marge["tIns"] - marge["bIns"]) / EMU_PAR_CM) def controler_place(f, suite, protos, libelle_zone, conseil): largeur, hauteur = place_cm(f) besoin = hauteur_cm(suite, protos, largeur) if besoin > hauteur: return [f"{libelle_zone} probablement trop pleine ({besoin / hauteur:.0%} de la place) : {conseil}."] return [] # --------------------------------------------------------------------------------------------- # Construction def remplir_langue(prs, slides_langue, c, langue, protos): """Remplit couverture, synthèse et zoom d'une langue ; ajoute les slides de zoom nécessaires. Renvoie les avertissements de remplissage.""" lib = LIBELLES[langue] avis = [] couv, synth, zoom = slides_langue["couverture"], slides_langue["synthese"], slides_langue["zoom"] zooms = [zoom] for _ in c["zoom"][1:]: zooms.append(dupliquer(prs, zooms[-1])) suites = suites_slide(c, lib, c["zoom"][0]) for nom, zone in (("cv:couverture", "couverture"), ("cv:initiale", "initiale")): remplir(forme(couv, nom).text_frame, suites[nom], protos[zone], zone) for s in [synth] + zooms: remplir(forme(s, "cv:entete").text_frame, suites["cv:entete"], protos["entete"], "entete") zones = (("cv:parcours", "parcours", "colonne « parcours »", "raccourcir le profil ou les axes"), ("cv:expertise", "expertise", f"encart « {c['expertise']['titre']} »", "raccourcir les points"), ("cv:competences", "competences", "colonne « profil »", "retirer une ligne"), ("cv:missions", "missions", "colonne des missions", "raccourcir les résumés des missions anciennes")) for nom, zone, libelle_zone, conseil in zones: f = forme(synth, nom) remplir(f.text_frame, suites[nom], protos[zone], zone) avis += controler_place(f, suites[nom], protos[zone], f"{langue} : synthèse, {libelle_zone}", conseil) for n, (z, contenu_slide) in enumerate(zip(zooms, c["zoom"]), 1): for nom, cote in (("cv:gauche", "gauche"), ("cv:droite", "droite")): suite = suite_colonne(contenu_slide[cote], lib) f = forme(z, nom) remplir(f.text_frame, suite, protos["zoom"], "zoom") avis += controler_place(f, suite, protos["zoom"], f"{langue} : zoom {n}, colonne de {cote}", "raccourcir ou passer un bloc sur une autre slide") return avis def ecrire_a_propos(slide, langue): for nom, paragraphes_runs in TEXTES_A_PROPOS[langue].items(): ecrire_runs(forme(slide, nom), paragraphes_runs) for el in slide._element.iter(A + "rPr", A + "endParaRPr"): # langue de la correction orthographique el.set("lang", LANGUE_XML[langue]) def paginer(prs): """« 02 / 05 » sur chaque slide qui a une pagination ; la couverture n'en a pas.""" n = len(prs.slides) for i, s in enumerate(prs.slides, 1): f = forme(s, PAGE, obligatoire=False) if f is not None: ecrire_runs(f, [[f"{i:02d} / {n:02d}"]]) def proprietes(prs, prenom): cp = prs.core_properties cp.title = f"CV {prenom} - adalan" cp.author = "adalan" cp.last_modified_by = "adalan" cp.subject = cp.keywords = cp.comments = cp.category = "" cp.revision = 1 cp.modified = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0) for el in list(cp._element): if el.tag.endswith("}lastPrinted"): cp._element.remove(el) def nettoyer(prs): """Retire ce qu'un enregistrement dans PowerPoint, SharePoint ou Google Slides a pu laisser dans la trame : vignette de la première slide, auteurs de commentaires, étiquettes, extensions, métadonnées.""" for porteur, xml in ((prs.part.package, None), (prs.part, prs.part._element)): for rId, rel in list(porteur._rels.items()): if not rel.reltype.endswith(RELATIONS_RETIREES): continue porteur._rels.pop(rId) if xml is None: continue for el in [e for e in xml.iter() if e.get(R + "id") == rId]: parent = el.getparent() parent.remove(el) if parent.tag.endswith("}ext") and len(parent) == 0: parent.getparent().remove(parent) def lire_exemple(): try: with open(EXEMPLE, encoding="utf-8") as f: return json.load(f) except (OSError, json.JSONDecodeError) as e: raise ErreurCV(f"assets/exemple.json illisible ({e}) : le script en a besoin pour lire la trame.") def verifier(contenu, langue): """Les langues à produire et les avertissements sur le contenu ; ErreurCV si le JSON est mal formé.""" langues = {"fr": ["fr"], "en": ["en"], "bilingue": ["fr", "en"]}[langue] erreurs = [] for lg in langues: if lg not in contenu: erreurs.append(f"Clé « {lg} » absente du JSON : elle est nécessaire pour --langue {langue}.") else: erreurs += verifier_contenu(contenu[lg], lg) if erreurs: raise ErreurCV("Le contenu n'a pas la forme attendue (voir references/contenu.md) :\n - " + "\n - ".join(erreurs)) avis = [] for lg in langues: avis += controles_contenu(contenu[lg], lg) if len(langues) == 2: avis += controles_alignement(contenu["fr"], contenu["en"]) return langues, avis def construire(contenu, langue, trame, sortie): """Écrit le CV en .pptx et renvoie les avertissements et le nombre de slides.""" if Presentation is None: raise ErreurCV("python-pptx manque : lancer le script avec « uv run », ou installer le paquet (pip install python-pptx).") langues, avis = verifier(contenu, langue) if not Path(trame).is_file(): raise ErreurCV(f"Trame introuvable : {trame}") prs = Presentation(trame) slides = list(prs.slides) if len(slides) != NOMBRE_DE_SLIDES: raise ErreurCV(f"La trame ne correspond plus au script : {NOMBRE_DE_SLIDES} slides attendues, {len(slides)} trouvées.") par_langue = {lg: {cle: slides[i] for cle, i in SLIDES[lg].items()} for lg in SLIDES} a_propos = slides[A_PROPOS] exemple = lire_exemple() for lg in langues: protos = prototypes(par_langue[lg], exemple[lg], LIBELLES[lg]) avis += remplir_langue(prs, par_langue[lg], contenu[lg], lg, protos) for lg in SLIDES: if lg not in langues: for s in (par_langue[lg]["couverture"], par_langue[lg]["synthese"], par_langue[lg]["zoom"]): supprimer(prs, s) langue_a_propos = "en" if langue == "en" else "fr" ecrire_a_propos(a_propos, langue_a_propos) if langue == "en": deplacer_apres(prs, a_propos, None) paginer(prs) proprietes(prs, contenu[langues[0]]["prenom"]) nettoyer(prs) texte = " ".join(texte_de(p) for s in prs.slides for f in s.shapes for p in f._element.iter(A + "p")) if MARQUE_EXEMPLE in texte and not any(MARQUE_EXEMPLE in a for a in avis): avis.append(f"Le fichier contient encore « {MARQUE_EXEMPLE} » : une zone de la trame n'a pas été remplie.") Path(sortie).parent.mkdir(parents=True, exist_ok=True) prs.save(sortie) return avis, len(prs.slides) def main(): parser = argparse.ArgumentParser(description="Écrit le CV adalan (.pptx, .pdf ou .html) à partir d'un fichier JSON.") parser.add_argument("contenu", help="fichier JSON du CV (voir references/contenu.md)") parser.add_argument("--langue", required=True, choices=["fr", "en", "bilingue"]) parser.add_argument("-o", "--sortie", required=True, help="fichier à écrire : .pptx, .pdf (A4 portrait) ou .html") parser.add_argument("--trame", default=str(TRAME), help="trame à utiliser (par défaut assets/trame-cv.pptx)") args = parser.parse_args() try: with open(args.contenu, encoding="utf-8") as f: contenu = json.load(f) except FileNotFoundError: sys.exit(f"Fichier introuvable : {args.contenu}") except json.JSONDecodeError as e: sys.exit(f"JSON invalide dans {args.contenu}, ligne {e.lineno}, colonne {e.colno} : {e.msg}") format_ = Path(args.sortie).suffix.lower() if format_ not in (".pptx", ".pdf", ".html"): sys.exit(f"Extension de sortie inconnue « {format_ or Path(args.sortie).name} » : .pptx, .pdf ou .html attendu.") try: if format_ == ".pptx": avis, nombre = construire(contenu, args.langue, args.trame, args.sortie) detail = f"{nombre} slides" else: import cv_web avis, nombre = cv_web.construire(contenu, args.langue, args.sortie) detail = "page HTML" if format_ == ".html" else f"PDF A4, {nombre or '?'} pages" except ErreurCV as e: sys.exit(str(e)) print(f"CV écrit : {args.sortie} ({detail})") if avis: print("À relire :") for a in avis: print(f" - {a}") elif format_ == ".pptx": print("Aucun point signalé. Vérifier quand même le rendu : l'estimation de la place est approximative.") else: print("Aucun point signalé.") if __name__ == "__main__": main()