From 3929d14e9fbee8adae6eb1d0b5d31c8d99456768 Mon Sep 17 00:00:00 2001 From: Master Date: Sun, 12 Jul 2026 08:14:57 +0200 Subject: [PATCH] feat: freeform palier 4 - derives de tokens, shapes, alpha, rotation, full_bleed (C6) --- facilitator_v9.py | 98 ++++++-- patches/patch_facilitator_c6.py | 206 ++++++++++++++++ patches/patch_render_engine_c6.py | 365 +++++++++++++++++++++++++++++ patches/patch_render_engine_c6b.py | 91 +++++++ prompt_the_free_designer_v2.md | 210 +++++++++++++++++ render_engine_v2.py | 260 +++++++++++++++----- 6 files changed, 1160 insertions(+), 70 deletions(-) create mode 100644 patches/patch_facilitator_c6.py create mode 100644 patches/patch_render_engine_c6.py create mode 100644 patches/patch_render_engine_c6b.py create mode 100644 prompt_the_free_designer_v2.md diff --git a/facilitator_v9.py b/facilitator_v9.py index 82d5cf3..36d5004 100644 --- a/facilitator_v9.py +++ b/facilitator_v9.py @@ -1143,11 +1143,19 @@ def list_assets(proj: "Project"): VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate", "card", "white", "body", "muted"} 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): - """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: data = yaml.safe_load(extract_yaml(raw)) except yaml.YAMLError as e: @@ -1157,35 +1165,95 @@ def validate_freeform(raw: str): slides = data["slides"] if not slides: 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): if s.get("layout") != "freeform": errors.append(f"Slide {i} : layout doit être 'freeform'") continue - if s.get("mode") not in ("light", "dark"): - errors.append(f"Slide {i} : mode doit être 'light' ou 'dark'") + 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) > 8: - errors.append(f"Slide {i} : {len(blocks)} blocs (max 8)") + 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"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)) row = float(b.get("row", 0)); h = float(b.get("h", 1)) 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: - errors.append(f"Slide {i} bloc {j} : déborde (row+h={row+h:.1f}>12)") - color = b.get("color") - if color and color not in VALID_TOKENS: - errors.append(f"Slide {i} bloc {j} : couleur '{color}' hors charte") + 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 - 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): """Flux libre : le Free Designer produit directement le YAML freeform.""" diff --git a/patches/patch_facilitator_c6.py b/patches/patch_facilitator_c6.py new file mode 100644 index 0000000..4983801 --- /dev/null +++ b/patches/patch_facilitator_c6.py @@ -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() diff --git a/patches/patch_render_engine_c6.py b/patches/patch_render_engine_c6.py new file mode 100644 index 0000000..b49884f --- /dev/null +++ b/patches/patch_render_engine_c6.py @@ -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: 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() diff --git a/patches/patch_render_engine_c6b.py b/patches/patch_render_engine_c6b.py new file mode 100644 index 0000000..bb21297 --- /dev/null +++ b/patches/patch_render_engine_c6b.py @@ -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 +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() diff --git a/prompt_the_free_designer_v2.md b/prompt_the_free_designer_v2.md new file mode 100644 index 0000000..f7b1ac6 --- /dev/null +++ b/prompt_the_free_designer_v2.md @@ -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: `** 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: + blocks: + - {type: ..., col: ..., row: ..., w: ..., h: ..., ...} +``` diff --git a/render_engine_v2.py b/render_engine_v2.py index d67b72c..b880bc3 100644 --- a/render_engine_v2.py +++ b/render_engine_v2.py @@ -1268,16 +1268,30 @@ class RenderEngineV2: FREE_ROWS = 12 # 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: + """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"], } - return mapping.get((token or "").strip().lower(), - default or self.C["navy"]) + 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 def _free_x(self, col: float) -> float: """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 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): mode = d.get("mode", "light") + bg = d.get("background") 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"]) 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() - col = float(blk.get("col", 0)) - row = float(blk.get("row", 0)) - w_cols = float(blk.get("w", 4)) - 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) + fb = bool(blk.get("full_bleed")) + x, y, w, h = self._free_geom(blk, fb) + sh = None if btype == "rect": - self._rect(slide, x, y, w, h, - self._token_color(blk.get("color"), self.C["card"]), - rounded=blk.get("rounded", False)) + 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": - self._card(slide, x, y, w, h, - self._token_color(blk.get("color"), self.C["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) - self._oval(slide, x, y, d_cm, - self._token_color(blk.get("color"), self.C["coral"])) + 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 + _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": - ln = slide.shapes.add_connector( + sh = slide.shapes.add_connector( 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h)) - ln.line.color.rgb = hex_to_rgb( - self._token_color(blk.get("color"), self.C["muted"])) - ln.line.width = Pt(float(blk.get("weight", 1.25))) + 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 + _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": - self._image(slide, x, y, w, h, - blk.get("image") or blk.get("src", ""), - fit=str(blk.get("fit") or "cover")) + 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) - 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"])) + 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": - # Grand chiffre — display, corail par défaut - 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) + 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"): - 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) + 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 - # Police body, ou display si explicitement demandé - font = self.F_DISPLAY if blk.get("serif") else self.F_BODY - 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) + 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,