Files
sliding-automation/render_engine_v2.py
T

2297 lines
102 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.dml import MSO_LINE_DASH_STYLE as MSO_LINE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml.ns import qn
from pptx.util import Cm, Emu, Pt
try:
import measure
HAS_MEASURE = True
except ImportError:
HAS_MEASURE = False
FIT_TEXT = os.getenv("FIT_TEXT", "1") != "0" # C3
# ----------------------------------------------------------------
# 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 _blend(hex_a: str, hex_b: str, t: float) -> str:
"""Mélange linéaire déterministe de deux couleurs hex (t: 0→a, 1→b)."""
a = (hex_a or "#000000").lstrip("#")
b = (hex_b or "#FFFFFF").lstrip("#")
t = max(0.0, min(1.0, float(t)))
out = "".join(
"%02X" % round(int(a[i:i + 2], 16)
+ (int(b[i:i + 2], 16) - int(a[i:i + 2], 16)) * t)
for i in (0, 2, 4))
return "#" + out
def _image_size(path):
"""Dimensions (w, h) d'une image : Pillow si présent, sinon lecture
directe des en-têtes PNG/JPEG. None si indéterminable."""
try:
from PIL import Image
with Image.open(path) as im:
return im.size
except Exception:
pass
import struct
try:
with open(path, "rb") as f:
head = f.read(26)
if head[:8] == b"\x89PNG\r\n\x1a\n":
w, h = struct.unpack(">II", head[16:24])
return int(w), int(h)
if head[:2] == b"\xff\xd8":
f.seek(2)
while True:
b2 = f.read(2)
if len(b2) < 2 or b2[0] != 0xFF:
return None
marker = b2[1]
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6,
0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE,
0xCF):
f.read(3)
h, w = struct.unpack(">HH", f.read(4))
return int(w), int(h)
ln = struct.unpack(">H", f.read(2))[0]
f.seek(ln - 2, 1)
except Exception:
pass
return None
def pick(d, *keys, default=""):
"""Accès tolérant : première clé non vide trouvée.
Si d est une chaîne (l'agent a produit 'Texte' au lieu de {label:'Texte'}),
on la retourne directement — évite les crashs 'str has no attribute get'."""
if isinstance(d, str):
return d
if not isinstance(d, dict):
return default
for k in keys:
v = d.get(k)
if v not in (None, ""):
return v
return default
def as_label(item, *keys, default=""):
"""Normalise un élément de liste en texte : 'Texte' ou {label:'Texte'}."""
if isinstance(item, str):
return item
if isinstance(item, dict):
return pick(item, *(keys or ("label", "texte", "titre", "title")),
default=default)
return default
def estimate_text_height(text: str, size_pt: int, width_cm: float,
font_name: str = "Calibri",
bold: bool = False) -> float:
"""Hauteur d'un texte (cm). Mesure réelle PIL si disponible (C3),
sinon heuristique v2 inchangée."""
if not text:
return 0.0
if HAS_MEASURE:
try:
return measure.text_height_cm(str(text), font_name,
size_pt, width_cm, bold)
except Exception:
pass
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
_MD_RES = [
(re.compile(r"\*\*(.+?)\*\*"), r"\1"),
(re.compile(r"__(.+?)__"), r"\1"),
(re.compile(r"`([^`]+)`"), r"\1"),
(re.compile(r"^#{1,4}\s+"), ""),
(re.compile(r"^[-•]\s+"), ""),
]
def strip_markdown_tree(node):
"""Nettoyage récursif du Markdown résiduel (C3) : gras,
italique, code inline, titres et puces en tête de valeur."""
if isinstance(node, dict):
return {k: strip_markdown_tree(v) for k, v in node.items()}
if isinstance(node, list):
return [strip_markdown_tree(v) for v in node]
if isinstance(node, str):
s = node
for rx, rep in _MD_RES:
s = rx.sub(rep, s)
return s
return node
# ----------------------------------------------------------------
# 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,
fit=True):
if (fit and FIT_TEXT and HAS_MEASURE and txt
and h and h > 0.3 and w and w > 0.5):
_ratio = (spacing / size) if spacing else None
_s, _t, _tr = measure.fit_text(
str(txt), font or self.F_BODY, size, w, h,
bold=bold, line_ratio=_ratio)
if _s != size or _tr:
print(f" ~ fit slide "
f"{getattr(self, '_slide_num', '?')} : "
f"{size}{_s} pt"
+ (" +troncature" if _tr else ""))
size, txt = _s, _t
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)
# Bloc label+texte centré verticalement dans la carte (C3b)
H_LBL, GAP_LT, H_TXT = 0.85, 0.18, 1.8
blk = H_LBL + GAP_LT + H_TXT
y_lbl = y + max(0.0, (ch - blk) / 2)
self._text(slide, self.MX + 3.81, y_lbl, 8.1, H_LBL, label,
size=14, bold=True, color=col, char_spacing=3,
anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, self.MX + 3.81, y_lbl + H_LBL + GAP_LT,
self.SLIDE_W - 2 * self.MX - 5.33, H_TXT, txt,
size=self.T["body"], color=self.C["body"],
anchor=MSO_ANCHOR.MIDDLE)
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")
# Hauteur réelle du bloc (C3b) : chiffre + desc (+ source)
H_VAL, GAP_D, H_DESC, GAP_S, H_SRC = 5.1, 0.23, 2.0, 0.14, 0.9
block = H_VAL + GAP_D + H_DESC + (
GAP_S + H_SRC if src else 0.0)
y = self._cy(block)
self._text(slide, 0, y, self.SLIDE_W, H_VAL, val,
font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True,
color=self.C["coral"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE)
y_desc = y + H_VAL + GAP_D
self._text(slide, 6.86, y_desc, self.SLIDE_W - 13.72, H_DESC, desc,
size=18, color=self.C["body"], align=PP_ALIGN.CENTER)
if src:
y_src = y_desc + H_DESC + GAP_S
self._text(slide, 6.86, y_src, self.SLIDE_W - 13.72, H_SRC, 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):
if isinstance(st, str):
st = {"titre": st}
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
if isinstance(ws, dict))
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):
if not isinstance(ws, dict):
continue
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", []):
if not isinstance(task, dict):
continue
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):
if not isinstance(task, dict):
continue
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"))
def _node(n):
"""Normalise un nœud : 'Texte'{label:'Texte'}, dict inchangé."""
if isinstance(n, str):
return {"label": n, "children": []}
if isinstance(n, dict):
return {"label": pick(n, "label", "titre", "title", "nom"),
"children": n.get("children", []) or []}
return {"label": str(n), "children": []}
root = d.get("root", {})
if isinstance(root, str):
root = {"label": root, "children": []}
if not root:
return
root = _node(root)
BOX_W, BOX_H = 3.81, 1.0
GAP_V, GAP_H = 1.27, 0.64
children = [_node(c) for c in 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 = [_node(g) for g in 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):
if not isinstance(item, dict):
continue
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,
as_label(item), 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)
_TOKEN_RE = re.compile(r"^([a-z_]+)(?:@(\d{1,3}))?$")
def _token_color(self, token: str, default: str = None) -> str:
"""Tokens de la charte + dérivés paramétrés (C6, palier 4) :
'navy@15' = navy éclairci de 15 % vers blanc. L'espace couleur
reste engendré par la charte — jamais de hex."""
mapping = {
"navy": self.C["navy"], "navy_light": self.C["navy2"],
"coral": self.C["coral"], "glacier": self.C["glacier"],
"slate": self.C["slate"], "card": self.C["card"],
"card_alt": self.C["card_alt"],
"white": self.C["white"], "body": self.C["body"],
"muted": self.C["muted"],
}
m = self._TOKEN_RE.match((token or "").strip().lower())
if not m:
return default or self.C["navy"]
base = mapping.get(m.group(1))
if base is None:
return default or self.C["navy"]
if m.group(2) is not None:
pct = min(100, int(m.group(2)))
return _blend(base, "#FFFFFF", pct / 100.0)
return base
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
FREE_SHAPES = {
"chevron": MSO_SHAPE.CHEVRON,
"arrow": MSO_SHAPE.RIGHT_ARROW,
"triangle": MSO_SHAPE.ISOSCELES_TRIANGLE,
"pill": MSO_SHAPE.ROUNDED_RECTANGLE,
"donut": MSO_SHAPE.DONUT,
"bracket_left": MSO_SHAPE.LEFT_BRACKET,
"bracket_right": MSO_SHAPE.RIGHT_BRACKET,
"oval": MSO_SHAPE.OVAL,
"diamond": MSO_SHAPE.DIAMOND,
"hexagon": MSO_SHAPE.HEXAGON,
"parallelogram": MSO_SHAPE.PARALLELOGRAM,
"moon": MSO_SHAPE.MOON,
}
_DARK_BASES = {"navy", "navy_light", "slate", "body"}
FREE_MAX_BLOCKS = 15
def _set_fill_alpha(self, sh, alpha_pct):
"""Transparence du remplissage (voile) — silencieux si le
shape n'a pas de solidFill (textbox…)."""
try:
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
qn("a:srgbClr"))
a = srgb.makeelement(
qn("a:alpha"),
{"val": str(int(max(0.0, min(100.0,
float(alpha_pct))) * 1000))})
srgb.append(a)
except Exception:
pass
def _free_geom(self, blk, full_bleed):
"""Grille 12×12 : zone utile par défaut, slide entière en
full_bleed."""
col = float(blk.get("col", 0))
row = float(blk.get("row", 0))
wc = float(blk.get("w", 4))
hr = float(blk.get("h", 1))
if full_bleed:
return ((col / self.FREE_COLS) * self.SLIDE_W,
(row / self.FREE_ROWS) * self.SLIDE_H,
(wc / self.FREE_COLS) * self.SLIDE_W,
(hr / self.FREE_ROWS) * self.SLIDE_H)
return (self._free_x(col), self._free_y(row),
self._free_w(wc), self._free_h(hr))
def _free_decorate(self, sh, blk):
"""Propriétés transverses du palier 4 : alpha, rotation (pas
de 15°), border {color, weight}, radius. Silencieux quand une
propriété ne s'applique pas au type de shape."""
if sh is None:
return
if blk.get("alpha") is not None:
self._set_fill_alpha(sh, blk["alpha"])
rot = blk.get("rotation")
if rot:
try:
sh.rotation = (round(float(rot) / 15.0) * 15) % 360
except Exception:
pass
border = blk.get("border")
if isinstance(border, dict):
try:
sh.line.color.rgb = hex_to_rgb(self._token_color(
border.get("color"), self.C["navy"]))
sh.line.width = Pt(float(border.get("weight", 1.0)))
except Exception:
pass
if blk.get("radius") is not None:
try:
sh.adjustments[0] = max(0.0, min(
0.5, float(blk["radius"])))
except Exception:
pass
def _render_freeform(self, slide, d):
mode = d.get("mode", "light")
bg = d.get("background")
on_dark = (mode == "dark")
if bg:
self._bg(slide, self._token_color(bg, self.C["white"]))
m = self._TOKEN_RE.match(str(bg).strip().lower())
if m and m.group(1) in self._DARK_BASES:
pct = int(m.group(2)) if m.group(2) is not None else 0
on_dark = pct < 45
else:
on_dark = False
elif on_dark:
self._bg(slide, self.C["navy"])
default_text = self.C["white"] if on_dark else self.C["body"]
blocks = d.get("blocks", [])
if len(blocks) > self.FREE_MAX_BLOCKS:
print(f" ~ freeform : {len(blocks)} blocs → "
f"{self.FREE_MAX_BLOCKS} (plafond moteur)")
blocks = blocks[:self.FREE_MAX_BLOCKS]
for blk in blocks:
btype = (blk.get("type") or "text").lower()
fb = bool(blk.get("full_bleed"))
x, y, w, h = self._free_geom(blk, fb)
sh = None
if btype == "rect":
sh = self._rect(slide, x, y, w, h,
self._token_color(blk.get("color"),
self.C["card"]),
rounded=blk.get("rounded", False))
elif btype == "card":
sh = self._card(slide, x, y, w, h,
self._token_color(blk.get("color"),
self.C["card"]))
elif btype == "circle":
d_cm = min(w, h)
sh = self._oval(slide, x, y, d_cm,
self._token_color(blk.get("color"),
self.C["coral"]))
elif btype == "shape":
kind = self.FREE_SHAPES.get(
str(blk.get("shape") or "oval").lower())
if kind is None:
print(f" ~ freeform : shape "
f"'{blk.get('shape')}' inconnue → oval")
kind = MSO_SHAPE.OVAL
sh = slide.shapes.add_shape(
kind, Cm(x), Cm(y), Cm(w), Cm(h))
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(
self._token_color(blk.get("color"),
self.C["coral"]))
sh.line.fill.background()
sh.shadow.inherit = False
_st = sh._element.find(qn("p:style"))
if _st is not None: # style implicite = ombre
sh._element.remove(_st)
if blk.get("text"):
tf = sh.text_frame
tf.word_wrap = True
tf.text = str(blk["text"])
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
for r in p.runs:
r.font.name = self.F_BODY
r.font.size = Pt(int(blk.get("size", 14)))
r.font.bold = True
r.font.color.rgb = hex_to_rgb(
self._token_color(blk.get("text_color"),
self.C["white"]))
elif btype == "line":
sh = slide.shapes.add_connector(
1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))
sh.line.color.rgb = hex_to_rgb(
self._token_color(blk.get("color"),
self.C["muted"]))
sh.line.width = Pt(float(blk.get("weight", 1.25)))
if blk.get("dash"):
sh.line.dash_style = MSO_LINE.DASH
sh.shadow.inherit = False
_st = sh._element.find(qn("p:style"))
if _st is not None:
sh._element.remove(_st)
sh = None # pas de décoration fill sur ligne
elif btype == "image":
sh = self._image(slide, x, y, w, h,
blk.get("image") or blk.get("src", ""),
fit=str(blk.get("fit") or "cover"))
elif btype == "badge":
d_cm = min(w, h)
sh = self._badge(slide, x + d_cm / 2, y + d_cm / 2,
d_cm, blk.get("text", ""),
fill=self._token_color(
blk.get("color"), self.C["navy"]))
elif btype == "stat":
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=self.F_DISPLAY,
size=int(blk.get("size", 72)),
bold=True,
color=self._token_color(
blk.get("color"), self.C["coral"]),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.MIDDLE)
elif btype in ("title", "heading"):
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=self.F_DISPLAY,
size=int(blk.get("size", 28)),
bold=True,
color=self._token_color(
blk.get("color"),
self.C["white"] if on_dark
else self.C["navy"]),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.MIDDLE)
else: # text
font = self.F_DISPLAY if blk.get("serif") else self.F_BODY
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
font=font,
size=int(blk.get("size", 16)),
bold=blk.get("bold", False),
italic=blk.get("italic", False),
color=self._token_color(
blk.get("color"), default_text),
align=self._free_align(
blk.get("align", "left")),
anchor=MSO_ANCHOR.TOP)
self._free_decorate(sh, blk)
def _free_align(self, a: str):
return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER,
"right": PP_ALIGN.RIGHT}.get((a or "left").lower(),
PP_ALIGN.LEFT)
# ---------------- assets & images (C4) ----------------
assets_dir = "assets" # surchargé par main() ou l'appelant
def _asset(self, name):
"""Résout un nom de fichier image vers un chemin existant :
chemin direct, puis assets_dir/name. None si introuvable."""
name = str(name or "").strip()
if not name:
return None
if os.path.isfile(name):
return name
cand = os.path.join(str(self.assets_dir or "assets"), name)
if os.path.isfile(cand):
return cand
return None
def _image(self, slide, x, y, w, h, name, fit="cover"):
"""Insère une image dans la zone (x, y, w, h) SANS déformation.
cover : remplit la zone, recadrage centré symétrique (crop_*).
contain : letterbox centré dans la zone.
Image introuvable ou illisible → placeholder déterministe."""
path = self._asset(name)
size = _image_size(path) if path else None
if not path or not size or size[0] <= 0 or size[1] <= 0:
print(f" ~ image absente ou illisible : {name} → placeholder")
self._rect(slide, x, y, w, h, self.C["card_alt"])
self._text(slide, x, y, w, h, f"[image : {name}]",
size=12, color=self.C["muted"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
fit=False)
return None
iw, ih = size
target_ratio = w / h
img_ratio = iw / ih
if fit == "contain":
if img_ratio > target_ratio:
w2, h2 = w, w / img_ratio
x2, y2 = x, y + (h - h2) / 2
else:
h2, w2 = h, h * img_ratio
x2, y2 = x + (w - w2) / 2, y
return slide.shapes.add_picture(path, Cm(x2), Cm(y2),
Cm(w2), Cm(h2))
pic = slide.shapes.add_picture(path, Cm(x), Cm(y), Cm(w), Cm(h))
if img_ratio > target_ratio: # trop large → crop lat.
c = (1 - target_ratio / img_ratio) / 2
pic.crop_left = c
pic.crop_right = c
elif img_ratio < target_ratio: # trop haut → crop vert.
c = (1 - img_ratio / target_ratio) / 2
pic.crop_top = c
pic.crop_bottom = c
return pic
def _rect_alpha(self, slide, x, y, w, h, color, alpha_pct):
"""Rectangle de couleur avec transparence (voile) — OOXML."""
sh = self._rect(slide, x, y, w, h, color)
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
qn("a:srgbClr"))
alpha = srgb.makeelement(
qn("a:alpha"), {"val": str(int(alpha_pct * 1000))})
srgb.append(alpha)
return sh
# ---------------- renderers images (C4) ----------------
def _render_image_split(self, slide, d):
"""Image 40 % d'un côté (side: left|right) + titre et bullets.
L'image s'arrête au-dessus du footer (numéro de page lisible)."""
side = str(d.get("side") or "left").lower()
img_w = self.SLIDE_W * 0.40
img_h = self.FOOTER_Y - 0.2
ix = 0.0 if side != "right" else self.SLIDE_W - img_w
self._image(slide, ix, 0.0, img_w, img_h,
pick(d, "image", "img"), fit="cover")
legende = pick(d, "legende", "caption")
if legende:
bh = 1.15
self._rect(slide, ix, img_h - bh, img_w, bh, self.C["navy"])
self._text(slide, ix + 0.5, img_h - bh, img_w - 1.0, bh,
legende, size=11, color=self.C["white"],
anchor=MSO_ANCHOR.MIDDLE)
cx = self.MX if side == "right" else img_w + 1.5
cw = self.SLIDE_W - img_w - 1.5 - self.MX
self._text(slide, cx, self.TITLE_Y, cw, self.TITLE_H,
pick(d, "titre", "title"),
font=self.F_DISPLAY, size=self.T["slide_title"],
bold=True, color=self.C["navy"])
bullets = [b for b in (d.get("bullets") or [])][:6]
bh_i, gap = 1.9, 0.5
tot = len(bullets) * (bh_i + gap) - gap if bullets else 0
y = self._cy(tot)
for b in bullets:
self._oval(slide, cx, y + 0.28, 0.28, self.C["coral"])
self._text(slide, cx + 0.8, y, cw - 0.8, bh_i,
as_label(b, "texte", "text"),
size=self.T["body"], color=self.C["body"])
y += bh_i + gap
def _render_image_full(self, slide, d):
"""Image plein cadre + voile navy 60 % + titre display blanc.
Ouverture de chapitre visuelle (alternative à section_divider)."""
self._bg(slide, self.C["navy"])
self._image(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H,
pick(d, "image", "img"), fit="cover")
self._rect_alpha(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H,
self.C["navy"], 60)
self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"])
self._text(slide, self.MX, 7.6, 26.0, 4.5,
pick(d, "titre", "title"),
font=self.F_DISPLAY, size=44, bold=True,
color=self.C["white"], spacing=48)
st = pick(d, "sous_titre", "subtitle")
if st:
self._text(slide, self.MX, 12.3, 22.0, 1.5, st,
size=16, italic=True, color=self.C["glacier"])
# ---------------- charts natifs (C5 lot 1) ----------------
def _num(self, v, default=0.0):
"""Nombre robuste : int/float directs, chaînes '12,5' ou '12.5'."""
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).replace(",", ".").replace(" ", ""))
except (TypeError, ValueError):
return float(default)
def _chart_series_colors(self):
return [self.C["navy"], self.C["coral"], self.C["glacier"]]
def _chart_base(self, slide, x, y, w, h, chart_type, chart_data):
"""Insertion + style de base commun (police, taille, couleur)."""
gf = slide.shapes.add_chart(chart_type, Cm(x), Cm(y),
Cm(w), Cm(h), chart_data)
ch = gf.chart
ch.font.name = self.F_BODY
ch.font.size = Pt(11)
ch.font.color.rgb = hex_to_rgb(self.C["body"])
return ch
def _chart_caption(self, slide, d):
"""Unité (droite de la zone titre) et source (bas de page)."""
unite = pick(d, "unite", "unit")
if unite:
self._text(slide, self.SLIDE_W - self.MX - 8.0,
self.TITLE_Y + 0.35, 8.0, 0.8, f"en {unite}",
size=12, italic=True, color=self.C["muted"],
align=PP_ALIGN.RIGHT)
source = pick(d, "source")
if source:
self._text(slide, self.MX, self.CONT_B - 0.55,
self.SLIDE_W - 2 * self.MX, 0.55,
f"Source : {source}", size=10,
color=self.C["muted"])
def _chart_zone(self, d):
"""Zone du graphique (réserve la ligne source si présente)."""
h = self.CONT_H - (0.7 if pick(d, "source") else 0.0)
return self.MX, self.CONT_Y, self.SLIDE_W - 2 * self.MX, h
def _render_bar_chart(self, slide, d):
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION,
XL_LABEL_POSITION)
self._title(slide, pick(d, "titre", "title"))
self._chart_caption(slide, d)
cats = [as_label(c) for c in (d.get("categories") or [])]
series = [s for s in (d.get("series") or []) if isinstance(s, dict)]
if len(cats) > 8:
print(f" ~ bar_chart : {len(cats)} catégories → 8 (borne)")
cats = cats[:8]
if len(series) > 3:
print(f" ~ bar_chart : {len(series)} séries → 3 (borne)")
series = series[:3]
horiz = bool(d.get("horizontal"))
if horiz:
# BAR_CLUSTERED trace de bas en haut : on inverse pour un
# ordre de lecture naturel (1re catégorie du YAML en haut).
cats = cats[::-1]
cd = CategoryChartData()
cd.categories = cats
for s in series:
vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)]
vals += [0.0] * (len(cats) - len(vals))
if horiz:
vals = vals[::-1]
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
x, y, w, h = self._chart_zone(d)
ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz
else XL_CHART_TYPE.COLUMN_CLUSTERED)
ch = self._chart_base(slide, x, y, w, h, ctype, cd)
plot = ch.plots[0]
plot.gap_width = 60
if len(series) > 1:
plot.overlap = -10
for i, ser in enumerate(ch.series):
ser.format.fill.solid()
ser.format.fill.fore_color.rgb = hex_to_rgb(
self._chart_series_colors()[i % 3])
ch.value_axis.visible = False
ch.value_axis.has_major_gridlines = False
cat_ax = ch.category_axis
cat_ax.has_major_gridlines = False
cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"])
cat_ax.tick_labels.font.size = Pt(11)
plot.has_data_labels = True
dls = plot.data_labels
dls.font.size = Pt(10)
dls.font.bold = True
dls.font.color.rgb = hex_to_rgb(self.C["body"])
dls.position = (XL_LABEL_POSITION.OUTSIDE_END)
ch.has_legend = len(series) > 1
if ch.has_legend:
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
ch.legend.include_in_layout = False
ch.legend.font.size = Pt(11)
def _render_line_chart(self, slide, d):
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION,
XL_LABEL_POSITION, XL_MARKER_STYLE)
self._title(slide, pick(d, "titre", "title"))
self._chart_caption(slide, d)
pts = [as_label(p) for p in (d.get("points_x") or [])]
series = [s for s in (d.get("series") or []) if isinstance(s, dict)]
if len(pts) > 12:
print(f" ~ line_chart : {len(pts)} points → 12 (borne)")
pts = pts[:12]
if len(series) > 3:
print(f" ~ line_chart : {len(series)} séries → 3 (borne)")
series = series[:3]
cd = CategoryChartData()
cd.categories = pts
for s in series:
vals = [self._num(v) for v in (s.get("values") or [])][:len(pts)]
vals += [0.0] * (len(pts) - len(vals))
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
x, y, w, h = self._chart_zone(d)
ch = self._chart_base(slide, x, y, w, h,
XL_CHART_TYPE.LINE_MARKERS, cd)
for i, ser in enumerate(ch.series):
col = hex_to_rgb(self._chart_series_colors()[i % 3])
ser.format.line.color.rgb = col
ser.format.line.width = Pt(2.25)
ser.smooth = False
ser.marker.style = XL_MARKER_STYLE.CIRCLE
ser.marker.size = 6
ser.marker.format.fill.solid()
ser.marker.format.fill.fore_color.rgb = col
ser.marker.format.line.fill.background()
va = ch.value_axis
va.has_major_gridlines = True
va.major_gridlines.format.line.color.rgb = hex_to_rgb(
self.C["card_alt"])
va.tick_labels.font.size = Pt(10)
va.tick_labels.font.color.rgb = hex_to_rgb(self.C["muted"])
va.format.line.fill.background()
cat_ax = ch.category_axis
cat_ax.has_major_gridlines = False
cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"])
cat_ax.tick_labels.font.size = Pt(11)
ch.has_legend = len(series) > 1
if ch.has_legend:
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
ch.legend.include_in_layout = False
ch.legend.font.size = Pt(11)
# Règle fixe : dernier point de la série 1 étiqueté corail gras
if series and pts:
try:
last = ch.series[0].points[len(pts) - 1]
dl = last.data_label
dl.position = XL_LABEL_POSITION.ABOVE
v = self._num((series[0].get("values") or [0])[
min(len(pts), len(series[0].get("values") or [])) - 1])
txt = ("%g" % v)
dl.text_frame.text = txt
run = dl.text_frame.paragraphs[0].runs[0]
run.font.size = Pt(12)
run.font.bold = True
run.font.color.rgb = hex_to_rgb(self.C["coral"])
run.font.name = self.F_BODY
except Exception as e:
print(f" ~ line_chart : étiquette dernier point sautée "
f"({e})")
def _render_donut_split(self, slide, d):
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
self._title(slide, pick(d, "titre", "title"))
self._chart_caption(slide, d)
segs = [s for s in (d.get("segments") or []) if isinstance(s, dict)]
if len(segs) > 6:
print(f" ~ donut_split : {len(segs)} segments → 6 (borne)")
segs = segs[:6]
labels = [pick(s, "label", default="") for s in segs]
vals = [self._num(pick(s, "valeur", "value")) for s in segs]
cd = CategoryChartData()
cd.categories = labels
cd.add_series("Répartition", tuple(vals))
x, y, w, h = self._chart_zone(d)
cw = w * 0.55
side = min(cw, h)
cx0 = x + (cw - side) / 2
cy0 = y + (h - side) / 2
ch = self._chart_base(slide, cx0, cy0, side, side,
XL_CHART_TYPE.DOUGHNUT, cd)
ch.has_legend = False
for i, pt in enumerate(ch.series[0].points):
pt.format.fill.solid()
pt.format.fill.fore_color.rgb = hex_to_rgb(
self.cycle[i % len(self.cycle)])
pt.format.line.color.rgb = hex_to_rgb(self.C["white"])
pt.format.line.width = Pt(1.5)
plot_el = ch.plots[0]._element # <c:doughnutChart>
hs = plot_el.find(qn("c:holeSize"))
if hs is None:
hs = plot_el.makeelement(qn("c:holeSize"), {})
plot_el.append(hs)
hs.set("val", "65")
centre = pick(d, "valeur_centrale", "centre")
if centre:
self._text(slide, cx0, cy0 + side / 2 - 1.1, side, 2.2,
centre, font=self.F_DISPLAY, size=24, bold=True,
color=self.C["navy"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE, fit=False)
# Légende détaillée custom à droite
lx = x + cw + 1.2
lw = w - cw - 1.2
ih, gap = 1.35, 0.45
tot = len(segs) * (ih + gap) - gap if segs else 0
ly = self._cy(tot)
for i, s in enumerate(segs):
self._oval(slide, lx, ly + (ih - 0.5) / 2, 0.5,
self.cycle[i % len(self.cycle)])
self._text(slide, lx + 1.0, ly, lw - 4.2, ih,
pick(s, "label", default=""),
size=14, bold=True, color=self.C["body"],
anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, lx + lw - 3.2, ly, 3.2, ih,
"%g" % self._num(pick(s, "valeur", "value")),
size=14, color=self.C["muted"],
align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
ly += ih + gap
# ---------------- data & structure (C5 lot 2) ----------------
WF_MAX_STEPS = 8
HM_MAX_COLS = 6
HM_MAX_ROWS = 8
FUNNEL_MAX = 5
def _render_waterfall(self, slide, d):
"""Pont de valeur : cumuls calculés, jamais fournis."""
self._title(slide, pick(d, "titre", "title"))
self._chart_caption(slide, d)
zx, zy, zw, zh = self._chart_zone(d)
dep = d.get("depart") or {}
arr = d.get("arrivee") or {}
marches = (d.get("marches") or [])[:self.WF_MAX_STEPS]
if len(d.get("marches") or []) > self.WF_MAX_STEPS:
print(f" ~ waterfall : marches tronquées à "
f"{self.WF_MAX_STEPS}")
v0 = self._num(pick(dep, "valeur", "value", default=0))
cums = [v0]
for m in marches:
cums.append(cums[-1] + self._num(pick(m, "delta", default=0)))
v_end = cums[-1]
declared = self._num(pick(arr, "valeur", "value",
default=v_end), default=v_end)
if abs(declared - v_end) > 1e-9:
print(f" ~ waterfall : arrivée déclarée {declared:g}"
f"cumul {v_end:g} — le cumul fait foi")
chart_top = zy + 0.7 # réserve étiquettes hautes
chart_b = zy + zh - 1.3 # réserve labels bas
chart_h = chart_b - chart_top
vmin = min(0.0, min(cums))
vmax = max(max(cums), v0, 0.0)
span = (vmax - vmin) or 1.0
hpu = chart_h / span
base_y = chart_top + vmax * hpu # y de la valeur 0
n = len(marches) + 2
slot = zw / n
bar_w = slot * 0.62
def bar(i, v_from, v_to, color):
x = zx + i * slot + (slot - bar_w) / 2
y1 = base_y - max(v_from, v_to) * hpu
h = max(0.06, abs(v_to - v_from) * hpu)
self._rect(slide, x, y1, bar_w, h, color)
return x, y1
def val_label(i, y_ref, text, color):
self._text(slide, zx + i * slot, y_ref - 0.62, slot, 0.55,
text, size=11, bold=True, color=color,
align=PP_ALIGN.CENTER, fit=False)
def cat_label(i, label):
sz = 9 if len(str(label)) > 14 else 10
self._text(slide, zx + i * slot + 0.05, chart_b + 0.15,
slot - 0.1, 1.05, label, size=sz,
color=self.C["slate"], align=PP_ALIGN.CENTER)
def connector(x1, x2, level):
conn = slide.shapes.add_connector(
1, Cm(x1), Cm(base_y - level * hpu),
Cm(x2), Cm(base_y - level * hpu))
conn.line.color.rgb = hex_to_rgb(self.C["muted"])
conn.line.width = Pt(1.0)
conn.line.dash_style = MSO_LINE.DASH
conn.shadow.inherit = False
ln = slide.shapes.add_connector(
1, Cm(zx), Cm(base_y), Cm(zx + zw), Cm(base_y))
ln.line.color.rgb = hex_to_rgb(self.C["muted"])
ln.line.width = Pt(0.75)
ln.shadow.inherit = False
x, y1 = bar(0, 0.0, v0, self.C["navy"])
val_label(0, y1, f"{v0:g}", self.C["navy"])
cat_label(0, as_label(dep, "label", default="Départ"))
prev_x_end = x + bar_w
for i, m in enumerate(marches, start=1):
c_prev, c_cur = cums[i - 1], cums[i]
delta = c_cur - c_prev
color = self.C["coral"] if delta >= 0 else self.C["slate"]
x, y1 = bar(i, c_prev, c_cur, color)
sign = "+" if delta >= 0 else "\u2212"
val_label(i, y1, f"{sign}{abs(delta):g}", color)
cat_label(i, as_label(m, "label"))
connector(prev_x_end, x, c_prev)
prev_x_end = x + bar_w
i = n - 1
x, y1 = bar(i, 0.0, v_end, self.C["navy"])
val_label(i, y1 if v_end >= 0 else base_y, f"{v_end:g}",
self.C["navy"])
cat_label(i, as_label(arr, "label", default="Arrivée"))
connector(prev_x_end, x, v_end)
def _render_heatmap_table(self, slide, d):
"""Grille d'intensité : score 0-4 → 5 teintes du navy."""
self._title(slide, pick(d, "titre", "title"))
headers = [as_label(h) for h in (d.get("headers") or [])][
:self.HM_MAX_COLS]
rows = (d.get("rows") or [])[:self.HM_MAX_ROWS]
if not headers or not rows:
self._text(slide, self.MX, self.CONT_Y, 10, 1.0,
"[données manquantes]", size=14,
color=self.C["muted"])
return
tints = [_blend("#FFFFFF", self.C["navy"], t)
for t in (0.10, 0.28, 0.50, 0.74, 1.0)]
legende = pick(d, "legende", "legend")
label_w = 6.5
gap = 0.12
w_total = self.SLIDE_W - 2 * self.MX
col_w = (w_total - label_w - gap * len(headers)) / len(headers)
head_h = 0.9
leg_h = 0.8 if legende else 0.0
row_h = min(1.55, (self.CONT_H - head_h - leg_h
- gap * (len(rows) + 1)) / len(rows))
total_h = head_h + gap + len(rows) * (row_h + gap) + leg_h
y = self._cy(total_h)
for j, h in enumerate(headers):
x = self.MX + label_w + gap + j * (col_w + gap)
self._text(slide, x, y, col_w, head_h, h, size=12,
bold=True, color=self.C["slate"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
cy = y + head_h + gap
for r in rows:
self._text(slide, self.MX, cy, label_w, row_h,
as_label(r, "label"), size=13, bold=True,
color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE)
scores = (r.get("scores") if isinstance(r, dict) else []) or []
for j in range(len(headers)):
s = int(self._num(scores[j] if j < len(scores) else 0))
if not 0 <= s <= 4:
print(f" ~ heatmap : score {s} hors 0-4 — clampé")
s = max(0, min(4, s))
x = self.MX + label_w + gap + j * (col_w + gap)
self._rect(slide, x, cy, col_w, row_h, tints[s])
self._text(slide, x, cy, col_w, row_h, str(s), size=12,
bold=True,
color=self.C["white"] if s >= 2
else self.C["navy"],
align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE, fit=False)
cy += row_h + gap
if legende:
self._text(slide, self.MX, cy + 0.05, w_total, 0.6, legende,
size=10, italic=True, color=self.C["muted"])
def _render_funnel(self, slide, d):
"""Entonnoir : trapèzes proportionnels, plancher 30 %,
dernier étage corail."""
self._title(slide, pick(d, "titre", "title"))
self._chart_caption(slide, d)
zx, zy, zw, zh = self._chart_zone(d)
etapes = (d.get("etapes") or [])[:self.FUNNEL_MAX]
if len(etapes) < 2:
self._text(slide, zx, zy, 12, 1.0,
"[funnel : 2 étapes minimum]", size=14,
color=self.C["muted"])
return
vals = [max(0.0, self._num(pick(e, "valeur", "value",
default=0)))
for e in etapes]
vmax = max(vals) or 1.0
has_desc = any(pick(e, "description") for e in etapes)
fun_w = zw * (0.58 if has_desc else 0.80)
cx = zx + fun_w / 2
gap = 0.18
n = len(etapes)
stage_h = (zh - 0.4 - gap * (n - 1)) / n
widths = [max(0.30, v / vmax) * fun_w for v in vals]
y = zy + 0.2
for i, e in enumerate(etapes):
wt = widths[i]
wb = widths[i + 1] if i + 1 < n else widths[i] * 0.72
color = self.C["coral"] if i == n - 1 else _blend(self.C["navy"], self.C["glacier"],
0.5 * i / max(1, n - 1))
fb = slide.shapes.build_freeform(
cx - wt / 2, y, scale=360000)
fb.add_line_segments(
[(cx + wt / 2, y),
(cx + wb / 2, y + stage_h),
(cx - wb / 2, y + stage_h)], close=True)
sh = fb.convert_to_shape()
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(color)
sh.line.fill.background()
sh.shadow.inherit = False
label = as_label(e, "label")
val = self._num(pick(e, "valeur", "value", default=0))
self._text(slide, cx - wt / 2, y + stage_h / 2 - 0.78,
wt, 0.8, label, size=14, bold=True,
color=self.C["white"], align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.BOTTOM)
self._text(slide, cx - wt / 2, y + stage_h / 2 + 0.06,
wt, 0.6, f"{val:g}", size=12,
color=self.C["white"], align=PP_ALIGN.CENTER,
fit=False)
desc = pick(e, "description")
if desc:
dx = zx + fun_w + 1.2
self._text(slide, dx, y, zx + zw - dx, stage_h, desc,
size=13, color=self.C["body"],
anchor=MSO_ANCHOR.MIDDLE)
y += stage_h + gap
# ---------------- structure & concept (C5 lot 3) ----------------
AGENDA_MAX = 8
PYRAMID_WIDTHS = {3: (0.44, 0.72, 1.0),
4: (0.40, 0.60, 0.80, 1.0)}
def _render_agenda(self, slide, d):
"""Sommaire : badges numérotés, section active en corail."""
self._title(slide, pick(d, "titre", "title"))
sections = (d.get("sections") or [])[:self.AGENDA_MAX]
if len(d.get("sections") or []) > self.AGENDA_MAX:
print(f" ~ agenda : sections tronquées à {self.AGENDA_MAX}")
if len(sections) < 2:
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
"[agenda : 2 sections minimum]", size=14,
color=self.C["muted"])
return
n = len(sections)
gap = 0.5
row_h = min(1.9, (self.CONT_H - gap * (n - 1)) / n)
tot = n * row_h + (n - 1) * gap
y = self._cy(tot)
bd = self.components["badge"]["sizes"]["m"]
x0 = self.MX + 1.5
w = self.SLIDE_W - 2 * self.MX - 3.0
for i, s in enumerate(sections):
if not isinstance(s, dict):
s = {"label": str(s)}
actif = bool(s.get("actif"))
num = s.get("numero", i + 1)
fill = self.C["coral"] if actif else self.C["navy"]
self._badge(slide, x0 + bd / 2, y + row_h / 2, bd, num,
fill=fill, font_size=20)
self._text(slide, x0 + bd + 1.0, y, w - bd - 6.0, row_h,
as_label(s, "label", "titre"),
size=20 if actif else 19, bold=actif,
color=self.C["navy"] if actif
else self.C["body"],
anchor=MSO_ANCHOR.MIDDLE)
duree = pick(s, "duree", "duration")
if duree:
self._text(slide, x0 + w - 4.5, y, 4.5, row_h, duree,
size=13, italic=True, color=self.C["muted"],
align=PP_ALIGN.RIGHT,
anchor=MSO_ANCHOR.MIDDLE, fit=False)
if i < n - 1:
self._rect(slide, x0 + bd + 1.0,
y + row_h + gap / 2 - 0.015,
w - bd - 1.0, 0.03, self.C["card"])
y += row_h + gap
def _render_pyramid(self, slide, d):
"""Pyramide à degrés : largeurs fixes, couleurs fixes."""
self._title(slide, pick(d, "titre", "title"))
niveaux = (d.get("niveaux") or d.get("levels") or [])[:4]
if len(niveaux) < 3:
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
"[pyramid : 3 ou 4 niveaux]", size=14,
color=self.C["muted"])
return
n = len(niveaux)
widths = self.PYRAMID_WIDTHS[n]
colors = [self.C["navy"], self.C["navy2"],
self.C["slate"], self.C["glacier"]]
if n == 3:
colors = [self.C["navy"], self.C["navy2"],
self.C["glacier"]]
has_desc = any(isinstance(nv, dict) and pick(nv, "description")
for nv in niveaux)
zone_w = self.SLIDE_W - 2 * self.MX
pyr_w = zone_w * (0.58 if has_desc else 0.80)
pyr_x = self.MX if has_desc else self.MX + (zone_w - pyr_w) / 2
cx = pyr_x + pyr_w / 2
gap = 0.15
avail = self.CONT_H - 0.4
stage_h = (avail - gap * (n - 1)) / n
y = self._cy(avail) + 0.2
for i, nv in enumerate(niveaux):
if not isinstance(nv, dict):
nv = {"label": str(nv)}
wt = widths[i] * pyr_w
color = colors[i]
txt_color = self.C["navy"] if color == self.C["glacier"] else self.C["white"]
self._rect(slide, cx - wt / 2, y, wt, stage_h, color)
self._text(slide, cx - wt / 2 + 0.3, y, wt - 0.6, stage_h,
as_label(nv, "label", "titre"),
size=16, bold=True, color=txt_color,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
desc = pick(nv, "description", "detail")
if desc:
dx = pyr_x + pyr_w + 1.2
self._text(slide, dx, y,
self.SLIDE_W - self.MX - dx, stage_h, desc,
size=13, color=self.C["body"],
anchor=MSO_ANCHOR.MIDDLE)
y += stage_h + gap
# ---------------- 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",
"agenda": "_render_agenda",
"pyramid": "_render_pyramid",
"waterfall": "_render_waterfall",
"heatmap_table": "_render_heatmap_table",
"funnel": "_render_funnel",
"bar_chart": "_render_bar_chart",
"line_chart": "_render_line_chart",
"donut_split": "_render_donut_split",
"image_split": "_render_image_split",
"image_full": "_render_image_full",
}
def render(self, data, output_path: str):
if isinstance(data, str):
data = yaml.safe_load(data)
data = strip_markdown_tree(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)
notes = sd.get("notes") if isinstance(sd, dict) else None
if notes:
slide.notes_slide.notes_text_frame.text = str(notes)
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")
parser.add_argument("--assets", default=None,
help="Dossier des images (défaut : <input>/../assets)")
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.assets_dir = args.assets or os.path.join(
os.path.dirname(os.path.abspath(args.input_file)),
"..", "assets")
engine.render(data, args.output)
if __name__ == "__main__":
main()