#!/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"^.*?", 1)[0]
return (f'')
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("