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
+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()