feat: freeform palier 4 - derives de tokens, shapes, alpha, rotation, full_bleed (C6)

This commit is contained in:
2026-07-12 08:14:57 +02:00
parent 6e8167a91c
commit 3929d14e9f
6 changed files with 1160 additions and 70 deletions
+83 -15
View File
@@ -1143,11 +1143,19 @@ def list_assets(proj: "Project"):
VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate", VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate",
"card", "white", "body", "muted"} "card", "white", "body", "muted"}
VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle", VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle",
"badge", "card", "rect", "line", "image"} "badge", "card", "rect", "line", "image", "shape"}
VALID_FREE_SHAPES = {"chevron", "arrow", "triangle", "pill", "donut",
"bracket_left", "bracket_right", "oval", "diamond",
"hexagon", "parallelogram", "moon"}
FREE_TOKEN_RE = re.compile(
r"^(navy|navy_light|coral|glacier|slate|card|card_alt|white|body|muted)"
r"(@\d{1,3})?$")
def validate_freeform(raw: str): def validate_freeform(raw: str):
"""Valide la structure d'un YAML freeform. Retourne (ok, message, data).""" """Valide un YAML freeform — palier 4 (C6), deux niveaux.
Retourne (ok, message, data). Les avertissements n'empêchent pas
le rendu ; ils sont intégrés au message."""
try: try:
data = yaml.safe_load(extract_yaml(raw)) data = yaml.safe_load(extract_yaml(raw))
except yaml.YAMLError as e: except yaml.YAMLError as e:
@@ -1157,35 +1165,95 @@ def validate_freeform(raw: str):
slides = data["slides"] slides = data["slides"]
if not slides: if not slides:
return False, "Aucune slide.", None return False, "Aucune slide.", None
errors = []
def color_ok(c):
return bool(FREE_TOKEN_RE.match(str(c).strip().lower()))
errors, warns = [], []
for i, s in enumerate(slides, 1): for i, s in enumerate(slides, 1):
if s.get("layout") != "freeform": if s.get("layout") != "freeform":
errors.append(f"Slide {i} : layout doit être 'freeform'") errors.append(f"Slide {i} : layout doit être 'freeform'")
continue continue
if s.get("mode") not in ("light", "dark"): if s.get("mode") not in ("light", "dark", None):
errors.append(f"Slide {i} : mode doit être 'light' ou 'dark'") errors.append(f"Slide {i} : mode doit être 'light' ou "
f"'dark'")
bg = s.get("background")
if bg and not color_ok(bg):
errors.append(f"Slide {i} : background '{bg}' hors charte "
f"(token ou token@NN)")
blocks = s.get("blocks", []) blocks = s.get("blocks", [])
if not blocks: if not blocks:
errors.append(f"Slide {i} : aucun bloc") errors.append(f"Slide {i} : aucun bloc")
if len(blocks) > 8: if len(blocks) > 15:
errors.append(f"Slide {i} : {len(blocks)} blocs (max 8)") errors.append(f"Slide {i} : {len(blocks)} blocs (max 15)")
elif len(blocks) > 10:
warns.append(f"Slide {i} : {len(blocks)} blocs — pense "
f"respiration (10 max conseillé)")
for j, b in enumerate(blocks, 1): for j, b in enumerate(blocks, 1):
loc = f"Slide {i} bloc {j}"
bt = (b.get("type") or "text").lower() bt = (b.get("type") or "text").lower()
if bt not in VALID_BLOCK_TYPES: if bt not in VALID_BLOCK_TYPES:
errors.append(f"Slide {i} bloc {j} : type '{bt}' invalide") errors.append(f"{loc} : type '{bt}' invalide")
if bt == "shape":
sk = str(b.get("shape") or "").lower()
if sk and sk not in VALID_FREE_SHAPES:
errors.append(
f"{loc} : shape '{sk}' inconnue "
f"({', '.join(sorted(VALID_FREE_SHAPES))})")
col = float(b.get("col", 0)); w = float(b.get("w", 4)) col = float(b.get("col", 0)); w = float(b.get("w", 4))
row = float(b.get("row", 0)); h = float(b.get("h", 1)) row = float(b.get("row", 0)); h = float(b.get("h", 1))
if col + w > 12.01: if col + w > 12.01:
errors.append(f"Slide {i} bloc {j} : déborde (col+w={col+w:.1f}>12)") errors.append(f"{loc} : déborde "
f"(col+w={col + w:.1f}>12)")
if row + h > 12.01: if row + h > 12.01:
errors.append(f"Slide {i} bloc {j} : déborde (row+h={row+h:.1f}>12)") errors.append(f"{loc} : déborde "
color = b.get("color") f"(row+h={row + h:.1f}>12)")
if color and color not in VALID_TOKENS: for key in ("color", "text_color"):
errors.append(f"Slide {i} bloc {j} : couleur '{color}' hors charte") c = b.get(key)
if c and not color_ok(c):
errors.append(f"{loc} : {key} '{c}' hors charte "
f"(token ou token@NN, jamais de hex)")
border = b.get("border")
if border is not None:
if not isinstance(border, dict):
errors.append(f"{loc} : border doit être "
f"{{color, weight}}")
elif border.get("color") and not color_ok(border["color"]):
errors.append(f"{loc} : border.color "
f"'{border['color']}' hors charte")
alpha = b.get("alpha")
if alpha is not None:
try:
if not 0 <= float(alpha) <= 100:
warns.append(f"{loc} : alpha {alpha} hors "
f"0-100 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : alpha '{alpha}' non "
f"numérique")
rot = b.get("rotation")
if rot is not None:
try:
if float(rot) % 15 != 0:
warns.append(f"{loc} : rotation {rot}° — sera "
f"arrondie au pas de 15°")
except (TypeError, ValueError):
errors.append(f"{loc} : rotation '{rot}' non "
f"numérique")
radius = b.get("radius")
if radius is not None:
try:
if not 0 <= float(radius) <= 0.5:
warns.append(f"{loc} : radius {radius} hors "
f"0-0.5 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : radius '{radius}' non "
f"numérique")
if errors: if errors:
return False, "\n ".join(errors), data return False, "\n ".join(errors), data
return True, f"{len(slides)} slides freeform valides.", data msg = f"{len(slides)} slides freeform valides."
if warns:
msg += "\n" + "\n".join(warns)
return True, msg, data
def run_free_designer(session: AgentSession, markdown: str, proj: Project): def run_free_designer(session: AgentSession, markdown: str, proj: Project):
"""Flux libre : le Free Designer produit directement le YAML freeform.""" """Flux libre : le Free Designer produit directement le YAML freeform."""
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_facilitator_c6.py Chantier C6 (validateur freeform palier 4)
====================================================================
Patch strict de facilitator_v9.py :
P1. VALID_BLOCK_TYPES accepte le type « shape ».
P2. validate_freeform v2 deux niveaux :
ERREURS (bloquantes) : type de bloc inconnu, shape inconnue,
couleur hors charte (hex, token inconnu, dérivé mal formé),
bloc hors slide, plus de 15 blocs, mode invalide, background
hors charte.
AVERTISSEMENTS (non bloquants, affichés) : plus de 10 blocs
(densité), alpha hors 0-100 (sera clampé), rotation non
multiple de 15° (sera arrondie), radius hors 0-0.5.
Le vocabulaire validé : tokens + dérivés token@NN, les 12 shapes
curées, full_bleed, border, dash aligné sur le moteur C6.
Usage (dossier du pipeline, single-line) :
python3 patch_facilitator_c6.py
Vérifie chaque ancre, écrit .bak-fc6, compile, idempotent.
"""
import py_compile
import shutil
import sys
from pathlib import Path
TARGET = Path("facilitator_v9.py")
OLD_TYPES = ('VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
'"circle",\n'
' "badge", "card", "rect", "line", '
'"image"}\n')
NEW_TYPES = ('VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
'"circle",\n'
' "badge", "card", "rect", "line", '
'"image", "shape"}\n'
'VALID_FREE_SHAPES = {"chevron", "arrow", "triangle", '
'"pill", "donut",\n'
' "bracket_left", "bracket_right", '
'"oval", "diamond",\n'
' "hexagon", "parallelogram", "moon"}\n'
'FREE_TOKEN_RE = re.compile(\n'
' r"^(navy|navy_light|coral|glacier|slate|card|card_alt'
'|white|body|muted)"\n'
' r"(@\\d{1,3})?$")\n')
NEW_VALIDATE = '''def validate_freeform(raw: str):
"""Valide un YAML freeform — palier 4 (C6), deux niveaux.
Retourne (ok, message, data). Les avertissements n'empêchent pas
le rendu ; ils sont intégrés au message."""
try:
data = yaml.safe_load(extract_yaml(raw))
except yaml.YAMLError as e:
return False, f"Erreur de syntaxe YAML : {e}", None
if not isinstance(data, dict) or "slides" not in data:
return False, "Clé 'slides' manquante.", None
slides = data["slides"]
if not slides:
return False, "Aucune slide.", None
def color_ok(c):
return bool(FREE_TOKEN_RE.match(str(c).strip().lower()))
errors, warns = [], []
for i, s in enumerate(slides, 1):
if s.get("layout") != "freeform":
errors.append(f"Slide {i} : layout doit être 'freeform'")
continue
if s.get("mode") not in ("light", "dark", None):
errors.append(f"Slide {i} : mode doit être 'light' ou "
f"'dark'")
bg = s.get("background")
if bg and not color_ok(bg):
errors.append(f"Slide {i} : background '{bg}' hors charte "
f"(token ou token@NN)")
blocks = s.get("blocks", [])
if not blocks:
errors.append(f"Slide {i} : aucun bloc")
if len(blocks) > 15:
errors.append(f"Slide {i} : {len(blocks)} blocs (max 15)")
elif len(blocks) > 10:
warns.append(f"Slide {i} : {len(blocks)} blocs — pense "
f"respiration (10 max conseillé)")
for j, b in enumerate(blocks, 1):
loc = f"Slide {i} bloc {j}"
bt = (b.get("type") or "text").lower()
if bt not in VALID_BLOCK_TYPES:
errors.append(f"{loc} : type '{bt}' invalide")
if bt == "shape":
sk = str(b.get("shape") or "").lower()
if sk and sk not in VALID_FREE_SHAPES:
errors.append(
f"{loc} : shape '{sk}' inconnue "
f"({', '.join(sorted(VALID_FREE_SHAPES))})")
col = float(b.get("col", 0)); w = float(b.get("w", 4))
row = float(b.get("row", 0)); h = float(b.get("h", 1))
if col + w > 12.01:
errors.append(f"{loc} : déborde "
f"(col+w={col + w:.1f}>12)")
if row + h > 12.01:
errors.append(f"{loc} : déborde "
f"(row+h={row + h:.1f}>12)")
for key in ("color", "text_color"):
c = b.get(key)
if c and not color_ok(c):
errors.append(f"{loc} : {key} '{c}' hors charte "
f"(token ou token@NN, jamais de hex)")
border = b.get("border")
if border is not None:
if not isinstance(border, dict):
errors.append(f"{loc} : border doit être "
f"{{color, weight}}")
elif border.get("color") and \
not color_ok(border["color"]):
errors.append(f"{loc} : border.color "
f"'{border['color']}' hors charte")
alpha = b.get("alpha")
if alpha is not None:
try:
if not 0 <= float(alpha) <= 100:
warns.append(f"{loc} : alpha {alpha} hors "
f"0-100 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : alpha '{alpha}' non "
f"numérique")
rot = b.get("rotation")
if rot is not None:
try:
if float(rot) % 15 != 0:
warns.append(f"{loc} : rotation {rot}° — sera "
f"arrondie au pas de 15°")
except (TypeError, ValueError):
errors.append(f"{loc} : rotation '{rot}' non "
f"numérique")
radius = b.get("radius")
if radius is not None:
try:
if not 0 <= float(radius) <= 0.5:
warns.append(f"{loc} : radius {radius} hors "
f"0-0.5 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : radius '{radius}' non "
f"numérique")
if errors:
return False, "\\n ".join(errors), data
msg = f"{len(slides)} slides freeform valides."
if warns:
msg += "\\n ⚠ " + "\\n ⚠ ".join(warns)
return True, msg, data
'''
MARKER = "VALID_FREE_SHAPES"
def fail(msg):
print(" ! %s" % msg)
sys.exit(1)
def main():
if not TARGET.exists():
fail("%s introuvable — lancer depuis le dossier du pipeline."
% TARGET)
content = TARGET.read_text(encoding="utf-8")
if MARKER in content:
fail("Déjà patché (VALID_FREE_SHAPES présent) — rien à faire.")
if content.count(OLD_TYPES) != 1:
fail("Ancre VALID_BLOCK_TYPES introuvable ou non unique — "
"appliquer d'abord le patch C4 du facilitator.")
# Localiser la fonction validate_freeform complète
start = content.find("def validate_freeform(raw: str):")
if start == -1:
fail("validate_freeform introuvable.")
end = content.find("\ndef ", start + 10)
if end == -1:
fail("Fin de validate_freeform introuvable.")
end += 1 # conserver le \\n final avant def suivant
shutil.copy2(TARGET, str(TARGET) + ".bak-fc6")
print(" + Sauvegarde : %s.bak-fc6" % TARGET)
content = content.replace(OLD_TYPES, NEW_TYPES)
# recalcul après P1
start = content.find("def validate_freeform(raw: str):")
end = content.find("\ndef ", start + 10) + 1
content = content[:start] + NEW_VALIDATE + "\n" + content[end:]
TARGET.write_text(content, encoding="utf-8")
print(" + Types + shapes + validateur 2 niveaux appliqués.")
try:
py_compile.compile(str(TARGET), doraise=True)
print(" + Compilation OK.")
except py_compile.PyCompileError as e:
shutil.copy2(str(TARGET) + ".bak-fc6", TARGET)
fail("Erreur de compilation — fichier restauré :\n%s" % e)
print("\n Prochaine étape : prompt_the_free_designer_v2.md dans "
"Mistral Studio (temp 0.7).")
if __name__ == "__main__":
main()
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_render_engine_c6.py Chantier C6 (Freeform palier 4)
===========================================================
« Charte minimale, vocabulaire maximal. » Patch strict de
render_engine_v2.py. PRÉREQUIS : C5 lot 2 appliqué (_blend, MSO_LINE).
P1. _token_color v2 dérivés paramétrés : 'navy@15' = navy éclairci
de 15 % vers blanc. L'espace couleur reste ENTIÈREMENT engendré
par la charte (aucun hex, jamais), mais passe de 9 couleurs à un
continuum de tons de marque. Regex stricte, inconnu défaut.
P2. _render_freeform v2 :
- background: <token[@NN]> au niveau slide (remplace/complète
mode light|dark ; la couleur du texte par défaut s'adapte).
- full_bleed: true par bloc la grille 12×12 mappe alors la
slide ENTIÈRE (aplats bord à bord, colonnes pleine hauteur).
- Nouveau type shape : chevron, arrow, triangle, pill, donut,
bracket_left/right, oval, diamond, hexagon, parallelogram,
moon (douzaine curée MSO_SHAPE) + texte centré optionnel.
- Propriétés transverses sur tout bloc : alpha (0-100),
rotation (arrondie au pas de 15°), border {color, weight},
radius (0-0.5, rects arrondis).
- line : diagonales natives (w ET h non nuls) + dash: true.
- Plafond moteur 15 blocs (troncature tracée le validateur
du facilitator bloque avant en usage normal).
Rien n'est ouvert sur : les polices, les hex, le footer/logo.
Usage (dossier du pipeline, single-line) :
python3 patches/patch_render_engine_c6.py
(ou depuis la racine si le script y est copié)
Vérifie chaque ancre, écrit .bak-c6, compile, idempotent.
"""
import py_compile
import shutil
import sys
from pathlib import Path
TARGET = Path("render_engine_v2.py")
OLD_TOKEN_COLOR = ''' def _token_color(self, token: str, default: str = None) -> str:
mapping = {
"navy": self.C["navy"], "navy_light": self.C["navy2"],
"coral": self.C["coral"], "glacier": self.C["glacier"],
"slate": self.C["slate"], "card": self.C["card"],
"white": self.C["white"], "body": self.C["body"],
"muted": self.C["muted"],
}
return mapping.get((token or "").strip().lower(),
default or self.C["navy"])
'''
NEW_TOKEN_COLOR = ''' _TOKEN_RE = re.compile(r"^([a-z_]+)(?:@(\\d{1,3}))?$")
def _token_color(self, token: str, default: str = None) -> str:
"""Tokens de la charte + dérivés paramétrés (C6, palier 4) :
'navy@15' = navy éclairci de 15 % vers blanc. L'espace couleur
reste engendré par la charte jamais de hex."""
mapping = {
"navy": self.C["navy"], "navy_light": self.C["navy2"],
"coral": self.C["coral"], "glacier": self.C["glacier"],
"slate": self.C["slate"], "card": self.C["card"],
"card_alt": self.C["card_alt"],
"white": self.C["white"], "body": self.C["body"],
"muted": self.C["muted"],
}
m = self._TOKEN_RE.match((token or "").strip().lower())
if not m:
return default or self.C["navy"]
base = mapping.get(m.group(1))
if base is None:
return default or self.C["navy"]
if m.group(2) is not None:
pct = min(100, int(m.group(2)))
return _blend(base, "#FFFFFF", pct / 100.0)
return base
'''
OLD_FREEFORM_START = " def _render_freeform(self, slide, d):\n"
OLD_FREEFORM_END = (' '
'PP_ALIGN.LEFT)\n')
NEW_FREEFORM = ''' FREE_SHAPES = {
"chevron": MSO_SHAPE.CHEVRON,
"arrow": MSO_SHAPE.RIGHT_ARROW,
"triangle": MSO_SHAPE.ISOSCELES_TRIANGLE,
"pill": MSO_SHAPE.ROUNDED_RECTANGLE,
"donut": MSO_SHAPE.DONUT,
"bracket_left": MSO_SHAPE.LEFT_BRACKET,
"bracket_right": MSO_SHAPE.RIGHT_BRACKET,
"oval": MSO_SHAPE.OVAL,
"diamond": MSO_SHAPE.DIAMOND,
"hexagon": MSO_SHAPE.HEXAGON,
"parallelogram": MSO_SHAPE.PARALLELOGRAM,
"moon": MSO_SHAPE.MOON,
}
_DARK_BASES = {"navy", "navy_light", "slate", "body"}
FREE_MAX_BLOCKS = 15
def _set_fill_alpha(self, sh, alpha_pct):
"""Transparence du remplissage (voile) — silencieux si le
shape n'a pas de solidFill (textbox…)."""
try:
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
qn("a:srgbClr"))
a = srgb.makeelement(
qn("a:alpha"),
{"val": str(int(max(0.0, min(100.0,
float(alpha_pct))) * 1000))})
srgb.append(a)
except Exception:
pass
def _free_geom(self, blk, full_bleed):
"""Grille 12×12 : zone utile par défaut, slide entière en
full_bleed."""
col = float(blk.get("col", 0))
row = float(blk.get("row", 0))
wc = float(blk.get("w", 4))
hr = float(blk.get("h", 1))
if full_bleed:
return ((col / self.FREE_COLS) * self.SLIDE_W,
(row / self.FREE_ROWS) * self.SLIDE_H,
(wc / self.FREE_COLS) * self.SLIDE_W,
(hr / self.FREE_ROWS) * self.SLIDE_H)
return (self._free_x(col), self._free_y(row),
self._free_w(wc), self._free_h(hr))
def _free_decorate(self, sh, blk):
"""Propriétés transverses du palier 4 : alpha, rotation (pas
de 15°), border {color, weight}, radius. Silencieux quand une
propriété ne s'applique pas au type de shape."""
if sh is None:
return
if blk.get("alpha") is not None:
self._set_fill_alpha(sh, blk["alpha"])
rot = blk.get("rotation")
if rot:
try:
sh.rotation = (round(float(rot) / 15.0) * 15) % 360
except Exception:
pass
border = blk.get("border")
if isinstance(border, dict):
try:
sh.line.color.rgb = hex_to_rgb(self._token_color(
border.get("color"), self.C["navy"]))
sh.line.width = Pt(float(border.get("weight", 1.0)))
except Exception:
pass
if blk.get("radius") is not None:
try:
sh.adjustments[0] = max(0.0, min(
0.5, float(blk["radius"])))
except Exception:
pass
def _render_freeform(self, slide, d):
mode = d.get("mode", "light")
bg = d.get("background")
on_dark = (mode == "dark")
if bg:
self._bg(slide, self._token_color(bg, self.C["white"]))
m = self._TOKEN_RE.match(str(bg).strip().lower())
if m and m.group(1) in self._DARK_BASES:
pct = int(m.group(2)) if m.group(2) is not None else 0
on_dark = pct < 45
else:
on_dark = False
elif on_dark:
self._bg(slide, self.C["navy"])
default_text = self.C["white"] if on_dark else self.C["body"]
blocks = d.get("blocks", [])
if len(blocks) > self.FREE_MAX_BLOCKS:
print(f" ~ freeform : {len(blocks)} blocs → "
f"{self.FREE_MAX_BLOCKS} (plafond moteur)")
blocks = blocks[:self.FREE_MAX_BLOCKS]
for blk in blocks:
btype = (blk.get("type") or "text").lower()
fb = bool(blk.get("full_bleed"))
x, y, w, h = self._free_geom(blk, fb)
sh = None
if btype == "rect":
sh = self._rect(slide, x, y, w, h,
self._token_color(blk.get("color"),
self.C["card"]),
rounded=blk.get("rounded", False))
elif btype == "card":
sh = self._card(slide, x, y, w, h,
self._token_color(blk.get("color"),
self.C["card"]))
elif btype == "circle":
d_cm = min(w, h)
sh = self._oval(slide, x, y, d_cm,
self._token_color(blk.get("color"),
self.C["coral"]))
elif btype == "shape":
kind = self.FREE_SHAPES.get(
str(blk.get("shape") or "oval").lower())
if kind is None:
print(f" ~ freeform : shape "
f"'{blk.get('shape')}' inconnue → oval")
kind = MSO_SHAPE.OVAL
sh = slide.shapes.add_shape(
kind, Cm(x), Cm(y), Cm(w), Cm(h))
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(
self._token_color(blk.get("color"),
self.C["coral"]))
sh.line.fill.background()
sh.shadow.inherit = False
if blk.get("text"):
tf = sh.text_frame
tf.word_wrap = True
tf.text = str(blk["text"])
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
for r in p.runs:
r.font.name = self.F_BODY
r.font.size = Pt(int(blk.get("size", 14)))
r.font.bold = True
r.font.color.rgb = hex_to_rgb(
self._token_color(blk.get("text_color"),
self.C["white"]))
elif btype == "line":
sh = slide.shapes.add_connector(
1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))
sh.line.color.rgb = hex_to_rgb(
self._token_color(blk.get("color"),
self.C["muted"]))
sh.line.width = Pt(float(blk.get("weight", 1.25)))
if blk.get("dash"):
sh.line.dash_style = MSO_LINE.DASH
sh.shadow.inherit = False
sh = None # pas de décoration fill sur ligne
elif btype == "image":
sh = self._image(slide, x, y, w, h,
blk.get("image") or blk.get("src", ""),
fit=str(blk.get("fit") or "cover"))
elif btype == "badge":
d_cm = min(w, h)
sh = self._badge(slide, x + d_cm / 2, y + d_cm / 2,
d_cm, blk.get("text", ""),
fill=self._token_color(
blk.get("color"), self.C["navy"]))
elif btype == "stat":
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=self.F_DISPLAY,
size=int(blk.get("size", 72)),
bold=True,
color=self._token_color(
blk.get("color"), self.C["coral"]),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.MIDDLE)
elif btype in ("title", "heading"):
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=self.F_DISPLAY,
size=int(blk.get("size", 28)),
bold=True,
color=self._token_color(
blk.get("color"),
self.C["white"] if on_dark
else self.C["navy"]),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.MIDDLE)
else: # text
font = self.F_DISPLAY if blk.get("serif") \
else self.F_BODY
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=font,
size=int(blk.get("size", 16)),
bold=blk.get("bold", False),
italic=blk.get("italic", False),
color=self._token_color(
blk.get("color"), default_text),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.TOP)
self._free_decorate(sh, blk)
def _free_align(self, a: str):
return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER,
"right": PP_ALIGN.RIGHT}.get((a or "left").lower(),
PP_ALIGN.LEFT)
'''
MARKER = "_free_decorate"
PREREQ = "_blend"
def fail(msg):
print(" ! %s" % msg)
sys.exit(1)
def main():
if not TARGET.exists():
fail("%s introuvable — lancer depuis le dossier du pipeline."
% TARGET)
content = TARGET.read_text(encoding="utf-8")
if MARKER in content:
fail("Déjà patché (_free_decorate présent) — rien à faire.")
if ("def " + PREREQ) not in content:
fail("Prérequis manquant : C5 lot 2 (_blend) doit être appliqué "
"avant C6.")
# P1 — _token_color
if content.count(OLD_TOKEN_COLOR) != 1:
fail("Ancre _token_color introuvable ou non unique — moteur "
"inattendu.")
# P2 — bloc freeform complet (de def _render_freeform à la fin de
# _free_align)
start = content.find(OLD_FREEFORM_START)
if start == -1:
fail("Ancre _render_freeform introuvable.")
end = content.find(OLD_FREEFORM_END, start)
if end == -1:
fail("Fin du bloc freeform (_free_align) introuvable — moteur "
"inattendu.")
end += len(OLD_FREEFORM_END)
shutil.copy2(TARGET, str(TARGET) + ".bak-c6")
print(" + Sauvegarde : %s.bak-c6" % TARGET)
content = content.replace(OLD_TOKEN_COLOR, NEW_TOKEN_COLOR)
# recalcul des offsets après P1
start = content.find(OLD_FREEFORM_START)
end = content.find(OLD_FREEFORM_END, start) + len(OLD_FREEFORM_END)
content = content[:start] + NEW_FREEFORM + content[end:]
TARGET.write_text(content, encoding="utf-8")
print(" + _token_color v2 (dérivés @NN) + freeform palier 4 "
"appliqués.")
try:
py_compile.compile(str(TARGET), doraise=True)
print(" + Compilation OK.")
except py_compile.PyCompileError as e:
shutil.copy2(str(TARGET) + ".bak-c6", TARGET)
fail("Erreur de compilation — fichier restauré :\n%s" % e)
print("\n Suite C6 : python3 patches/patch_facilitator_c6.py "
"(validateur 2 niveaux),")
print(" puis remplacer le prompt Free Designer dans Mistral Studio"
" (v2, temp 0.7).")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_render_engine_c6b.py Finition C6 (ombres parasites des shapes)
======================================================================
Les blocs freeform « shape » (add_shape) portent un élément <p:style>
implicite que LibreOffice/PowerPoint interprètent avec une ombre par
défaut shadow.inherit=False ne suffit pas. Ce patch retire l'élément
de style à la création : rendu strictement plat, conforme à la charte.
Usage (dossier du pipeline, single-line) :
python3 patch_render_engine_c6b.py
Prérequis : C6 appliqué. Vérifie l'ancre, écrit .bak-c6b, compile,
idempotent.
"""
import py_compile
import shutil
import sys
from pathlib import Path
TARGET = Path("render_engine_v2.py")
PATCHES = [
(
' sh.line.fill.background()\n'
' sh.shadow.inherit = False\n'
' if blk.get("text"):\n',
' sh.line.fill.background()\n'
' sh.shadow.inherit = False\n'
' _st = sh._element.find(qn("p:style"))\n'
' if _st is not None: # style implicite = '
'ombre\n'
' sh._element.remove(_st)\n'
' if blk.get("text"):\n',
),
(
' if blk.get("dash"):\n'
' sh.line.dash_style = MSO_LINE.DASH\n'
' sh.shadow.inherit = False\n'
' sh = None # pas de décoration fill sur '
'ligne\n',
' if blk.get("dash"):\n'
' sh.line.dash_style = MSO_LINE.DASH\n'
' sh.shadow.inherit = False\n'
' _st = sh._element.find(qn("p:style"))\n'
' if _st is not None:\n'
' sh._element.remove(_st)\n'
' sh = None # pas de décoration fill sur '
'ligne\n',
),
]
MARKER = 'sh._element.remove(_st)'
def fail(msg):
print(" ! %s" % msg)
sys.exit(1)
def main():
if not TARGET.exists():
fail("%s introuvable — lancer depuis le dossier du pipeline."
% TARGET)
content = TARGET.read_text(encoding="utf-8")
if MARKER in content:
fail("Déjà patché — rien à faire.")
for i, (old, _) in enumerate(PATCHES, 1):
n = content.count(old)
if n == 0:
fail("Ancre %d introuvable — le patch C6 est-il appliqué ?"
% i)
if n > 1:
fail("Ancre %d non unique (%d occurrences)." % (i, n))
shutil.copy2(TARGET, str(TARGET) + ".bak-c6b")
for old, new in PATCHES:
content = content.replace(old, new)
TARGET.write_text(content, encoding="utf-8")
print(" + Style implicite retiré des shapes et lignes freeform.")
try:
py_compile.compile(str(TARGET), doraise=True)
print(" + Compilation OK.")
except py_compile.PyCompileError as e:
shutil.copy2(str(TARGET) + ".bak-c6b", TARGET)
fail("Erreur de compilation — fichier restauré :\n%s" % e)
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
# THE FREE DESIGNER — v2 (palier 4)
# Sliding Pipeline v11 — Pernod Ricard · Flux libre
# Mistral Large · Temperature 0.7 · Format : YAML freeform
## RÔLE
Tu es The Free Designer. Tu interviens dans le **flux libre**, pour les
présentations stratégiques qui ont besoin de compositions sur mesure plutôt
que de layouts prédéfinis.
Tu reçois un Markdown narratif (issu du Narrator) et tu produis directement
un **YAML freeform** : chaque slide est composée librement de blocs
positionnés sur une grille, dans le respect ABSOLU de la charte Pernod Ricard.
Tu es un directeur artistique, pas un remplisseur de gabarits. Tu composes
avec audace — la charte est ton cadre, pas ta cage. Le validateur et la
prévisualisation te rattrapent : ose.
---
## LA CHARTE PERNOD RICARD (IMPOSÉE — non négociable)
**Couleurs** — uniquement les tokens et leurs dérivés (JAMAIS de code hex) :
- `navy` : bleu nuit dominant (fonds sombres, titres)
- `navy_light` : navy plus clair (cercles décoratifs sur fond sombre)
- `coral` : accent unique (chiffres, badges, lignes d'accent)
- `glacier` : bleu clair (sous-titres sur fond sombre, 3e couleur)
- `slate` : gris ardoise (2e couleur de cycle)
- `card` / `card_alt` : fonds de cartes (gris chaud / bleuté)
- `white`, `body`, `muted` : blanc, texte courant, légendes
**NOUVEAU — les dérivés `token@NN`** : chaque token peut être éclairci vers
le blanc de NN % : `navy@15` (teinte légère), `navy@70` (pastel),
`coral@30`, `glacier@50`… Tu disposes d'un continuum de tons de marque,
comme un DA qui décline sa palette. C'est TA nuance ; le hex reste interdit.
**Règle d'accent** : le corail est rare et précieux. Un seul élément corail
dominant par slide. Le navy et ses dérivés dominent.
**Polices** (gérées automatiquement — tu ne les choisis jamais) :
serif (Cambria) pour `title`/`heading`/`stat` et `serif: true` ;
sans-serif (Calibri) pour le reste.
**Rythme sandwich** : ouvertures, transitions et conclusions sombres ;
contenu clair. Tu peux affiner avec `background:`.
---
## LA GRILLE
12 colonnes × 12 lignes sur la zone utile. Chaque bloc : `col`, `row`,
`w`, `h`. Jamais `col + w > 12` ni `row + h > 12`.
**NOUVEAU — `full_bleed: true`** sur un bloc : sa grille mappe alors la
slide ENTIÈRE, bord à bord (aplats pleine page, colonne de couleur pleine
hauteur, image jusqu'aux bords). Réserve-le aux fonds et grandes masses ;
le texte reste dans la zone utile.
**NOUVEAU — `background: <token[@NN]>`** au niveau de la slide : fond de
n'importe quel ton de marque (`background: navy@92` = presque blanc bleuté,
`background: card` = gris chaud). La couleur de texte par défaut s'adapte.
**Garde-fous de composition :**
- Laisse de l'air. Le vide est un outil. UN point focal par slide.
- Aligne les blocs entre eux (mêmes `col` ou mêmes `row`).
- Maximum 15 blocs ; au-delà de 10, tu reçois un avertissement — et tu
as probablement tort.
---
## LES TYPES DE BLOCS
**Textes** (inchangés) :
- `title` — grand titre serif (défaut 28, `size:` libre)
- `heading` — intertitre serif
- `text` — corps sans-serif (`bold:`, `italic:`, `serif:`, `size:`, `align:`)
- `stat` — chiffre héros serif corail (défaut 72)
**Formes pleines** :
- `rect` — rectangle (`rounded: true`, `radius: 0-0.5`)
- `card` — carte avec ombre douce
- `circle` — cercle (diamètre = min(w, h))
- `badge` — pastille numérotée (`text:`)
- **NOUVEAU `shape`** — forme de la bibliothèque curée :
`chevron`, `arrow`, `triangle`, `pill`, `donut`, `bracket_left`,
`bracket_right`, `oval`, `diamond`, `hexagon`, `parallelogram`, `moon`.
Texte centré optionnel (`text:`, `text_color:`, `size:`).
**Lignes** :
- `line` — du coin (col,row) au coin (col+w, row+h). **NOUVEAU : les
diagonales sont permises** (w ET h non nuls) et `dash: true` pointille.
**Image** :
- `image` — fichier de assets/ (`image:`, `fit: cover|contain`).
**NOUVEAU — propriétés transverses** (sur tout bloc plein) :
- `alpha: 0-100` — transparence du remplissage (voiles, superpositions)
- `rotation:` — par pas de 15° (±15, 30, 45…) : dynamise un shape, un stat
- `border: {color: token, weight: pt}` — contour (rect/card/shape)
- `full_bleed: true` — voir grille
---
## LES ARCHÉTYPES — compose par variation, pas par règle
Cinq compositions de référence. Ne les copie pas : varie-les, hybride-les,
détourne-les selon le contenu.
### 1. Éditorial pleine page (ouverture de partie, idée forte)
```yaml
- layout: freeform
background: navy
blocks:
- {type: shape, shape: oval, col: 8, row: -0, w: 8, h: 8, full_bleed: true,
color: navy_light, alpha: 55}
- {type: line, col: 0, row: 3, w: 2, h: 0, color: coral, weight: 3}
- {type: title, text: "L'idée qui change tout", col: 0, row: 3.6, w: 8,
h: 3, size: 40}
- {type: text, text: "Une phrase d'appui, sobre, glacier.", col: 0,
row: 7, w: 7, h: 1.5, color: glacier, italic: true, size: 17}
```
→ masse décorative full_bleed voilée, filet corail, titre à gauche, air.
### 2. Poster chiffre héros (un chiffre qui doit marquer)
```yaml
- layout: freeform
background: navy@94
blocks:
- {type: shape, shape: donut, col: 7.5, row: 2, w: 4.5, h: 4.5,
color: coral@70, alpha: 60}
- {type: stat, text: "3,4 M€", col: 1, row: 3.5, w: 8, h: 4, size: 110,
color: coral}
- {type: text, text: "d'économies identifiées sur le cycle 2026",
col: 1, row: 8, w: 7, h: 1.2, size: 18, color: body}
- {type: text, text: "Source : contrôle de gestion", col: 1, row: 9.4,
w: 6, h: 1, size: 11, color: muted, italic: true}
```
→ fond teinté à peine perceptible, écho géométrique derrière le chiffre.
### 3. Diptyque asymétrique 4/8 (tension entre deux idées)
```yaml
- layout: freeform
blocks:
- {type: rect, col: 0, row: 0, w: 4, h: 12, full_bleed: true, color: navy}
- {type: heading, text: "Avant", col: 0.5, row: 4, w: 3, h: 1.5,
color: white, size: 24}
- {type: text, text: "Données dispersées, décisions lentes.", col: 0.5,
row: 5.6, w: 3, h: 3, color: glacier, size: 14}
- {type: heading, text: "Après", col: 5, row: 4, w: 6, h: 1.5, size: 24}
- {type: text, text: "Un actif gouverné qui accélère chaque arbitrage.",
col: 5, row: 5.6, w: 6, h: 3, size: 16}
- {type: shape, shape: chevron, col: 3.6, row: 5.2, w: 1.2, h: 1.2,
color: coral}
```
→ colonne navy pleine hauteur (full_bleed), chevron corail de passage.
### 4. Data-hero (une donnée mise en scène, pas un graphique)
```yaml
- layout: freeform
blocks:
- {type: shape, shape: triangle, col: 8, row: 1, w: 3.5, h: 3.5,
color: glacier@40, rotation: 15}
- {type: title, text: "La qualité progresse", col: 0, row: 1, w: 7, h: 2}
- {type: stat, text: "61 %", col: 0, row: 3.6, w: 6, h: 3.2, size: 84}
- {type: text, text: "d'incidents qualité en huit trimestres", col: 0,
row: 7, w: 6, h: 1.2, size: 17}
- {type: line, col: 7, row: 9.5, w: 4, h: -3, color: navy, weight: 2.5}
- {type: circle, col: 10.7, row: 6.2, w: 0.55, h: 0.55, color: coral}
```
→ la diagonale ascendante DESSINE la tendance ; le point corail la termine.
### 5. Manifeste typographique (conviction, conclusion)
```yaml
- layout: freeform
background: navy
blocks:
- {type: shape, shape: bracket_left, col: 0.2, row: 2.5, w: 0.8, h: 7,
color: coral}
- {type: title, text: "La donnée n'est pas un coût.", col: 1.6, row: 3,
w: 10, h: 2.2, size: 36}
- {type: title, text: "C'est notre prochain avantage.", col: 1.6,
row: 5.4, w: 10, h: 2.2, size: 36, color: glacier}
- {type: text, text: "Data Governance — Comex, juillet 2026", col: 1.6,
row: 9.5, w: 8, h: 1, size: 12, color: muted}
```
→ deux lignes de force, un crochet corail, rien d'autre.
---
## CE QU'IL NE FAUT JAMAIS FAIRE
- Un code hex, une couleur hors tokens/dérivés → REFUSÉ par le validateur.
- Choisir une police, toucher au footer ou au logo (gérés par le moteur).
- Référencer une image absente de la liste fournie en contexte.
- Remplir la grille : plus de 10 blocs = tu as raté ta hiérarchie.
- Plusieurs éléments corail dominants sur la même slide.
## FORMAT DE SORTIE
Uniquement le YAML, sans commentaire ni Markdown autour :
```yaml
titre_presentation: ...
slides:
- position: 1
layout: freeform
mode: dark # ou background: <token[@NN]>
blocks:
- {type: ..., col: ..., row: ..., w: ..., h: ..., ...}
```
+205 -55
View File
@@ -1268,16 +1268,30 @@ class RenderEngineV2:
FREE_ROWS = 12 FREE_ROWS = 12
# Tokens de couleur autorisés (charte imposée — aucune couleur arbitraire) # Tokens de couleur autorisés (charte imposée — aucune couleur arbitraire)
_TOKEN_RE = re.compile(r"^([a-z_]+)(?:@(\d{1,3}))?$")
def _token_color(self, token: str, default: str = None) -> str: def _token_color(self, token: str, default: str = None) -> str:
"""Tokens de la charte + dérivés paramétrés (C6, palier 4) :
'navy@15' = navy éclairci de 15 % vers blanc. L'espace couleur
reste engendré par la charte jamais de hex."""
mapping = { mapping = {
"navy": self.C["navy"], "navy_light": self.C["navy2"], "navy": self.C["navy"], "navy_light": self.C["navy2"],
"coral": self.C["coral"], "glacier": self.C["glacier"], "coral": self.C["coral"], "glacier": self.C["glacier"],
"slate": self.C["slate"], "card": self.C["card"], "slate": self.C["slate"], "card": self.C["card"],
"card_alt": self.C["card_alt"],
"white": self.C["white"], "body": self.C["body"], "white": self.C["white"], "body": self.C["body"],
"muted": self.C["muted"], "muted": self.C["muted"],
} }
return mapping.get((token or "").strip().lower(), m = self._TOKEN_RE.match((token or "").strip().lower())
default or self.C["navy"]) if not m:
return default or self.C["navy"]
base = mapping.get(m.group(1))
if base is None:
return default or self.C["navy"]
if m.group(2) is not None:
pct = min(100, int(m.group(2)))
return _blend(base, "#FFFFFF", pct / 100.0)
return base
def _free_x(self, col: float) -> float: def _free_x(self, col: float) -> float:
"""Colonne de grille (0-12) → position x en cm (dans la zone utile).""" """Colonne de grille (0-12) → position x en cm (dans la zone utile)."""
@@ -1298,87 +1312,223 @@ class RenderEngineV2:
usable = self.FOOTER_Y - 0.4 - self.TITLE_Y usable = self.FOOTER_Y - 0.4 - self.TITLE_Y
return (rows / self.FREE_ROWS) * usable return (rows / self.FREE_ROWS) * usable
FREE_SHAPES = {
"chevron": MSO_SHAPE.CHEVRON,
"arrow": MSO_SHAPE.RIGHT_ARROW,
"triangle": MSO_SHAPE.ISOSCELES_TRIANGLE,
"pill": MSO_SHAPE.ROUNDED_RECTANGLE,
"donut": MSO_SHAPE.DONUT,
"bracket_left": MSO_SHAPE.LEFT_BRACKET,
"bracket_right": MSO_SHAPE.RIGHT_BRACKET,
"oval": MSO_SHAPE.OVAL,
"diamond": MSO_SHAPE.DIAMOND,
"hexagon": MSO_SHAPE.HEXAGON,
"parallelogram": MSO_SHAPE.PARALLELOGRAM,
"moon": MSO_SHAPE.MOON,
}
_DARK_BASES = {"navy", "navy_light", "slate", "body"}
FREE_MAX_BLOCKS = 15
def _set_fill_alpha(self, sh, alpha_pct):
"""Transparence du remplissage (voile) — silencieux si le
shape n'a pas de solidFill (textbox…)."""
try:
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
qn("a:srgbClr"))
a = srgb.makeelement(
qn("a:alpha"),
{"val": str(int(max(0.0, min(100.0,
float(alpha_pct))) * 1000))})
srgb.append(a)
except Exception:
pass
def _free_geom(self, blk, full_bleed):
"""Grille 12×12 : zone utile par défaut, slide entière en
full_bleed."""
col = float(blk.get("col", 0))
row = float(blk.get("row", 0))
wc = float(blk.get("w", 4))
hr = float(blk.get("h", 1))
if full_bleed:
return ((col / self.FREE_COLS) * self.SLIDE_W,
(row / self.FREE_ROWS) * self.SLIDE_H,
(wc / self.FREE_COLS) * self.SLIDE_W,
(hr / self.FREE_ROWS) * self.SLIDE_H)
return (self._free_x(col), self._free_y(row),
self._free_w(wc), self._free_h(hr))
def _free_decorate(self, sh, blk):
"""Propriétés transverses du palier 4 : alpha, rotation (pas
de 15°), border {color, weight}, radius. Silencieux quand une
propriété ne s'applique pas au type de shape."""
if sh is None:
return
if blk.get("alpha") is not None:
self._set_fill_alpha(sh, blk["alpha"])
rot = blk.get("rotation")
if rot:
try:
sh.rotation = (round(float(rot) / 15.0) * 15) % 360
except Exception:
pass
border = blk.get("border")
if isinstance(border, dict):
try:
sh.line.color.rgb = hex_to_rgb(self._token_color(
border.get("color"), self.C["navy"]))
sh.line.width = Pt(float(border.get("weight", 1.0)))
except Exception:
pass
if blk.get("radius") is not None:
try:
sh.adjustments[0] = max(0.0, min(
0.5, float(blk["radius"])))
except Exception:
pass
def _render_freeform(self, slide, d): def _render_freeform(self, slide, d):
mode = d.get("mode", "light") mode = d.get("mode", "light")
bg = d.get("background")
on_dark = (mode == "dark") on_dark = (mode == "dark")
if on_dark: if bg:
self._bg(slide, self._token_color(bg, self.C["white"]))
m = self._TOKEN_RE.match(str(bg).strip().lower())
if m and m.group(1) in self._DARK_BASES:
pct = int(m.group(2)) if m.group(2) is not None else 0
on_dark = pct < 45
else:
on_dark = False
elif on_dark:
self._bg(slide, self.C["navy"]) self._bg(slide, self.C["navy"])
default_text = self.C["white"] if on_dark else self.C["body"] default_text = self.C["white"] if on_dark else self.C["body"]
for blk in d.get("blocks", []): blocks = d.get("blocks", [])
if len(blocks) > self.FREE_MAX_BLOCKS:
print(f" ~ freeform : {len(blocks)} blocs → "
f"{self.FREE_MAX_BLOCKS} (plafond moteur)")
blocks = blocks[:self.FREE_MAX_BLOCKS]
for blk in blocks:
btype = (blk.get("type") or "text").lower() btype = (blk.get("type") or "text").lower()
col = float(blk.get("col", 0)) fb = bool(blk.get("full_bleed"))
row = float(blk.get("row", 0)) x, y, w, h = self._free_geom(blk, fb)
w_cols = float(blk.get("w", 4)) sh = None
h_rows = float(blk.get("h", 1))
x, y = self._free_x(col), self._free_y(row)
w, h = self._free_w(w_cols), self._free_h(h_rows)
if btype == "rect": if btype == "rect":
self._rect(slide, x, y, w, h, sh = self._rect(slide, x, y, w, h,
self._token_color(blk.get("color"), self.C["card"]), self._token_color(blk.get("color"),
rounded=blk.get("rounded", False)) self.C["card"]),
rounded=blk.get("rounded", False))
elif btype == "card": elif btype == "card":
self._card(slide, x, y, w, h, sh = self._card(slide, x, y, w, h,
self._token_color(blk.get("color"), self.C["card"])) self._token_color(blk.get("color"),
self.C["card"]))
elif btype == "circle": elif btype == "circle":
d_cm = min(w, h) d_cm = min(w, h)
self._oval(slide, x, y, d_cm, sh = self._oval(slide, x, y, d_cm,
self._token_color(blk.get("color"), self.C["coral"])) self._token_color(blk.get("color"),
self.C["coral"]))
elif btype == "shape":
kind = self.FREE_SHAPES.get(
str(blk.get("shape") or "oval").lower())
if kind is None:
print(f" ~ freeform : shape "
f"'{blk.get('shape')}' inconnue → oval")
kind = MSO_SHAPE.OVAL
sh = slide.shapes.add_shape(
kind, Cm(x), Cm(y), Cm(w), Cm(h))
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(
self._token_color(blk.get("color"),
self.C["coral"]))
sh.line.fill.background()
sh.shadow.inherit = False
_st = sh._element.find(qn("p:style"))
if _st is not None: # style implicite = ombre
sh._element.remove(_st)
if blk.get("text"):
tf = sh.text_frame
tf.word_wrap = True
tf.text = str(blk["text"])
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
for r in p.runs:
r.font.name = self.F_BODY
r.font.size = Pt(int(blk.get("size", 14)))
r.font.bold = True
r.font.color.rgb = hex_to_rgb(
self._token_color(blk.get("text_color"),
self.C["white"]))
elif btype == "line": elif btype == "line":
ln = slide.shapes.add_connector( sh = slide.shapes.add_connector(
1, Cm(x), Cm(y), Cm(x + w), Cm(y + h)) 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))
ln.line.color.rgb = hex_to_rgb( sh.line.color.rgb = hex_to_rgb(
self._token_color(blk.get("color"), self.C["muted"])) self._token_color(blk.get("color"),
ln.line.width = Pt(float(blk.get("weight", 1.25))) self.C["muted"]))
sh.line.width = Pt(float(blk.get("weight", 1.25)))
if blk.get("dash"):
sh.line.dash_style = MSO_LINE.DASH
sh.shadow.inherit = False
_st = sh._element.find(qn("p:style"))
if _st is not None:
sh._element.remove(_st)
sh = None # pas de décoration fill sur ligne
elif btype == "image": elif btype == "image":
self._image(slide, x, y, w, h, sh = self._image(slide, x, y, w, h,
blk.get("image") or blk.get("src", ""), blk.get("image") or blk.get("src", ""),
fit=str(blk.get("fit") or "cover")) fit=str(blk.get("fit") or "cover"))
elif btype == "badge": elif btype == "badge":
d_cm = min(w, h) d_cm = min(w, h)
self._badge(slide, x + d_cm / 2, y + d_cm / 2, d_cm, sh = self._badge(slide, x + d_cm / 2, y + d_cm / 2,
blk.get("text", ""), d_cm, blk.get("text", ""),
fill=self._token_color(blk.get("color"), fill=self._token_color(
self.C["navy"])) blk.get("color"), self.C["navy"]))
elif btype == "stat": elif btype == "stat":
# Grand chiffre — display, corail par défaut sh = self._text(slide, x, y, w, h, blk.get("text", ""),
self._text(slide, x, y, w, h, blk.get("text", ""), font=self.F_DISPLAY,
font=self.F_DISPLAY, size=int(blk.get("size", 72)),
size=int(blk.get("size", 72)), bold=True, bold=True,
color=self._token_color(blk.get("color"), color=self._token_color(
self.C["coral"]), blk.get("color"), self.C["coral"]),
align=self._free_align(blk.get("align", "left")), align=self._free_align(
anchor=MSO_ANCHOR.MIDDLE) blk.get("align", "left")),
anchor=MSO_ANCHOR.MIDDLE)
elif btype in ("title", "heading"): elif btype in ("title", "heading"):
self._text(slide, x, y, w, h, blk.get("text", ""), sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=self.F_DISPLAY, font=self.F_DISPLAY,
size=int(blk.get("size", 28)), bold=True, size=int(blk.get("size", 28)),
color=self._token_color( bold=True,
blk.get("color"), color=self._token_color(
self.C["white"] if on_dark else self.C["navy"]), blk.get("color"),
align=self._free_align(blk.get("align", "left")), self.C["white"] if on_dark
anchor=MSO_ANCHOR.MIDDLE) else self.C["navy"]),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.MIDDLE)
else: # text else: # text
# Police body, ou display si explicitement demandé font = self.F_DISPLAY if blk.get("serif") else self.F_BODY
font = self.F_DISPLAY if blk.get("serif") else self.F_BODY sh = self._text(slide, x, y, w, h, blk.get("text", ""),
self._text(slide, x, y, w, h, blk.get("text", ""), font=font,
font=font, size=int(blk.get("size", 16)),
size=int(blk.get("size", 16)), bold=blk.get("bold", False),
bold=blk.get("bold", False), italic=blk.get("italic", False),
italic=blk.get("italic", False), color=self._token_color(
color=self._token_color(blk.get("color"), blk.get("color"), default_text),
default_text), align=self._free_align(
align=self._free_align(blk.get("align", "left")), blk.get("align", "left")),
anchor=MSO_ANCHOR.TOP) anchor=MSO_ANCHOR.TOP)
self._free_decorate(sh, blk)
def _free_align(self, a: str): def _free_align(self, a: str):
return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER,