Files
sliding-automation/render_engine_v2.py
T

1322 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
============================================================
RENDER ENGINE V2 — Design System "PR Editorial"
SlidingAutomation / Pernod Ricard
============================================================
Principes (vs v1) :
1. Grille fixe 3 zones (titre / contenu / footer) définie
dans theme_v2.yaml — plus de coordonnées par layout.
2. Centrage vertical SYSTÉMATIQUE : chaque renderer calcule
la hauteur de son contenu puis appelle self._cy(h).
3. Sandwich dark/light, motif cercle, pas de barre d'accent.
4. Composants unifiés : _card(), _badge(), _text()…
Usage :
python3 render_engine_v2.py input.yaml output.pptx
(input YAML ou JSON, mêmes champs que v1)
============================================================
"""
import json
import os
import re
import sys
import yaml
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml.ns import qn
from pptx.util import Cm, Emu, Pt
# ----------------------------------------------------------------
# Helpers généraux
# ----------------------------------------------------------------
def hex_to_rgb(h: str) -> RGBColor:
h = (h or "#000000").lstrip("#")
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def pick(d: dict, *keys, default=""):
"""Accès tolérant : retourne la première clé non vide trouvée."""
for k in keys:
v = d.get(k)
if v not in (None, ""):
return v
return default
def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float:
"""Hauteur estimée d'un texte (cm) pour une largeur donnée."""
if not text:
return 0.0
char_w_cm = size_pt * 0.0185 # largeur moyenne d'un caractère
chars_per_line = max(1, int(width_cm / char_w_cm))
lines = 0
for para in str(text).split("\n"):
lines += max(1, -(-len(para) // chars_per_line))
return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3 em
# ----------------------------------------------------------------
# Moteur
# ----------------------------------------------------------------
class RenderEngineV2:
def __init__(self, theme_path="theme_v2.yaml",
layouts_path="layouts_v2.yaml",
components_path="components_v2.yaml"):
with open(theme_path, encoding="utf-8") as f:
self.theme = yaml.safe_load(f)
with open(layouts_path, encoding="utf-8") as f:
self.layouts = yaml.safe_load(f)["layouts"]
with open(components_path, encoding="utf-8") as f:
self.components = yaml.safe_load(f)["components"]
c = self.theme["colors"]
self.C = {
"navy": c["primary"]["navy"],
"navy2": c["primary"]["navy_light"],
"coral": c["accent"]["coral"],
"glacier": c["secondary"]["glacier"],
"slate": c["secondary"]["slate"],
"white": c["backgrounds"]["white"],
"card": c["backgrounds"]["card"],
"card_alt": c["backgrounds"]["card_alt"],
"body": c["text"]["body"],
"muted": c["text"]["muted"],
}
self.cycle = c["cycle"]
self.F_DISPLAY = self.theme["fonts"]["display"]
self.F_BODY = self.theme["fonts"]["body"]
self.T = self.theme["typography"]
g = self.theme["grid"]
self.MX = g["margin_x"]
self.TITLE_Y, self.TITLE_H = g["title_y"], g["title_h"]
self.CONT_Y, self.CONT_B = g["content_y"], g["content_b"]
self.CONT_H = self.CONT_B - self.CONT_Y
self.FOOTER_Y = g["footer_y"]
self.GAP, self.GAP_S = g["gap"], g["gap_small"]
self.SLIDE_W, self.SLIDE_H = 33.87, 19.05
self._section_counter = 0
# ---------------- primitives ----------------
def _cy(self, content_h: float) -> float:
"""PRINCIPE 2 : top centré verticalement dans la zone contenu."""
return self.CONT_Y + max(0.0, (self.CONT_H - content_h) / 2)
def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,
bold=False, italic=False, color=None, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing=None):
tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))
tf = tb.text_frame
tf.word_wrap = True
tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = 0
tf.margin_top = tf.margin_bottom = 0
p = tf.paragraphs[0]
p.alignment = align
if spacing:
p.line_spacing = Pt(spacing)
run = p.add_run()
run.text = str(txt)
run.font.name = font or self.F_BODY
run.font.size = Pt(size)
run.font.bold = bold
run.font.italic = italic
run.font.color.rgb = hex_to_rgb(color or self.C["body"])
if char_spacing:
run.font._rPr.set("spc", str(int(char_spacing * 100)))
return tb
def _rich(self, slide, x, y, w, h, parts, *, size=16,
anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT):
"""Texte multi-runs : parts = [(txt, {bold, color, italic}), ...]"""
tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))
tf = tb.text_frame
tf.word_wrap = True
tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = 0
tf.margin_top = tf.margin_bottom = 0
p = tf.paragraphs[0]
p.alignment = align
for txt, opt in parts:
r = p.add_run()
r.text = txt
r.font.name = self.F_BODY
r.font.size = Pt(size)
r.font.bold = opt.get("bold", False)
r.font.italic = opt.get("italic", False)
r.font.color.rgb = hex_to_rgb(opt.get("color", self.C["body"]))
return tb
def _rect(self, slide, x, y, w, h, color, *, rounded=False, radius=0.06):
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE
sh = slide.shapes.add_shape(shape_type, Cm(x), Cm(y), Cm(w), Cm(h))
if rounded:
try:
sh.adjustments[0] = radius
except Exception:
pass
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(color)
sh.line.fill.background()
sh.shadow.inherit = False
return sh
def _oval(self, slide, x, y, d, color, *, dy=None):
sh = slide.shapes.add_shape(MSO_SHAPE.OVAL, Cm(x), Cm(y),
Cm(d), Cm(dy if dy else d))
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(color)
sh.line.fill.background()
sh.shadow.inherit = False
return sh
def _shadow(self, shape):
"""Ombre douce via OOXML (non exposée par python-pptx)."""
cfg = self.theme["card_style"]["shadow"]
sp = shape._element.spPr
old = sp.find(qn("a:effectLst"))
if old is not None:
sp.remove(old)
el = sp.makeelement(qn("a:effectLst"), {})
shdw = el.makeelement(qn("a:outerShdw"), {
"blurRad": str(int(cfg["blur_pt"] * 12700)),
"dist": str(int(cfg["dist_pt"] * 12700)),
"dir": str(int(cfg["dir_deg"] * 60000)),
"rotWithShape": "0",
})
clr = shdw.makeelement(qn("a:srgbClr"),
{"val": cfg["color"].lstrip("#")})
alpha = clr.makeelement(qn("a:alpha"),
{"val": str(int(cfg["alpha_pct"] * 1000))})
clr.append(alpha)
shdw.append(clr)
el.append(shdw)
sp.append(el)
def _card(self, slide, x, y, w, h, fill=None):
"""PRINCIPE 5 : carte unifiée — coins arrondis + ombre douce."""
sh = self._rect(slide, x, y, w, h, fill or self.C["card"],
rounded=True)
self._shadow(sh)
return sh
def _badge(self, slide, cx, cy_, d, num, *, fill=None, font_size=None):
"""Rond numéroté centré sur (cx, cy_)."""
self._oval(slide, cx - d / 2, cy_ - d / 2, d, fill or self.C["navy"])
fs = font_size or max(14, int(d * 11))
self._text(slide, cx - d / 2, cy_ - d / 2, d, d, str(num),
font=self.F_DISPLAY, size=fs, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
def _title(self, slide, txt):
self._text(slide, self.MX, self.TITLE_Y,
self.SLIDE_W - 2 * self.MX, self.TITLE_H, txt,
font=self.F_DISPLAY, size=self.T["slide_title"],
bold=True, color=self.C["navy"])
def _footer(self, slide, num):
sig = self.theme["signature"]["footer"]
self._text(slide, self.MX, self.FOOTER_Y, 2, 0.7, str(num),
size=sig["size"], color=sig["color"])
self._text(slide, self.SLIDE_W - self.MX - 10, self.FOOTER_Y, 10, 0.7,
sig["right"], size=sig["size"], color=sig["color"],
align=PP_ALIGN.RIGHT)
def _bg(self, slide, color):
slide.background.fill.solid()
slide.background.fill.fore_color.rgb = hex_to_rgb(color)
# ---------------- renderers ----------------
def _render_cover_split(self, slide, d):
self._bg(slide, self.C["navy"])
for c in self.components["decor_circles"]["cover"]:
col = {"theme.primary.navy_light": self.C["navy2"],
"theme.accent.coral": self.C["coral"],
"theme.secondary.glacier": self.C["glacier"]}[c["color"]]
self._oval(slide, c["x"], c["y"], c["d"], col)
self._text(slide, self.MX, 2.5, 15, 1.0, "PERNOD RICARD",
size=13, bold=True, color=self.C["glacier"],
char_spacing=4)
self._text(slide, self.MX, 5.8, 24, 6.1,
pick(d, "titre", "title"),
font=self.F_DISPLAY, size=self.T["cover_title"],
bold=True, color=self.C["white"], spacing=52)
self._text(slide, self.MX, 12.7, 20.8, 1.5,
pick(d, "sous_titre", "subtitle", "tagline", "accroche"),
size=18, italic=True, color=self.C["coral"])
def _render_executive_summary(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
labels = self.layouts["executive_summary"].get(
"labels", ["SITUATION", "COMPLICATION", "RÉSOLUTION"])
keys = ["situation", "complication", "resolution"]
colors = [self.C["navy"], self.C["coral"], self.C["glacier"]]
ch, gap = 3.68, 0.89
rows = [(labels[i], colors[i], pick(d, k)) for i, k in enumerate(keys)
if pick(d, k)]
tot = len(rows) * ch + (len(rows) - 1) * gap
y = self._cy(tot)
bd = self.components["badge"]["sizes"]["m"]
for i, (label, col, txt) in enumerate(rows):
self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, ch)
self._badge(slide, self.MX + 1.14 + bd / 2, y + ch / 2, bd, i + 1,
fill=col, font_size=22)
self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, label,
size=14, bold=True, color=col, char_spacing=3)
self._text(slide, self.MX + 3.81, y + 1.57,
self.SLIDE_W - 2 * self.MX - 5.33, 1.8, txt,
size=self.T["body"], color=self.C["body"])
y += ch + gap
def _render_section_divider(self, slide, d):
self._bg(slide, self.C["navy"])
self._section_counter += 1
num = str(d.get("numero", self._section_counter)).zfill(2)
self._text(slide, 20.6, 3.0, 12.7, 12.7, num,
font=self.F_DISPLAY, size=self.T["ghost_number"],
bold=True, color=self.C["navy2"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"])
self._text(slide, self.MX + 1.4, 4.83, 19.3, 4.1,
pick(d, "titre", "title"),
font=self.F_DISPLAY, size=self.T["section_title"],
bold=True, color=self.C["white"],
anchor=MSO_ANCHOR.MIDDLE)
def _render_big_stat(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
val = pick(d, "valeur", "stat", "chiffre", "value")
desc = pick(d, "description", "texte", "label")
src = pick(d, "source", "reference")
block = 8.13
y = self._cy(block)
self._text(slide, 0, y, self.SLIDE_W, 5.1, val,
font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True,
color=self.C["coral"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, 2.0, desc,
size=18, color=self.C["body"], align=PP_ALIGN.CENTER)
if src:
self._text(slide, 6.86, y + 7.37, self.SLIDE_W - 13.72, 0.9, src,
size=11, italic=True, color=self.C["muted"],
align=PP_ALIGN.CENTER)
def _col_items(self, col_data):
"""Extrait une liste de textes depuis une colonne two_cols."""
items = col_data.get("bullets") or col_data.get("items") or []
out = []
for b in items:
out.append(b.get("texte", "") if isinstance(b, dict) else str(b))
return out
def _render_two_cols_text(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
left, right = d.get("left", {}), d.get("right", {})
col_w = (self.SLIDE_W - 2 * self.MX - 1.27) / 2
ch = 10.67
y = self._cy(ch)
hdr_h = self.components["header_band"]["height_cm"]
for i, (col, accent) in enumerate(
[(left, self.C["navy"]), (right, self.C["coral"])]):
x = self.MX + i * (col_w + 1.27)
self._card(slide, x, y, col_w, ch)
self._rect(slide, x, y, col_w, hdr_h, accent)
self._text(slide, x + 1.0, y, col_w - 2.0, hdr_h,
pick(col, "titre", "title", "header"),
size=18, bold=True, color=self.C["white"],
anchor=MSO_ANCHOR.MIDDLE)
items = self._col_items(col)
tb = slide.shapes.add_textbox(Cm(x + 1.14), Cm(y + hdr_h + 0.6),
Cm(col_w - 2.28),
Cm(ch - hdr_h - 1.2))
tf = tb.text_frame
tf.word_wrap = True
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
first = True
for it in items:
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
p.space_after = Pt(14)
r = p.add_run()
r.text = "" + it
r.font.name = self.F_BODY
r.font.size = Pt(15)
r.font.color.rgb = hex_to_rgb(self.C["body"])
def _render_kpi_grid(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
items = d.get("items", [])
n = max(1, len(items))
gap = 1.14
col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n
ch = 9.14
y = self._cy(ch)
for i, it in enumerate(items):
x = self.MX + i * (col_w + gap)
self._card(slide, x, y, col_w, ch)
self._text(slide, x + 0.89, y + 0.89, col_w - 1.78, 1.14,
pick(it, "label", "titre"),
size=15, bold=True, color=self.C["slate"],
char_spacing=2)
# Taille adaptative : chiffre court = grand, texte long = réduit
_val = str(pick(it, "valeur", "value", "stat"))
_vlen = len(_val)
if _vlen <= 6:
_vsize = self.T["stat_card"] # 54pt — "20-40%", "+13%"
elif _vlen <= 12:
_vsize = 32 # "Baisse", "Conformité"
else:
_vsize = 22 # "Données fiables"
self._text(slide, x + 0.89, y + 2.41, col_w - 1.78, 3.68,
_val,
font=self.F_DISPLAY, size=_vsize,
bold=True, color=self.C["coral"],
anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, x + 0.89, y + ch - 2.67, col_w - 1.78, 2.03,
pick(it, "description", "source", "detail"),
size=12, color=self.C["muted"])
def _render_key_message(self, slide, d):
self._bg(slide, self.C["navy"])
self._text(slide, self.MX, 1.78, 5.6, 6.1, "\u201C",
font=self.F_DISPLAY, size=200, bold=True,
color=self.C["coral"])
self._text(slide, 6.6, 5.84, 23.9, 4.32,
pick(d, "message", "texte", "citation"),
font=self.F_DISPLAY, size=32, bold=True,
color=self.C["white"], spacing=40)
detail = pick(d, "detail", "sous_message", "sous_texte", "soutien")
if detail:
self._text(slide, 6.6, 10.8, 21.6, 1.5, detail,
size=18, italic=True, color=self.C["glacier"])
def _render_circular_diagram(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
segs = d.get("segments", [])
n = len(segs)
if not n:
return
D = 6.35
cxc = 8.64
cyc = self.CONT_Y + self.CONT_H / 2
# positions : triangle pour 3, sinon cercle de positions
if n == 3:
centers = [(cxc - 1.83, cyc - 1.57), (cxc + 1.83, cyc - 1.57),
(cxc, cyc + 1.57)]
else:
import math
r_orb = D * 0.62
centers = [(cxc + r_orb * math.cos(2 * math.pi * i / n - math.pi / 2),
cyc + r_orb * math.sin(2 * math.pi * i / n - math.pi / 2))
for i in range(n)]
for i, seg in enumerate(segs):
col = seg.get("couleur") or self.cycle[i % len(self.cycle)]
cx0, cy0 = centers[i]
sh = self._oval(slide, cx0 - D / 2, cy0 - D / 2, D, col)
sh.line.color.rgb = hex_to_rgb(self.C["white"])
sh.line.width = Pt(2)
for i, seg in enumerate(segs):
cx0, cy0 = centers[i]
below = cy0 > cyc # quadrant : numéro haut ou bas
ny = cy0 + (D / 2 - 1.83) * (1 if below else -1) - 0.7
self._text(slide, cx0 - 1.78, ny, 3.56, 1.4,
str(i + 1).zfill(2),
font=self.F_DISPLAY, size=22, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER)
# légende droite — centrée verticalement
lh = 2.67
leg_h = n * lh
ly = self._cy(leg_h)
for i, seg in enumerate(segs):
col = seg.get("couleur") or self.cycle[i % len(self.cycle)]
self._oval(slide, 17.8, ly + 0.2, 1.27, col)
self._text(slide, 19.7, ly, 12.7, 1.07,
f"{str(i + 1).zfill(2)} {seg.get('label', '')}",
size=17, bold=True, color=self.C["navy"])
self._text(slide, 19.7, ly + 1.07, 12.7, 1.0,
seg.get("description", ""),
size=14, color=self.C["body"])
ly += lh
def _render_default_bullets(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
bullets = d.get("bullets", [])
rh = 2.54
tot = len(bullets) * rh
y = self._cy(tot)
mk = self.components["square_mark"]["size_cm"]
for b in bullets:
txt = b.get("texte", "") if isinstance(b, dict) else str(b)
self._rect(slide, self.MX + 0.25, y + (rh - 0.5) / 2 - mk / 2 + 0.25,
mk, mk, self.C["coral"])
# "Mot : explication" → mot en gras navy
m = re.match(r"^([^:]{2,30})\s*:\s*(.+)$", txt)
if m:
parts = [(m.group(1) + "",
{"bold": True, "color": self.C["navy"]}),
(m.group(2), {"color": self.C["body"]})]
else:
parts = [(txt, {"color": self.C["body"]})]
self._rich(slide, self.MX + 1.52, y,
self.SLIDE_W - 2 * self.MX - 1.52, rh - 0.5, parts,
size=self.T["body_large"], anchor=MSO_ANCHOR.MIDDLE)
y += rh
def _render_numbered_steps(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
steps = d.get("steps", [])
rh, gap = 3.68, 0.76
tot = len(steps) * rh + (len(steps) - 1) * gap
y = self._cy(tot)
bd = self.components["badge"]["sizes"]["l"]
for i, st in enumerate(steps):
self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, rh)
self._badge(slide, self.MX + 1.02 + bd / 2, y + rh / 2, bd,
st.get("numero", i + 1), font_size=28)
self._text(slide, self.MX + 4.32, y + 0.64, 14.2, 1.27,
pick(st, "titre", "title"),
size=19, bold=True, color=self.C["navy"])
self._text(slide, self.MX + 4.32, y + 1.98,
self.SLIDE_W - 2 * self.MX - 5.84, 1.27,
pick(st, "description", "detail"),
size=15, color=self.C["body"])
y += rh + gap
def _render_phases_timeline(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
phases = d.get("phases", [])
n = max(1, len(phases))
gap = 1.02
col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n
bh = 3.81
tot = bh + 2.41
y = self._cy(tot)
# ligne pointillée de connexion
ln = slide.shapes.add_connector(1, Cm(self.MX + col_w / 2),
Cm(y + bh / 2),
Cm(self.SLIDE_W - self.MX - col_w / 2),
Cm(y + bh / 2))
ln.line.color.rgb = hex_to_rgb(self.C["muted"])
ln.line.width = Pt(1.25)
ln.line.dash_style = 4 # MSO_LINE.DASH
for i, ph in enumerate(phases):
col = self.cycle[i % len(self.cycle)]
x = self.MX + i * (col_w + gap)
sh = self._rect(slide, x, y, col_w, bh, col, rounded=True,
radius=0.08)
self._shadow(sh)
self._text(slide, x, y, col_w, bh, pick(ph, "label", "titre"),
font=self.F_DISPLAY, size=24, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, x, y + bh + 0.64, col_w, 1.5,
pick(ph, "periode", "description"),
size=15, color=self.C["body"],
align=PP_ALIGN.CENTER)
def _render_recommendation_card(self, slide, d):
sbw = self.layouts["recommendation_card"].get("sidebar_width_cm", 9.9)
self._rect(slide, 0, 0, sbw, self.SLIDE_H, self.C["navy"])
bd = self.components["badge"]["sizes"]["xl"]
titre = pick(d, "titre", "title")
cta = pick(d, "cta")
# contenu sidebar — centré verticalement sur la slide
cta_h = max(2.0, estimate_text_height(cta, 13, sbw - 2.3) + 0.8) if cta else 0
inner = bd + 0.89 + 1.78 + (1.27 + cta_h if cta else 0)
sy = (self.SLIDE_H - inner) / 2
self._badge(slide, sbw / 2, sy + bd / 2, bd,
d.get("numero", 1), fill=self.C["coral"], font_size=40)
self._text(slide, 0.76, sy + bd + 0.89, sbw - 1.52, 1.78, titre,
font=self.F_DISPLAY, size=24, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER)
if cta:
self._text(slide, 1.14, sy + bd + 0.89 + 1.78 + 1.27,
sbw - 2.28, cta_h, cta,
size=13, italic=True, color=self.C["glacier"],
align=PP_ALIGN.CENTER)
# corps droit
cx = sbw + 1.52
cw = self.SLIDE_W - sbw - 3.04
bullets = d.get("bullets", [])
texts = [b.get("texte", "") if isinstance(b, dict) else str(b)
for b in bullets]
# dédoublonnage
seen, dedup = set(), []
for t in texts:
if t not in seen:
seen.add(t)
dedup.append(t)
hdr_h = 2.16
body_h = len(dedup) * 1.57 + 1.78
tot = hdr_h + body_h
y = (self.SLIDE_H - tot) / 2
headline = pick(d, "headline", "header")
if headline:
self._rect(slide, cx, y, cw, hdr_h, self.C["navy"])
self._text(slide, cx + 1.0, y, cw - 2.0, hdr_h, headline,
size=18, bold=True, color=self.C["white"],
anchor=MSO_ANCHOR.MIDDLE, char_spacing=2)
self._card(slide, cx, y + hdr_h, cw, body_h)
tb = slide.shapes.add_textbox(Cm(cx + 1.27), Cm(y + hdr_h + 0.89),
Cm(cw - 2.54), Cm(body_h - 1.78))
tf = tb.text_frame
tf.word_wrap = True
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
first = True
for t in dedup:
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
p.space_after = Pt(12)
r = p.add_run()
r.text = "" + t
r.font.name = self.F_BODY
r.font.size = Pt(16)
r.font.color.rgb = hex_to_rgb(self.C["body"])
self._footer(slide, getattr(self, "_slide_num", ""))
def _render_end_slide(self, slide, d):
self._bg(slide, self.C["navy"])
for c in self.components["decor_circles"]["end"]:
col = {"theme.primary.navy_light": self.C["navy2"],
"theme.accent.coral": self.C["coral"]}[c["color"]]
self._oval(slide, c["x"], c["y"], c["d"], col)
self._text(slide, 4.06, 6.86, 25.7, 4.57,
pick(d, "message", "titre", "texte"),
font=self.F_DISPLAY, size=34, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER,
spacing=42)
self._text(slide, 4.06, 11.9, 25.7, 1.27, "Merci pour votre attention",
size=16, italic=True, color=self.C["glacier"],
align=PP_ALIGN.CENTER)
# ── NEW LAYOUTS — 8 extensions ────────────────────────────────────────────
def _col_positions(self, n_cols, first_col_w=None):
"""Helper : retourne [(x, width), ...] pour chaque colonne."""
if first_col_w and n_cols > 1:
rest = (self.SLIDE_W - 2*self.MX - first_col_w) / (n_cols - 1)
return [(self.MX, first_col_w)] + [
(self.MX + first_col_w + i*rest, rest) for i in range(n_cols-1)]
col_w = (self.SLIDE_W - 2*self.MX) / max(1, n_cols)
return [(self.MX + i*col_w, col_w) for i in range(n_cols)]
# ── from_to_pairs ─────────────────────────────────────────────────────────
def _render_from_to_pairs(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
pairs = d.get("pairs", [])
if not pairs:
return
lbl_from = pick(d, "label_from", "ÉTAT ACTUEL")
lbl_to = pick(d, "label_to", "ÉTAT CIBLE")
ARROW_W = 2.54
col_w = (self.SLIDE_W - 2*self.MX - ARROW_W) / 2
row_h, gap, hdr_h = 1.52, 0.28, 0.76
tot = hdr_h + gap + len(pairs) * (row_h + gap)
y = self._cy(tot)
ax = self.MX + col_w # arrow zone x
tx = ax + ARROW_W # TO column x
# Headers
self._text(slide, self.MX, y, col_w, hdr_h, lbl_from,
size=13, bold=True, color=self.C["muted"],
char_spacing=3, anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, tx, y, col_w, hdr_h, lbl_to,
size=13, bold=True, color=self.C["navy"],
char_spacing=3, anchor=MSO_ANCHOR.MIDDLE)
y += hdr_h + gap
for pair in pairs:
# FROM card — blue tint
self._card(slide, self.MX, y, col_w, row_h, self.C["card_alt"])
self._text(slide, self.MX + 0.64, y, col_w - 1.27, row_h,
pick(pair, "from", "de", "avant"),
size=15, italic=True, color=self.C["slate"],
anchor=MSO_ANCHOR.MIDDLE)
# Arrow
self._text(slide, ax, y, ARROW_W, row_h, "",
size=30, bold=True, color=self.C["coral"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# TO card — warm
self._card(slide, tx, y, col_w, row_h)
self._text(slide, tx + 0.64, y, col_w - 1.27, row_h,
pick(pair, "to", "vers", "après"),
size=15, bold=True, color=self.C["navy"],
anchor=MSO_ANCHOR.MIDDLE)
y += row_h + gap
# ── gantt_timeline ────────────────────────────────────────────────────────
def _render_gantt_timeline(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
periods = d.get("periods", [])
workstreams = d.get("workstreams", [])
if not periods or not workstreams:
return
n_p = len(periods)
LBL_W = 4.57
GAP_COL = 0.25
GRID_W = self.SLIDE_W - 2*self.MX - LBL_W - GAP_COL
col_w = GRID_W / n_p
HDR_H = 0.80
WS_H = 0.68
TASK_H = 0.78
n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams)
total_h = HDR_H + len(workstreams)*WS_H + n_tasks*TASK_H
y0 = self._cy(total_h)
gx = self.MX + LBL_W + GAP_COL # grid x origin
# Header
self._rect(slide, self.MX, y0, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"])
for i, p in enumerate(periods):
self._text(slide, gx + i*col_w, y0, col_w, HDR_H, p,
size=10, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Subtle vertical separators over full grid height
for i in range(1, n_p):
xl = gx + i * col_w
ln = slide.shapes.add_connector(1, Cm(xl), Cm(y0 + HDR_H),
Cm(xl), Cm(y0 + total_h))
ln.line.color.rgb = hex_to_rgb("#E0DEDB")
ln.line.width = Pt(0.5)
cur_y = y0 + HDR_H
for ws_idx, ws in enumerate(workstreams):
ws_col = self.cycle[ws_idx % len(self.cycle)]
# Workstream row
self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, WS_H, "#EEECEA")
self._rect(slide, self.MX, cur_y, 0.28, WS_H, ws_col)
self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, WS_H,
ws.get("label", ""), size=12, bold=True,
color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE)
cur_y += WS_H
for task in ws.get("tasks", []):
start = task.get("start", 0)
end = task.get("end", start + 1)
bg = self.C["white"]
self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, TASK_H, bg)
self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, TASK_H,
" " + task.get("label", ""), size=11,
color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE)
bx = gx + start * col_w + 0.14
bw = (end - start) * col_w - 0.28
self._rect(slide, bx, cur_y + 0.15, bw, TASK_H - 0.30,
ws_col, rounded=True)
cur_y += TASK_H
# bottom separator
ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(cur_y),
Cm(self.SLIDE_W - self.MX), Cm(cur_y))
ln.line.color.rgb = hex_to_rgb("#D8D4CF")
ln.line.width = Pt(0.5)
# ── yearly_timeline ───────────────────────────────────────────────────────
def _render_yearly_timeline(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
milestones = d.get("milestones", [])
if not milestones:
return
n = len(milestones)
D = 1.27 # circle diameter
LBL_H = 1.52
LINE_Y_DELTA = LBL_H + 0.38 # gap above line for top labels
tot_h = LBL_H * 2 + D + 0.76
y_top = self._cy(tot_h)
line_y = y_top + LBL_H + 0.38 # centre of the line / circles
usable_w = self.SLIDE_W - 2*self.MX
step = usable_w / (n - 1) if n > 1 else usable_w
# Horizontal line
ln = slide.shapes.add_connector(1,
Cm(self.MX), Cm(line_y + D/2),
Cm(self.SLIDE_W - self.MX), Cm(line_y + D/2))
ln.line.color.rgb = hex_to_rgb(self.C["navy"])
ln.line.width = Pt(1.75)
for i, ms in enumerate(milestones):
cx = self.MX + i * step
active = ms.get("actif", False)
col = self.C["coral"] if active else self.C["navy"]
lbl_col = self.C["navy"] if active else self.C["body"]
lbl_sz = 13 if active else 12
# Circle
self._oval(slide, cx - D/2, line_y, D, col)
annee = str(pick(ms, "annee", "year", str(i+1)))
self._text(slide, cx - D/2, line_y, D, D, annee,
size=10, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Label
lw = min(step * 0.85, 5.08) if n > 1 else usable_w
label = pick(ms, "label", "evenement", "event")
# Clamp : label ne sort jamais du slide
lx = max(self.MX, min(cx - lw/2, self.SLIDE_W - self.MX - lw))
if i % 2 == 0:
self._text(slide, lx, y_top, lw, LBL_H, label,
size=lbl_sz, bold=active, color=lbl_col,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM)
else:
self._text(slide, lx, line_y + D + 0.25, lw, LBL_H, label,
size=lbl_sz, bold=active, color=lbl_col,
align=PP_ALIGN.CENTER)
# ── comparison_table ──────────────────────────────────────────────────────
def _render_comparison_table(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
headers = d.get("headers", [])
rows = d.get("rows", [])
if not headers or not rows:
return
n_cols = len(headers)
n_rows = len(rows)
HDR_H = 1.02
ROW_H = min(1.27, (self.CONT_H - HDR_H - 0.5) / n_rows)
FIRST_W = 5.08
cols = self._col_positions(n_cols, FIRST_W if n_cols > 1 else None)
total_h = HDR_H + n_rows * ROW_H
y = self._cy(total_h)
# Header
self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"])
for j, (x, w) in enumerate(cols):
self._text(slide, x + 0.33, y, w - 0.33, HDR_H, headers[j],
size=13, bold=True, color=self.C["white"],
anchor=MSO_ANCHOR.MIDDLE)
# Rows
for i, row in enumerate(rows):
ry = y + HDR_H + i * ROW_H
bg = self.C["card"] if i % 2 == 0 else self.C["white"]
self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg)
cells = list(row) if isinstance(row, (list, tuple)) else ([row.get("label", "")] + row.get("values", [])) if isinstance(row, dict) else [str(row)]
for j, (x, w) in enumerate(cols):
val = cells[j] if j < len(cells) else ""
self._text(slide, x + 0.33, ry, w - 0.33, ROW_H, str(val),
size=13, bold=(j == 0), color=self.C["navy"] if j == 0 else self.C["body"],
anchor=MSO_ANCHOR.MIDDLE)
# separator
ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H),
Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H))
ln.line.color.rgb = hex_to_rgb("#E8E4E0")
ln.line.width = Pt(0.5)
# ── raci_table ────────────────────────────────────────────────────────────
def _render_raci_table(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
roles = d.get("roles", [])
tasks = d.get("tasks", [])
if not roles or not tasks:
return
RACI_COLORS = {"R": self.C["coral"], "A": self.C["navy"],
"C": self.C["slate"], "I": self.C["muted"]}
n_cols = len(roles) + 1
n_rows = len(tasks)
HDR_H = 1.02
ROW_H = min(1.14, (self.CONT_H - HDR_H - 0.5) / n_rows)
TASK_W = 6.35
ROLE_W = (self.SLIDE_W - 2*self.MX - TASK_W) / len(roles)
BADGE = 0.64
total_h = HDR_H + n_rows * ROW_H
y = self._cy(total_h)
# Header
self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"])
self._text(slide, self.MX + 0.33, y, TASK_W - 0.33, HDR_H, "Activité",
size=12, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE)
for j, role in enumerate(roles):
rx = self.MX + TASK_W + j * ROLE_W
self._text(slide, rx, y, ROLE_W, HDR_H, role,
size=11, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Rows
for i, task in enumerate(tasks):
ry = y + HDR_H + i * ROW_H
bg = self.C["card"] if i % 2 == 0 else self.C["white"]
self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg)
self._text(slide, self.MX + 0.33, ry, TASK_W - 0.33, ROW_H,
task.get("label", ""), size=12, color=self.C["navy"],
anchor=MSO_ANCHOR.MIDDLE)
raci = task.get("raci", [])
for j, letter in enumerate(raci):
if j >= len(roles):
break
rx = self.MX + TASK_W + j * ROLE_W + (ROLE_W - BADGE) / 2
by = ry + (ROW_H - BADGE) / 2
col = RACI_COLORS.get(letter.upper(), self.C["muted"])
self._rect(slide, rx, by, BADGE, BADGE, col, rounded=True, radius=0.08)
self._text(slide, rx, by, BADGE, BADGE, letter.upper(),
size=13, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H),
Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H))
ln.line.color.rgb = hex_to_rgb("#E8E4E0")
ln.line.width = Pt(0.5)
# ── process_arrow ─────────────────────────────────────────────────────────
def _render_process_arrow(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
steps = d.get("steps", [])
if not steps:
return
n = len(steps)
ARR_W = 0.89
BOX_H = 2.67
DSC_H = 1.0
usable = self.SLIDE_W - 2*self.MX - ARR_W * (n - 1)
box_w = usable / n
tot_h = BOX_H + DSC_H + 0.38
y0 = self._cy(tot_h)
BADGE_D = 0.76
for i, step in enumerate(steps):
x = self.MX + i * (box_w + ARR_W)
col = self.cycle[i % len(self.cycle)]
# Box (dark bg = navy or cycle)
self._rect(slide, x, y0, box_w, BOX_H, col, rounded=True)
# Badge number top-center
self._oval(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2,
BADGE_D, self.C["coral"])
self._text(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2,
BADGE_D, BADGE_D, str(i + 1),
size=14, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Title inside box
self._text(slide, x + 0.33, y0 + 0.38, box_w - 0.66, BOX_H - 0.76,
pick(step, "titre", "title", "label"),
size=15, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Description below
self._text(slide, x, y0 + BOX_H + 0.28, box_w, DSC_H,
pick(step, "description", "detail"),
size=12, color=self.C["body"],
align=PP_ALIGN.CENTER)
# Connecting arrow (except last)
if i < n - 1:
ax = x + box_w
self._text(slide, ax, y0, ARR_W, BOX_H, "",
size=28, bold=True, color=self.C["coral"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# ── org_chart ─────────────────────────────────────────────────────────────
def _render_org_chart(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
root = d.get("root", {})
if not root:
return
BOX_W, BOX_H = 3.81, 1.0
GAP_V, GAP_H = 1.27, 0.64
children = root.get("children", [])
n_children = len(children)
# grandchildren count per child (max)
max_gd = max((len(c.get("children", [])) for c in children), default=0)
# Heights
levels = 1 + (1 if children else 0) + (1 if max_gd > 0 else 0)
tot_h = levels * BOX_H + (levels - 1) * GAP_V
cy_top = self._cy(tot_h)
# Root box (centered, navy)
root_x = (self.SLIDE_W - BOX_W) / 2
self._card(slide, root_x, cy_top, BOX_W, BOX_H, self.C["navy"])
self._text(slide, root_x + 0.33, cy_top, BOX_W - 0.66, BOX_H,
root.get("label", ""), size=14, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
if not children:
return
# Children row
child_y = cy_top + BOX_H + GAP_V
total_children_w = n_children * BOX_W + (n_children - 1) * GAP_H
child_x_start = (self.SLIDE_W - total_children_w) / 2
# Connector from root bottom to children row
for i, child in enumerate(children):
cx = child_x_start + i * (BOX_W + GAP_H)
col = self.C["card"]
self._card(slide, cx, child_y, BOX_W, BOX_H, col)
self._text(slide, cx + 0.33, child_y, BOX_W - 0.66, BOX_H,
child.get("label", ""), size=13, bold=True,
color=self.C["navy"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
# Line: root bottom → child top
mid_root_x = root_x + BOX_W / 2
mid_child_x = cx + BOX_W / 2
mid_y = cy_top + BOX_H + GAP_V / 2
# Vertical from root
ln = slide.shapes.add_connector(1,
Cm(mid_root_x), Cm(cy_top + BOX_H),
Cm(mid_root_x), Cm(mid_y))
ln.line.color.rgb = hex_to_rgb(self.C["navy"])
ln.line.width = Pt(1.0)
# Horizontal to child
ln2 = slide.shapes.add_connector(1,
Cm(mid_root_x if i == 0 else child_x_start + (n_children-1)*(BOX_W+GAP_H)/2 if i == n_children-1 else mid_child_x),
Cm(mid_y), Cm(mid_child_x), Cm(mid_y))
ln2.line.color.rgb = hex_to_rgb(self.C["navy"])
ln2.line.width = Pt(1.0)
# Vertical to child
ln3 = slide.shapes.add_connector(1,
Cm(mid_child_x), Cm(mid_y),
Cm(mid_child_x), Cm(child_y))
ln3.line.color.rgb = hex_to_rgb(self.C["navy"])
ln3.line.width = Pt(1.0)
# Grandchildren — sizing dynamique pour tenir dans la colonne
grandchildren = child.get("children", [])
if grandchildren:
gd_y = child_y + BOX_H + GAP_V
n_gd = len(grandchildren)
col_span = BOX_W + GAP_H # largeur allouée par parent
GD_GAP = 0.25
GD_W = min(BOX_W, (col_span - GD_GAP * (n_gd - 1)) / n_gd)
GD_W = max(1.78, GD_W)
gd_total = n_gd * GD_W + (n_gd - 1) * GD_GAP
gd_x_start = cx + BOX_W/2 - gd_total/2
mid_cy_x = cx + BOX_W/2
for k, gc in enumerate(grandchildren):
gx = gd_x_start + k * (GD_W + GD_GAP)
self._card(slide, gx, gd_y, GD_W, BOX_H, "#EAF0F8")
self._text(slide, gx + 0.20, gd_y, GD_W - 0.40, BOX_H,
gc.get("label", ""), size=10,
color=self.C["navy"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
mid_gx = gx + GD_W/2
ln4 = slide.shapes.add_connector(1,
Cm(mid_cy_x), Cm(child_y + BOX_H),
Cm(mid_gx), Cm(gd_y))
ln4.line.color.rgb = hex_to_rgb(self.C["muted"])
ln4.line.width = Pt(0.75)
# ── matrix_2x2 ────────────────────────────────────────────────────────────
def _render_matrix_2x2(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
axis_x = d.get("axis_x", {})
axis_y = d.get("axis_y", {})
quadrants = d.get("quadrants", {})
items = d.get("items", [])
ITEM_D = 0.89
# Matrix zone
AXIS_LBL = 1.27
MAT_W = self.SLIDE_W - 2*self.MX - AXIS_LBL
MAT_H = self.CONT_H - AXIS_LBL - 0.3
tot_h = MAT_H + AXIS_LBL
y0 = self._cy(tot_h)
mx0 = self.MX + AXIS_LBL # matrix x start
# Quadrant backgrounds
hw, hh = MAT_W/2, MAT_H/2
Q_FILLS = ["#EAF0F8", self.C["card"], "#E8F4EC", "#FFF0EC"]
Q_POS = [(mx0, y0), (mx0+hw, y0), (mx0, y0+hh), (mx0+hw, y0+hh)]
for fill, (qx, qy) in zip(Q_FILLS, Q_POS):
self._rect(slide, qx, qy, hw, hh, fill)
# Axis lines
for lx, ly, ex, ey in [
(mx0, y0, mx0 + MAT_W, y0),
(mx0, y0 + MAT_H, mx0 + MAT_W, y0 + MAT_H),
(mx0, y0, mx0, y0 + MAT_H),
(mx0 + MAT_W, y0, mx0 + MAT_W, y0 + MAT_H),
(mx0 + hw, y0, mx0 + hw, y0 + MAT_H), # vertical mid
(mx0, y0 + hh, mx0 + MAT_W, y0 + hh), # horizontal mid
]:
ln = slide.shapes.add_connector(1, Cm(lx), Cm(ly), Cm(ex), Cm(ey))
ln.line.color.rgb = hex_to_rgb("#C8C4BE" if "mid" not in str(lx) else "#C8C4BE")
ln.line.width = Pt(1.0 if (lx == mx0+hw or ly == y0+hh) else 1.5)
# Quadrant labels (corners)
QD = {
"top_left": (mx0 + 0.3, y0 + 0.2),
"top_right": (mx0 + hw + 0.3, y0 + 0.2),
"bottom_left": (mx0 + 0.3, y0 + hh + 0.2),
"bottom_right": (mx0 + hw + 0.3, y0 + hh + 0.2),
}
for key, (qx, qy) in QD.items():
label = quadrants.get(key, "")
if label:
self._text(slide, qx, qy, hw - 0.5, 0.64, label,
size=11, italic=True, color=self.C["muted"])
# Axis labels
ax_lbl = pick(axis_x, "label", "x")
ay_lbl = pick(axis_y, "label", "y")
if ax_lbl:
self._text(slide, mx0, y0 + MAT_H + 0.12, MAT_W, AXIS_LBL, ax_lbl,
size=13, bold=True, color=self.C["navy"],
align=PP_ALIGN.CENTER)
if ay_lbl:
# Axe Y : flèche verticale + label court à gauche de la matrice
ln_ay = slide.shapes.add_connector(1,
Cm(self.MX + AXIS_LBL * 0.5), Cm(y0 + MAT_H),
Cm(self.MX + AXIS_LBL * 0.5), Cm(y0))
ln_ay.line.color.rgb = hex_to_rgb(self.C["navy"])
ln_ay.line.width = Pt(1.5)
self._text(slide, self.MX, y0 - 0.76, AXIS_LBL, 0.64,
f"{ay_lbl}",
size=12, bold=True, color=self.C["navy"])
# Axis extremities
for lbl, pos, anchor in [
(pick(axis_x, "low", "low_x", ""), (mx0, y0 + MAT_H + 0.1), PP_ALIGN.LEFT),
(pick(axis_x, "high", "high_x", ""), (mx0 + MAT_W - 1.5, y0 + MAT_H + 0.1), PP_ALIGN.RIGHT),
(pick(axis_y, "high", "high_y", ""), (self.MX, y0 + 0.1), PP_ALIGN.CENTER),
(pick(axis_y, "low", "low_y", ""), (self.MX, y0 + MAT_H - 0.8), PP_ALIGN.CENTER),
]:
if lbl:
self._text(slide, pos[0], pos[1], 1.5, 0.5, lbl,
size=10, italic=True, color=self.C["muted"], align=anchor)
# Items as numbered circles
for i, item in enumerate(items):
ix = item.get("x", 50) # 0-100
iy = item.get("y", 50) # 0-100
px = mx0 + (ix / 100) * MAT_W
py = y0 + ((100 - iy) / 100) * MAT_H
self._oval(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, self.C["coral"])
self._text(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, ITEM_D,
str(i + 1), size=12, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Legend below x-axis label
if items:
leg_y = y0 + MAT_H + AXIS_LBL * 0.55
leg_x = mx0
for i, item in enumerate(items):
lx = leg_x + i * 3.56
if lx + 3.3 > self.SLIDE_W - self.MX:
break
self._oval(slide, lx, leg_y - 0.3, 0.5, self.C["coral"])
self._text(slide, lx, leg_y - 0.3, 0.5, 0.5, str(i+1),
size=9, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, lx + 0.6, leg_y - 0.35, 2.8, 0.6,
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",
"executive_summary": "_render_executive_summary",
"section_divider": "_render_section_divider",
"big_stat": "_render_big_stat",
"two_cols_text": "_render_two_cols_text",
"kpi_grid": "_render_kpi_grid",
"key_message": "_render_key_message",
"circular_diagram": "_render_circular_diagram",
"default_bullets": "_render_default_bullets",
"numbered_steps": "_render_numbered_steps",
"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",
"comparison_table": "_render_comparison_table",
"raci_table": "_render_raci_table",
"process_arrow": "_render_process_arrow",
"org_chart": "_render_org_chart",
"matrix_2x2": "_render_matrix_2x2",
}
def render(self, data, output_path: str):
if isinstance(data, str):
data = yaml.safe_load(data)
slides = data.get("slides", data) if isinstance(data, dict) else data
prs = Presentation()
prs.slide_width = Cm(self.SLIDE_W)
prs.slide_height = Cm(self.SLIDE_H)
blank = prs.slide_layouts[6]
self._section_counter = 0
excluded = set(self.theme["signature"]["footer_excluded_layouts"])
for i, sd in enumerate(slides):
slide = prs.slides.add_slide(blank)
layout = sd.get("layout", "default_bullets")
self._slide_num = i + 1
method = self.REGISTRY.get(layout)
if not method:
print(f" ⚠ Layout inconnu '{layout}' → default_bullets")
method = "_render_default_bullets"
layout = "default_bullets"
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)")
def main():
import argparse
parser = argparse.ArgumentParser(
description="Sliding render_engine V2 — PR Editorial")
parser.add_argument("input_file", help="Fichier YAML/JSON de la présentation")
parser.add_argument("output", help="Chemin du PPTX à générer")
parser.add_argument("--theme", default="theme_v2.yaml")
parser.add_argument("--components", default="components_v2.yaml")
parser.add_argument("--layouts", default="layouts_v2.yaml")
args = parser.parse_args()
for f in [args.input_file, args.theme, args.components, args.layouts]:
if not os.path.exists(f):
print(f"✗ Fichier introuvable : {f}")
sys.exit(1)
with open(args.input_file, encoding="utf-8") as f:
data = yaml.safe_load(f)
engine = RenderEngineV2(args.theme, args.layouts, args.components)
engine.render(data, args.output)
if __name__ == "__main__":
main()