#!/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()