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
+205 -55
View File
@@ -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,