Files
sliding-automation/render_engine_v2.py
T

1187 lines
54 KiB
Python
Raw Permalink Normal View History

#!/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"])
# ---------------- 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",
"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"
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()