feat: flux libre freeform (Free Designer) - palier 3, charte imposee

This commit is contained in:
2026-06-19 16:23:15 +02:00
parent 6f0a8b7704
commit 41d13c4a8f
3 changed files with 1766 additions and 6 deletions
+141 -6
View File
@@ -1102,6 +1102,132 @@ class RenderEngineV2:
item.get("label", ""), size=10, color=self.C["body"])
# ── FLUX LIBRE — freeform (palier 3, charte imposée) ──────────────────────
#
# Une slide freeform : { layout: freeform, mode: light|dark, blocks: [...] }
# Chaque bloc est positionné sur une grille 12x12 (col 0-12, row 0-12),
# ce qui borne le positionnement et évite les débordements.
# Les couleurs ne peuvent être que des TOKENS de charte (imposé).
# Grille libre : 12 colonnes, 12 lignes, sur la zone utile (hors marges)
FREE_COLS = 12
FREE_ROWS = 12
# Tokens de couleur autorisés (charte imposée — aucune couleur arbitraire)
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"])
def _free_x(self, col: float) -> float:
"""Colonne de grille (0-12) → position x en cm (dans la zone utile)."""
usable = self.SLIDE_W - 2 * self.MX
return self.MX + (col / self.FREE_COLS) * usable
def _free_y(self, row: float) -> float:
"""Ligne de grille (0-12) → position y en cm (zone titre→footer)."""
top = self.TITLE_Y
usable = self.FOOTER_Y - 0.4 - top
return top + (row / self.FREE_ROWS) * usable
def _free_w(self, cols: float) -> float:
usable = self.SLIDE_W - 2 * self.MX
return (cols / self.FREE_COLS) * usable
def _free_h(self, rows: float) -> float:
usable = self.FOOTER_Y - 0.4 - self.TITLE_Y
return (rows / self.FREE_ROWS) * usable
def _render_freeform(self, slide, d):
mode = d.get("mode", "light")
on_dark = (mode == "dark")
if 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", []):
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)
if btype == "rect":
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"]))
elif btype == "circle":
d_cm = min(w, h)
self._oval(slide, x, y, d_cm,
self._token_color(blk.get("color"), self.C["coral"]))
elif btype == "line":
ln = 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)))
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"]))
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)
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)
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)
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)
# ---------------- orchestration ----------------
REGISTRY = {
"cover_split": "_render_cover_split",
@@ -1117,6 +1243,7 @@ class RenderEngineV2:
"phases_timeline": "_render_phases_timeline",
"recommendation_card": "_render_recommendation_card",
"end_slide": "_render_end_slide",
"freeform": "_render_freeform",
"from_to_pairs": "_render_from_to_pairs",
"gantt_timeline": "_render_gantt_timeline",
"yearly_timeline": "_render_yearly_timeline",
@@ -1148,12 +1275,20 @@ class RenderEngineV2:
print(f" ⚠ Layout inconnu '{layout}' → default_bullets")
method = "_render_default_bullets"
layout = "default_bullets"
mode = self.layouts.get(layout, {}).get("mode", "light")
if mode == "light":
self._bg(slide, self.C["white"])
getattr(self, method)(slide, sd)
if layout not in excluded and layout != "recommendation_card":
self._footer(slide, i + 1)
if layout == "freeform":
# Le freeform gère son fond lui-même selon sd["mode"]
if sd.get("mode", "light") == "light":
self._bg(slide, self.C["white"])
getattr(self, method)(slide, sd)
if sd.get("footer", True):
self._footer(slide, i + 1)
else:
mode = self.layouts.get(layout, {}).get("mode", "light")
if mode == "light":
self._bg(slide, self.C["white"])
getattr(self, method)(slide, sd)
if layout not in excluded and layout != "recommendation_card":
self._footer(slide, i + 1)
prs.save(output_path)
print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)")