feat: freeform palier 4 - derives de tokens, shapes, alpha, rotation, full_bleed (C6)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user