#!/usr/bin/env python3 # /// script # requires-python = ">=3.9" # dependencies = ["python-pptx>=1.0", "fonttools>=4.50", "resvg-py>=0.2", "pillow>=10"] # /// """Redessine assets/trame-cv.pptx d'après la charte graphique adalan. uv run scripts/dessiner_trame.py [--charte CHEMIN] À lancer seulement pour faire évoluer la trame : mise en page, couleurs, styles de texte. Le script lit les courbes de niveau et les logos du skill charte-graphique-adalan (par défaut le dossier voisin dans le dépôt adalan-skills) et les polices de assets/polices (voir preparer_polices.py). Il dessine les sept slides de la trame (couverture, synthèse et zoom en français, « À propos », puis couverture, synthèse et zoom en anglais), y met le contenu de assets/exemple.json avec les fonctions de remplir_cv.py, embarque les polices, puis vérifie que remplir_cv.py, lancé sur l'exemple, redonne le même texte. La scène fait 1920 × 1080 px, comme le modèle de présentation de la charte : toutes les mesures du script sont en px de scène (1 px = 6 350 EMU = 0,5 pt). """ import argparse import copy import io import json import re import struct import sys import tempfile from pathlib import Path try: import resvg_py from fontTools.ttLib import TTFont from lxml import etree from PIL import Image from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE from pptx.opc.constants import RELATIONSHIP_TYPE as RT from pptx.opc.package import Part from pptx.opc.packuri import PackURI except ImportError as e: sys.exit(f"Dépendance manquante ({e.name}) : lancer le script avec « uv run scripts/dessiner_trame.py ».") ICI = Path(__file__).resolve().parent sys.path.insert(0, str(ICI)) import remplir_cv as rc # noqa: E402 SKILL = ICI.parent POLICES = SKILL / "assets" / "polices" CHARTE = SKILL.parent / "charte-graphique-adalan" NS = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", "p": "http://schemas.openxmlformats.org/presentationml/2006/main", "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", } SVG_EXT = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}" EMU = 6350 # 1 px de scène W, H = 1920, 1080 # Couleurs de la charte (adalan.css) ENCRE, ENCRE_900, ENCRE_400, ENCRE_200 = "1F175A", "150F44", "7A73C4", "CFCBEE" PECHE, PECHE_600, PECHE_700 = "F9A66B", "D96A1F", "A94D08" SOL, BLANC = "F6F5FB", "FFFFFF" TEXTE_2, TEXTE_3 = "4B467A", "6E6A93" # Polices de assets/polices : Mona Sans et Monaspace Neon, renommées (licence OFL) SANS, LEGER, DISPLAY, HEADING = "adalan Sans", "adalan Sans Light", "adalan Display", "adalan Heading" MONO, MONO_M = "adalan Mono", "adalan Mono Medium" FAMILLES = { # famille : (régulier, gras) SANS: ("adalan-sans.ttf", "adalan-sans-bold.ttf"), LEGER: ("adalan-sans-light.ttf", None), DISPLAY: ("adalan-display.ttf", None), HEADING: ("adalan-heading.ttf", None), MONO: ("adalan-mono.ttf", None), MONO_M: ("adalan-mono-medium.ttf", None), } CARRE = "■" # dessiné en diagonale dans adalan Mono : sur-titres et puces LANGUE_XML = rc.LANGUE_XML STATIQUES = {langue: {"parcours": lib["parcours"], "profil": lib["profil"], "missions": lib["missions"], "pied_synthese": lib["synthese"], "pied_zoom": lib["zoom"]} for langue, lib in rc.LIBELLES.items()} def px(v): return int(round(v * EMU)) def qn(tag): prefixe, local = tag.split(":") return f"{{{NS[prefixe]}}}{local}" def sub(parent, tag, **attrs): el = etree.SubElement(parent, qn(tag)) for k, v in attrs.items(): el.set(k, str(v)) return el # --------------------------------------------------------------------------------------------- # Styles de texte. Tailles et espacements en px de scène ; chasse (interlettrage) en em. def R(police, taille, couleur, alpha=100, gras=False, capitales=False, chasse=0.0, degrade=None): return dict(police=police, taille=taille, couleur=couleur, alpha=alpha, gras=gras, capitales=capitales, chasse=chasse, degrade=degrade) def P(runs, interligne=1.25, avant=0, apres=0, retrait=0, alinea=0, puce=None, align=None): return dict(runs=runs, interligne=interligne, avant=avant, apres=apres, retrait=retrait, alinea=alinea, puce=puce, align=align) FONDU_PECHE = [(0, PECHE, 100), (45, PECHE, 82), (100, PECHE, 10)] # numéro géant de la charte FONDU_INITIALE = [(0, ENCRE_400, 60), (50, ENCRE_400, 22), (100, ENCRE_400, 0)] # ton sur ton : le prénom reste le titre SURTITRE_ENCRE = P([R(MONO_M, 20, PECHE, capitales=True, chasse=.24)], 1.2, retrait=32, alinea=-32, puce=(PECHE, 85)) STYLES = { "couverture": { "surtitre": dict(SURTITRE_ENCRE, apres=30), "prenom": P([R(DISPLAY, 168, BLANC, chasse=-.04)], 0.98, apres=8), "titre": P([R(HEADING, 52, PECHE, chasse=-.01)], 1.15, apres=30), "domaines": P([R(LEGER, 34, BLANC, alpha=90)], 1.32), }, "initiale": { "initiale": P([R(DISPLAY, 1100, ENCRE_400, chasse=-.04, degrade=FONDU_INITIALE)], 0.86, align="r"), }, "entete": { "domaines": P([R(MONO_M, 18, PECHE_700, capitales=True, chasse=.2)], 1.2, apres=10, retrait=26, alinea=-26, puce=(PECHE_700, 85)), "nom": P([R(DISPLAY, 50, ENCRE, chasse=-.02), R(DISPLAY, 50, PECHE_600, chasse=-.02)], 1.08, apres=8), "accroche": P([R(SANS, 22, TEXTE_2)], 1.35), }, "parcours": { "profil": P([R(SANS, 20, ENCRE)], 1.4, apres=10), "axe": P([R(SANS, 20, ENCRE, gras=True), R(SANS, 20, TEXTE_2)], 1.25, avant=6, retrait=22, alinea=-22, puce=(PECHE, 70)), }, "expertise": { "titre": P([R(HEADING, 26, BLANC, chasse=-.01)], 1.15, apres=12), "point": P([R(SANS, 20, BLANC, gras=True), R(SANS, 20, ENCRE_200)], 1.25, avant=6, retrait=22, alinea=-22, puce=(PECHE, 70)), }, "competences": { "rubrique": P([R(MONO_M, 16, PECHE_700, capitales=True, chasse=.16)], 1.2, avant=12, apres=4, retrait=22, alinea=-22, puce=(PECHE_700, 85)), "bloc": P([R(SANS, 20, ENCRE, gras=True)], 1.25, avant=6), "point": P([R(SANS, 20, ENCRE, gras=True), R(SANS, 20, TEXTE_2)], 1.22, avant=2, retrait=22, alinea=-22, puce=(PECHE, 60)), "outils": P([R(SANS, 20, ENCRE, gras=True), R(MONO, 17, TEXTE_2)], 1.3, avant=6), }, "missions": { "poste": P([R(HEADING, 21, ENCRE)], 1.25, avant=18, retrait=24, alinea=-24, puce=(PECHE, 80)), "contexte": P([R(MONO_M, 16, PECHE_700), R(SANS, 19, TEXTE_2)], 1.3, avant=3, retrait=24), "resume": P([R(SANS, 20, TEXTE_2)], 1.32, avant=4, retrait=24), }, "zoom": { "poste": P([R(HEADING, 25, ENCRE)], 1.2, avant=24, retrait=26, alinea=-26, puce=(PECHE, 80)), "contexte": P([R(MONO_M, 16, PECHE_700), R(SANS, 19, TEXTE_2)], 1.3, avant=3, apres=4, retrait=26), "texte": P([R(SANS, 20, ENCRE, gras=True), R(SANS, 20, ENCRE)], 1.3, avant=5, retrait=26), "bloc": P([R(SANS, 20, ENCRE, gras=True)], 1.25, avant=8, retrait=26), "point": P([R(SANS, 20, ENCRE, gras=True), R(SANS, 20, TEXTE_2)], 1.28, avant=3, retrait=48, alinea=-22, puce=(PECHE, 60)), "environnement": P([R(SANS, 20, ENCRE, gras=True), R(MONO, 17, TEXTE_2)], 1.3, avant=6, retrait=26), "rubrique": P([R(MONO_M, 17, PECHE_700, capitales=True, chasse=.16)], 1.2, avant=24, apres=4, retrait=26, alinea=-26, puce=(PECHE_700, 85)), }, } TITRE_CARTE = P([R(HEADING, 26, ENCRE, chasse=-.01)], 1.15) SIGNATURE = P([R(MONO, 13, TEXTE_3, capitales=True, chasse=.16)], 1.2, align="r") def pied(sur_encre): couleur, alpha = (BLANC, 60) if sur_encre else (TEXTE_3, 100) return (P([R(MONO, 15, couleur, alpha=alpha, capitales=True, chasse=.12)], 1.2), P([R(MONO, 15, couleur, alpha=alpha, chasse=.06)], 1.2, align="r")) # --------------------------------------------------------------------------------------------- # XML des paragraphes def couleur_xml(parent, couleur, alpha): c = sub(parent, "a:srgbClr", val=couleur) if alpha < 100: sub(c, "a:alpha", val=int(alpha * 1000)) return c def degrade_xml(parent, arrets, angle=5400000): g = sub(parent, "a:gradFill", rotWithShape="1") lst = sub(g, "a:gsLst") for pos, couleur, alpha in arrets: couleur_xml(sub(lst, "a:gs", pos=int(pos * 1000)), couleur, alpha) sub(g, "a:lin", ang=angle, scaled="0") return g def rpr_xml(rs, langue, tag="a:rPr"): rpr = etree.Element(qn(tag)) rpr.set("lang", langue) rpr.set("sz", str(round(rs["taille"] * 50))) rpr.set("b", "1" if rs["gras"] else "0") rpr.set("i", "0") if rs["capitales"]: rpr.set("cap", "all") if rs["chasse"]: rpr.set("spc", str(round(rs["chasse"] * rs["taille"] * 50))) rpr.set("kern", "100") rpr.set("dirty", "0") if rs["degrade"]: degrade_xml(rpr, rs["degrade"]) else: couleur_xml(sub(rpr, "a:solidFill"), rs["couleur"], rs["alpha"]) sub(rpr, "a:latin", typeface=rs["police"]) sub(rpr, "a:cs", typeface=rs["police"]) return rpr def espacement(pPr, tag, pts_x100): sub(sub(pPr, tag), "a:spcPts", val=int(round(pts_x100))) def paragraphe(st, textes, langue, premier=False): """Un paragraphe : tous ses runs, même vides, pour que remplir_cv.py y retrouve sa mise en forme.""" p = etree.Element(qn("a:p")) pPr = sub(p, "a:pPr") if st["retrait"]: pPr.set("marL", str(px(st["retrait"]))) if st["alinea"]: pPr.set("indent", str(px(st["alinea"]))) if st["align"]: pPr.set("algn", st["align"]) taille = st["runs"][0]["taille"] espacement(pPr, "a:lnSpc", taille * st["interligne"] * 50) if not premier: espacement(pPr, "a:spcBef", st["avant"] * 50) espacement(pPr, "a:spcAft", st["apres"] * 50) if st["puce"]: couleur, pct = st["puce"] couleur_xml(sub(pPr, "a:buClr"), couleur, 100) sub(pPr, "a:buSzPct", val=int(pct * 1000)) sub(pPr, "a:buFont", typeface=MONO) sub(pPr, "a:buChar", char=CARRE) else: sub(pPr, "a:buNone") if len(textes) != len(st["runs"]): raise ValueError(f"{len(textes)} textes pour {len(st['runs'])} runs") for rs, texte in zip(st["runs"], textes): r = sub(p, "a:r") r.append(rpr_xml(rs, langue)) sub(r, "a:t").text = texte p.append(rpr_xml(st["runs"][0], langue, "a:endParaRPr")) return p # --------------------------------------------------------------------------------------------- # Formes def sans_style(forme): style = forme._element.find(qn("p:style")) if style is not None: forme._element.remove(style) def boite(slide, nom, x, y, w, h, ancre="t", marges=(0, 0, 0, 0)): """Zone de texte sans fond, sans ajustement automatique : un texte trop long déborde et se voit.""" tb = slide.shapes.add_textbox(px(x), px(y), px(w), px(h)) tb.name = nom corps = tb.text_frame._txBody bodyPr = corps.find(qn("a:bodyPr")) bodyPr.attrib.clear() for enfant in list(bodyPr): bodyPr.remove(enfant) for cle, v in zip(("lIns", "tIns", "rIns", "bIns"), marges): bodyPr.set(cle, str(px(v))) bodyPr.set("wrap", "square") bodyPr.set("rtlCol", "0") bodyPr.set("anchor", ancre) sub(bodyPr, "a:noAutofit") for p in corps.findall(qn("a:p")): corps.remove(p) return tb def ecrire(tb, paragraphes): corps = tb.text_frame._txBody for p in paragraphes: corps.append(p) def texte(slide, nom, x, y, w, h, st, textes, langue, ancre="t"): tb = boite(slide, nom, x, y, w, h, ancre) ecrire(tb, [paragraphe(st, t if isinstance(t, list) else [t], langue, premier=i == 0) for i, t in enumerate(textes)]) return tb def remplir_suite(tb, styles, suite, langue): """Le texte de l'exemple, mis en forme rôle par rôle : le miroir de remplir_cv.remplir().""" ecrire(tb, [paragraphe(styles[role], runs, langue, premier=i == 0) for i, (role, runs) in enumerate(suite)]) def proprietes_forme(forme): spPr = forme._element.spPr for tag in ("a:solidFill", "a:gradFill", "a:blipFill", "a:noFill", "a:ln", "a:effectLst"): for el in spPr.findall(qn(tag)): spPr.remove(el) return spPr def remplir_forme(spPr, fond=None, alpha=100, degrade=None, image=None, recadrage=None, angle=5400000): if image is not None: bf = sub(spPr, "a:blipFill", dpi="0", rotWithShape="1") sub(bf, "a:blip", **{qn("r:embed"): image}) src = sub(bf, "a:srcRect") for cle, v in zip("ltrb", recadrage or (0, 0, 0, 0)): src.set(cle, str(int(v * 1000))) sub(sub(bf, "a:stretch"), "a:fillRect") elif degrade: degrade_xml(spPr, degrade, angle) elif fond: couleur_xml(sub(spPr, "a:solidFill"), fond, alpha) else: sub(spPr, "a:noFill") def filet_et_ombre(spPr, filet=None, ombre=None): """filet : (couleur, alpha, largeur px) ; ombre : (flou px, distance px, couleur, alpha).""" if filet: couleur, alpha, largeur = filet ln = sub(spPr, "a:ln", w=px(largeur)) couleur_xml(sub(ln, "a:solidFill"), couleur, alpha) else: sub(sub(spPr, "a:ln"), "a:noFill") if ombre: flou, distance, couleur, alpha = ombre eff = sub(spPr, "a:effectLst") o = sub(eff, "a:outerShdw", blurRad=px(flou), dist=px(distance), dir="5400000", algn="t", rotWithShape="0") couleur_xml(o, couleur, alpha) def diagonale(slide, nom, x, y, w, h, grand=28, petit=6, filet=None, ombre=None, **remplissage): """La forme de la charte : coins haut gauche et bas droit grands, les deux autres vifs.""" f = slide.shapes.add_shape(MSO_SHAPE.ROUND_2_DIAG_RECTANGLE, px(x), px(y), px(w), px(h)) f.name = nom sans_style(f) cote = min(w, h) f.adjustments[0] = min(grand / cote, 0.5) f.adjustments[1] = min(petit / cote, 0.5) spPr = proprietes_forme(f) remplir_forme(spPr, **remplissage) filet_et_ombre(spPr, filet, ombre) return f def rectangle(slide, nom, x, y, w, h, **remplissage): f = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, px(x), px(y), px(w), px(h)) f.name = nom sans_style(f) spPr = proprietes_forme(f) remplir_forme(spPr, **remplissage) filet_et_ombre(spPr) return f def trait(slide, nom, x, y, w=30, h=5, couleur=PECHE): """Le trait pêche en diagonale de la charte, en petit.""" return diagonale(slide, nom, x, y, w, h, grand=h * 0.6, petit=h * 0.15, fond=couleur) # --------------------------------------------------------------------------------------------- # Images : fonds, logos def rendre_svg(svg, largeur): return bytes(resvg_py.svg_to_bytes(svg_string=svg, width=largeur)) def jpeg(png, qualite=90): b = io.BytesIO() Image.open(io.BytesIO(png)).convert("RGB").save(b, "JPEG", quality=qualite, subsampling=0, optimize=True) return b.getvalue() def fond_svg(couleur, halos, motif): """Un fond de slide de la charte : couleur, halos (dégradés radiaux), courbes de niveau débordant de 96 px sur les côtés et de 54 px en haut et en bas, comme dans le modèle de présentation.""" defs, couches = [], [] for i, (cx, cy, rx, ry, rgb, alpha, fin) in enumerate(halos): defs.append( f'' f'' f'') couches.append(f'') interieur = re.sub(r"^.*?]*>", "", motif, count=1, flags=re.S).rsplit("", 1)[0] return (f'' f'{"".join(defs)}{"".join(couches)}' f'{interieur}') class Ressources: """Images et polices de la trame, préparées une fois.""" def __init__(self, charte): motifs, logos = charte / "assets" / "motifs", charte / "assets" / "logos" for chemin in (motifs / "topo-sur-encre.svg", motifs / "topo-sur-clair.svg", logos / "logo-label.svg", logos / "logo-label-white-orange.svg"): if not chemin.is_file(): sys.exit(f"Fichier de la charte introuvable : {chemin}. Préciser le dossier du skill avec --charte.") # .slide--ink : halo violet en haut à droite, plus sombre en bas à gauche encre = fond_svg(ENCRE, [(0, H, 1100, 800, "rgb(10,7,40)", .75, .70), (W * .88, 0, 1300, 900, "rgb(80,64,190)", .55, .62)], (motifs / "topo-sur-encre.svg").read_text()) # .slide--light, sur le fond lavande : halo lavande en haut à droite clair = fond_svg(SOL, [(W, 0, 1200, 800, "rgb(207,203,238)", .35, .60)], (motifs / "topo-sur-clair.svg").read_text()) self.fond_encre = jpeg(rendre_svg(encre, W * 2)) self.fond_clair = jpeg(rendre_svg(clair, W * 2)) self.logos = {} for cle, fichier in (("encre", "logo-label.svg"), ("blanc", "logo-label-white-orange.svg")): svg = (logos / fichier).read_bytes() self.logos[cle] = (svg, rendre_svg(svg.decode("utf-8"), 1476)) self.parts_svg = {} def svg_part(self, prs, cle): if cle not in self.parts_svg: self.parts_svg[cle] = Part(PackURI(f"/ppt/media/logo-{cle}.svg"), "image/svg+xml", prs.part.package, self.logos[cle][0]) return self.parts_svg[cle] def logo(slide, prs, res, cle, nom, x, y, largeur): """Le logotype en SVG, avec son rendu PNG pour les logiciels qui ne lisent pas le SVG.""" svg, png = res.logos[cle] hauteur = largeur * 199 / 738 pic = slide.shapes.add_picture(io.BytesIO(png), px(x), px(y), px(largeur), px(hauteur)) pic.name = nom rId = slide.part.relate_to(res.svg_part(prs, cle), RT.IMAGE) blip = pic._element.find(".//" + qn("a:blip")) ext = sub(sub(blip, "a:extLst"), "a:ext", uri=SVG_EXT) svg_blip = etree.SubElement(ext, qn("asvg:svgBlip"), nsmap={"asvg": NS["asvg"]}) svg_blip.set(qn("r:embed"), rId) pic._element.nvPicPr.cNvPr.set("descr", "adalan") return pic def image_rid(slide, donnees): _, rId = slide.part.get_or_add_image_part(io.BytesIO(donnees)) return rId # --------------------------------------------------------------------------------------------- # Présentation : thème, dispositions, polices def preparer_presentation(res): prs = Presentation() prs.slide_width, prs.slide_height = px(W), px(H) master = prs.slide_master theme = master.part.part_related_by(RT.THEME) xml = etree.fromstring(theme.blob) xml.set("name", "adalan") clr = xml.find(".//" + qn("a:clrScheme")) clr.set("name", "adalan") for tag, val in (("dk1", ENCRE), ("lt1", BLANC), ("dk2", ENCRE_900), ("lt2", SOL), ("accent1", PECHE), ("accent2", ENCRE), ("accent3", "5047B0"), ("accent4", PECHE_700), ("accent5", "A8A2DC"), ("accent6", PECHE_600), ("hlink", "3A2FA0"), ("folHlink", "5047B0")): el = clr.find(qn(f"a:{tag}")) for enfant in list(el): el.remove(enfant) sub(el, "a:srgbClr", val=val) fonts = xml.find(".//" + qn("a:fontScheme")) fonts.set("name", "adalan") for tag, police in (("a:majorFont", DISPLAY), ("a:minorFont", SANS)): fonts.find(qn(tag)).find(qn("a:latin")).set("typeface", police) theme._blob = etree.tostring(xml, xml_declaration=True, encoding="UTF-8", standalone=True) for f in list(master.shapes): # repères du modèle 4:3 de python-pptx : inutiles ici f._element.getparent().remove(f._element) fond_xml(master, couleur=SOL) garder = {"Encre": master.slide_layouts[5], "Clair": master.slide_layouts[6]} for disposition in list(master.slide_layouts): if disposition not in garder.values(): master.slide_layouts.remove(disposition) for nom, disposition in garder.items(): disposition.name = nom for f in list(disposition.shapes): f._element.getparent().remove(f._element) donnees = res.fond_encre if nom == "Encre" else res.fond_clair _, rId = disposition.part.get_or_add_image_part(io.BytesIO(donnees)) fond_xml(disposition, image=rId) return prs, garder def fond_xml(porteur, couleur=None, image=None): cSld = porteur._element.find(qn("p:cSld")) ancien = cSld.find(qn("p:bg")) if ancien is not None: cSld.remove(ancien) bg = etree.Element(qn("p:bg")) bgPr = sub(bg, "p:bgPr") if image: remplir_forme(bgPr, image=image) else: couleur_xml(sub(bgPr, "a:solidFill"), couleur, 100) sub(bgPr, "a:effectLst") cSld.insert(0, bg) def eot(chemin): """Une police TrueType au format Embedded OpenType, sans compression : le format que PowerPoint lit dans les polices embarquées (ppt/fonts/*.fntdata).""" donnees = chemin.read_bytes() f = TTFont(io.BytesIO(donnees)) os2, name = f["OS/2"], f["name"] p = os2.panose panose = bytes([p.bFamilyType, p.bSerifStyle, p.bWeight, p.bProportion, p.bContrast, p.bStrokeVariation, p.bArmStyle, p.bLetterForm, p.bMidline, p.bXHeight]) corps = panose + struct.pack("