Files
sliding-automation/archive/v1_pipeline/render_engine.py
T

2655 lines
103 KiB
Python
Raw Normal View History

"""
render_engine.py — Sliding Design System · Pernod Ricard
=========================================================
Moteur de rendu générique JSON → PPTX.
Usage :
from render_engine import RenderEngine
engine = RenderEngine("theme.yaml", "components.yaml", "layouts.yaml")
engine.render(json_data, "output.pptx")
Ou en ligne de commande :
python render_engine.py presentation.json output.pptx
Architecture :
RenderEngine.render()
└- pour chaque slide :
1. _resolve_layout() → charge la config du layout
2. _render_background() → fond (C01)
3. _render_signature() → logo, barre d'accent, footer (C02-C05)
4. _measure_title() → calcule hauteur réelle du titre
5. _render_title() → place le titre (C02)
6. pour chaque content_zone :
_measure_component() → hauteur réelle
_render_component() → dispatch vers le bon renderer
"""
from __future__ import annotations
import json
import math
import os
import sys
from pathlib import Path
from typing import Any
import yaml
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.util import Cm, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.oxml.ns import qn
from lxml import etree
# -
# CONSTANTES
# -
CM = 360000 # 1 cm = 360 000 EMU
PT = 12700 # 1 pt = 12 700 EMU
SLIDE_W = 12192000 # 33.87 cm
SLIDE_H = 6858000 # 19.05 cm
FOOTER_TOP = 18.35 # cm
FOOTER_H = 0.70 # cm
# -
# UTILITAIRES
# -
def cm(v: float) -> int:
"""Centimètres → EMU."""
return int(v * CM)
def pt(v: float) -> int:
"""Points → EMU (pour line_spacing, etc.)."""
return int(v * PT)
def hex_to_rgb(h: str) -> RGBColor:
"""'#rrggbb' → RGBColor."""
h = h.lstrip("#")
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def resolve_ref(value: str, theme: dict) -> str:
"""
Résout une référence theme.* dans une valeur YAML.
Ex: 'theme.colors.primary.rose''#ff9166'
Retourne la valeur brute si ce n'est pas une ref.
"""
if not isinstance(value, str) or not value.startswith("theme."):
return value
parts = value.split(".")[1:] # retire 'theme'
node = theme
for p in parts:
if isinstance(node, dict) and p in node:
node = node[p]
else:
return value # ref non résolue → retourne telle quelle
return node
def add_text_box(slide, left, top, width, height,
text, font_name, font_size_pt, bold=False, italic=False,
color="#000000", align=PP_ALIGN.LEFT, word_wrap=True):
"""Ajoute une text box sur le slide. Retourne le shape."""
txBox = slide.shapes.add_textbox(cm(left), cm(top), cm(width), cm(height))
tf = txBox.text_frame
tf.word_wrap = word_wrap
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.name = font_name
run.font.size = Pt(font_size_pt)
run.font.bold = bold
run.font.italic = italic
run.font.color.rgb = hex_to_rgb(color)
return txBox
def add_rect(slide, left, top, width, height, fill_color, border_color=None, border_width_cm=0):
"""Ajoute un rectangle plein. Retourne le shape."""
shape = slide.shapes.add_shape(
1, # MSO_SHAPE_TYPE.RECTANGLE
cm(left), cm(top), cm(width), cm(height)
)
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(fill_color)
if border_color and border_width_cm > 0:
shape.line.color.rgb = hex_to_rgb(border_color)
shape.line.width = cm(border_width_cm)
else:
shape.line.fill.background()
return shape
def add_line(slide, x1, y1, x2, y2, color="#e8e2d6", width_cm=0.03):
"""Ajoute une ligne."""
from pptx.util import Emu
connector = slide.shapes.add_connector(1, cm(x1), cm(y1), cm(x2), cm(y2))
connector.line.color.rgb = hex_to_rgb(color)
connector.line.width = cm(width_cm)
return connector
def estimate_text_height(text: str, font_size_pt: float,
box_width_cm: float, line_spacing: float = 1.15) -> float:
"""
Estime la hauteur en cm d'un texte dans une boîte.
Heuristique : ~2.2 caractères par cm de largeur à 11pt, scaled par font_size.
"""
chars_per_line = max(1, int(box_width_cm * 2.2 * (11 / font_size_pt)))
lines = 0
for paragraph in text.split("\n"):
if not paragraph.strip():
lines += 0.5
continue
lines += math.ceil(len(paragraph) / chars_per_line)
line_height_cm = font_size_pt * 0.035 * line_spacing
return lines * line_height_cm
# -
# RENDER ENGINE
# -
class RenderEngine:
"""
Moteur principal. Charge les 3 YAML, expose render(json_data, output_path).
"""
def __init__(self, theme_path: str, components_path: str, layouts_path: str):
with open(theme_path, encoding="utf-8") as f:
self.theme = yaml.safe_load(f)
with open(components_path, encoding="utf-8") as f:
self.components = yaml.safe_load(f)["components"]
with open(layouts_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
self.layouts = data["layouts"]
# Polices résolues (avec fallback si non installées)
self._font_display = self._resolve_font("display")
self._font_body = self._resolve_font("body")
# Cycle couleur (index global, remis à zéro par présentation)
self._cycle_index = 0
# - Résolution des polices -
def _resolve_font(self, role: str) -> str:
font_cfg = self.theme["typography"][role]
primary = font_cfg["family"]
fallback = font_cfg.get("fallback", "Arial")
if self._is_font_available(primary):
return primary
return fallback
def _is_font_available(self, font_name: str) -> bool:
fonts_dir = Path(self.theme["assets"].get("fonts_path", "assets/fonts/"))
if not fonts_dir.exists():
return False
fn = font_name.lower().replace(" ", "")
return any(fn in f.stem.lower().replace(" ", "").replace("-", "") for f in fonts_dir.iterdir())
def patch_pptx_theme(self, output_path: str):
import zipfile as _zf, shutil as _sh
from lxml import etree as _et
tmp = output_path + ".tmp"
ns = "http://schemas.openxmlformats.org/drawingml/2006/main"
zin = _zf.ZipFile(output_path, "r")
zout = _zf.ZipFile(tmp, "w", _zf.ZIP_DEFLATED)
for item in zin.infolist():
if item.filename != "ppt/theme/theme1.xml":
zout.writestr(item.filename, zin.read(item.filename))
data = zin.read("ppt/theme/theme1.xml")
root = _et.fromstring(data)
fs = root.find(f".//{{{ns}}}fontScheme")
if fs is not None:
for tag, font in [("majorFont", self._font_display), ("minorFont", self._font_body)]:
node = fs.find(f"{{{ns}}}{tag}")
if node is not None:
lat = node.find(f"{{{ns}}}latin")
if lat is not None:
lat.set("typeface", font)
zout.writestr("ppt/theme/theme1.xml", _et.tostring(root, encoding="unicode").encode("utf-8"))
zin.close()
zout.close()
_sh.move(tmp, output_path)
def _font(self, role: str) -> str:
return self._font_display if role == "display" else self._font_body
# - Résolution des refs theme -
def _r(self, value: Any) -> Any:
"""Résout une ref theme.* si nécessaire."""
return resolve_ref(value, self.theme)
def _cycle_color(self) -> str:
colors = self.theme["colors"]["cycle"]
c = colors[self._cycle_index % len(colors)]
self._cycle_index += 1
return c
# - Mesure -
def _measure_title(self, layout_cfg: dict, slide_data: dict) -> float:
"""
Calcule la hauteur réelle occupée par le bloc titre + sous-titre.
Retourne la hauteur en cm.
"""
tz = layout_cfg.get("title_zone")
if not tz:
return 0.0
titre = slide_data.get("titre", "")
sous_titre = slide_data.get("sous_titre", "")
font_override = tz.get("font_override", {})
size_pt = font_override.get("size_pt",
self.theme["typography"]["sizes"]["slide_title"])
width_cm = tz.get("width_cm", 30.0)
h = estimate_text_height(titre, size_pt, width_cm, 1.1)
if sous_titre:
sub_size = tz.get("subtitle", {}).get("size_pt",
self.theme["typography"]["sizes"]["slide_subtitle"])
h += estimate_text_height(sous_titre, sub_size, width_cm, 1.1)
h += 0.15 # marge entre titre et sous-titre
return max(h, 0.60) # minimum 0.6 cm
def _measure_component(self, zone: dict, slide_data: dict) -> float:
"""
Estime la hauteur réelle d'une content_zone selon son contenu.
Retourne la hauteur en cm. Si non estimable, retourne height_cm du layout.
"""
comp_id = zone.get("component", "")
max_h = zone.get("height_cm", 14.0)
# bullet_list
if comp_id == "C06":
bullets = slide_data.get("bullets", [])
if not bullets:
# cherche dans les sous-clés (two_cols, etc.)
return max_h
total_h = 0.0
for b in bullets:
lvl = b.get("niveau", 1)
size_pt = [20, 18, 16][min(lvl - 1, 2)]
w = zone.get("width_cm", 28.0)
total_h += estimate_text_height(
b.get("texte", ""), size_pt, w - (lvl - 1) * 0.5)
total_h += [0.14, 0.08, 0.04][min(lvl - 1, 2)]
return min(total_h + 0.3, max_h)
# text_paragraph (executive_summary blocs)
if comp_id == "C07":
# cherche le champ associé dans slide_data
for key in ["situation", "complication", "resolution",
"contenu", "description"]:
if key in slide_data:
txt = slide_data[key]
h = estimate_text_height(txt, 11, zone.get("width_cm", 28.0))
return min(h + 0.6, max_h) # +0.6 pour le titre de bloc
return max_h
# kpi_grid → hauteur calculée selon nb items
if comp_id == "C09":
items = slide_data.get("items", [])
n = len(items)
grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)}
cols, rows = grid.get(n, (3, 2))
card_h = 4.5 # hauteur d'une carte KPI en cm
gap = 0.4
return min(rows * card_h + (rows - 1) * gap, max_h)
# big_stat → hauteur fixe
if comp_id == "C10":
return max_h
# Pour tous les autres composants visuels complexes → hauteur max
return max_h
# - Rendu principal -
def render(self, json_data: dict | str, output_path: str):
"""
Point d'entrée. Accepte un dict ou une chaîne JSON.
Produit le fichier PPTX à output_path.
"""
if isinstance(json_data, str):
json_data = json.loads(json_data)
prs = Presentation()
prs.slide_width = Emu(SLIDE_W)
prs.slide_height = Emu(SLIDE_H)
# Supprime les layouts par défaut (on dessine tout manuellement)
blank_layout = prs.slide_layouts[6] # layout "blank"
self._cycle_index = 0
slides = json_data.get("slides", [])
for i, slide_data in enumerate(slides):
slide = prs.slides.add_slide(blank_layout)
layout_name = slide_data.get("layout", "default_bullets")
self._render_slide(slide, slide_data, layout_name, i + 1, len(slides))
prs.save(output_path)
self.patch_pptx_theme(output_path)
print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)")
def _render_slide(self, slide, slide_data: dict, layout_name: str,
slide_num: int, total: int):
"""Orchestre le rendu d'un slide complet."""
layout_cfg = self.layouts.get(layout_name)
if not layout_cfg:
print(f" ⚠ Layout inconnu '{layout_name}' → fallback default_bullets")
layout_cfg = self.layouts["default_bullets"]
layout_name = "default_bullets"
# - 1. Background -
self._render_background(slide, layout_cfg)
# - 2. Signature (footer, logo, accent bar) -
self._render_footer(slide, layout_name, slide_num)
self._render_logo(slide, layout_name)
# - 3. Titre + mesure -
if layout_name == "section_divider":
self._render_section_divider_title(slide, slide_data)
return
title_h = self._measure_title(layout_cfg, slide_data)
title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h)
# Barre orange = hauteur fixe du title_zone (pas l'estimation)
_tz = (layout_cfg or {}).get("title_zone") or {}
_bar_h = _tz.get("height_cm", title_h)
self._render_accent_bar(slide, layout_name, _bar_h)
# - 4. Content zones -
# Rendu specialise pour executive_summary
if layout_name == "executive_summary":
self._render_executive_summary(slide, slide_data, title_bottom)
return
# Rendu specialise circular_diagram (evite le double rendu circle+legend)
if layout_name == "circular_diagram":
self._render_circular_diagram_full(slide, slide_data, layout_cfg, title_bottom)
return
# Rendu specialise two_cols_text
if layout_name == "two_cols_text":
self._render_two_cols_text(slide, slide_data, layout_cfg, title_bottom)
return
# Rendu specialise recommendation_card
if layout_name == "recommendation_card":
avail_h = FOOTER_TOP - 0.30
zone_fake = {"id": "rec_sidebar", "component": "C26", "width_cm": 8.0}
self._render_recommendation_sidebar(slide, zone_fake, slide_data,
0.0, 0.0, 8.0, avail_h)
return
zones = layout_cfg.get("content_zones") or []
# cursor : commence juste sous le titre
cursor_y = title_bottom + 0.20 if title_bottom else 2.80
# zone max disponible (jusqu'au footer ou bas du slide)
max_bottom = FOOTER_TOP - 0.30 # laisse 0.3 cm au-dessus du footer
for zone in zones:
# Zones optionnelles absentes du JSON → skip
if zone.get("optional") and not self._zone_has_data(zone, slide_data):
continue
# Positions : priorité aux coords fixes, sinon on utilise le curseur
z_left = zone.get("left_cm", 1.50)
z_top = zone.get("top_cm", cursor_y)
z_width = zone.get("width_cm", 30.87)
# Calcul de la hauteur réelle
measured_h = self._measure_component(zone, slide_data)
z_height = min(measured_h, max_bottom - z_top)
if z_height <= 0:
continue # plus de place
# Mise à jour du curseur (uniquement pour les zones sans top fixe)
if "top_cm" not in zone:
cursor_y = z_top + z_height + 0.25
self._render_zone(slide, zone, slide_data,
z_left, z_top, z_width, z_height)
# - Background -
def _render_background(self, slide, layout_cfg: dict):
"""Rend le fond du slide (C01)."""
bg = layout_cfg.get("background", {})
color = self._r(bg.get("color", "#ffffff"))
if bg.get("diagonal_split"):
color_right = self._r(bg.get("color_right", "#023466"))
angle = bg.get("diagonal_angle_deg", 15)
self._render_diagonal_background(slide, color, color_right, angle)
else:
from pptx.dml.color import RGBColor as _RGBBG
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = _RGBBG(
int(color[1:3], 16),
int(color[3:5], 16),
int(color[5:7], 16)
)
def _render_diagonal_background(self, slide, color_left: str,
color_right: str, angle_deg: float = 0):
"""
Fond section_divider : 2 rectangles.
Gauche 2/3 slide (dark_blue), droite 1/3 slide (blanc).
angle_deg ignore -- conserve pour compatibilite.
"""
W = 33.87
H = 19.05
left_w = W * 2 / 3
right_w = W * 1 / 3
add_rect(slide, 0, 0, left_w, H, color_left)
add_rect(slide, left_w, 0, right_w, H, color_right)
def _diagonal_max_x(self, y_cm: float, angle_deg: float = 15) -> float:
"""
x maximum disponible dans le panneau sombre a la position y_cm.
Interpolation lineaire entre (0, split_x) et (H, split_x_bottom).
"""
split_x = 18.50
H = 19.05
offset = H * math.tan(math.radians(angle_deg))
split_x_bottom = split_x - offset
frac = min(max(y_cm / H, 0), 1)
return split_x + frac * (split_x_bottom - split_x) - 0.80
# - Signature -
def _render_footer(self, slide, layout_name: str, slide_num: int):
"""Rend le footer PR (C04)."""
footer_cfg = self.theme["signature"]["footer"]
hidden_on = footer_cfg.get("hidden_on", [])
if layout_name in hidden_on:
return
top = FOOTER_TOP
h = FOOTER_H
w = 33.87
# Fond blanc
add_rect(slide, 0, top, w, h, "#ffffff")
# Bordure top
add_line(slide, 0, top, w, top, "#e8e2d6", 0.03)
# Numéro de slide
add_text_box(slide, 0.80, top + 0.10, 1.50, 0.50,
str(slide_num), self._font_body, 8,
color="#000a32", align=PP_ALIGN.LEFT)
# Séparateur vertical
add_line(slide, 1.50, top + 0.10, 1.50, top + 0.60, "#7fa5d0", 0.03)
# "Pernod Ricard"
add_text_box(slide, 1.70, top + 0.10, 5.00, 0.50,
"Pernod Ricard", self._font_body, 8,
color="#000a32", align=PP_ALIGN.LEFT)
# Tagline à droite
add_text_box(slide, 15.00, top + 0.10, 18.00, 0.50,
"DATA GOVERNANCE DATA MANAGEMENT",
self._font_body, 7,
color="#48545a", align=PP_ALIGN.RIGHT)
def _render_logo(self, slide, layout_name: str):
"""Insère le logo PR top-left si le fichier assets/logo_pr_sun.png existe."""
logo_cfg = self.theme["signature"]["logo_topbar"]
visible_on = logo_cfg.get("visible_on", [])
if layout_name not in visible_on:
return
logo_path = logo_cfg.get("file", "assets/logo_pr_sun.png")
if not os.path.exists(logo_path):
return # Logo absent -> rien affiche
slide.shapes.add_picture(
logo_path,
cm(logo_cfg["position_left_cm"]),
cm(logo_cfg["position_top_cm"]),
height=cm(logo_cfg["height_cm"])
)
def _render_accent_bar(self, slide, layout_name: str, title_h: float):
"""Barre verticale rose à gauche du titre (C03)."""
sig = self.theme["signature"]["accent_bar"]
if layout_name not in sig.get("visible_on", []):
return
bar_h = max(title_h, 0.40) # proportionne au titre, pas de minimum arbitraire
add_rect(slide,
sig["position_left_cm"], 0.45,
sig["width_cm"], bar_h,
sig["color"])
# - Titre -
def _render_title(self, slide, layout_cfg: dict,
slide_data: dict, title_h: float) -> float:
"""
Rend le titre et le sous-titre.
Layouts diagonaux : une textbox, largeur max entre marges,
centrage vertical natif MSO_ANCHOR.MIDDLE a slide_height/2.
Autres layouts : comportement standard.
Retourne le y_bottom en cm.
"""
tz = layout_cfg.get("title_zone")
if not tz:
return 0.0
titre = slide_data.get("titre", "")
sous_titre = slide_data.get("sous_titre", "")
font_override = tz.get("font_override", {})
size_pt = font_override.get("size_pt",
self.theme["typography"]["sizes"]["slide_title"])
bold = font_override.get("bold", True)
color = self._r(font_override.get("color",
self.theme["colors"]["text"]["on_white"]))
bg = layout_cfg.get("background", {})
is_diagonal = bg.get("diagonal_split", False)
# - Layouts diagonaux -
if is_diagonal:
from pptx.enum.text import MSO_ANCHOR
SLIDE_W = 33.87
SLIDE_H = 19.05
# Marges statiques egales gauche et droite
margin = self.theme.get("spacing", {}).get("slide_margin_cm", 1.80)
sub_size = max(int(size_pt * 0.55), 14)
# Largeur = slide - 2 marges. Aucune contrainte diagonale.
width = SLIDE_W - 2 * margin
# Textbox centree sur la moitie du slide
box_h = SLIDE_H * 0.40 # hauteur genereusse (40% slide)
box_top = SLIDE_H / 2 - box_h / 2 # centre sur slide_h / 2
txBox = slide.shapes.add_textbox(
cm(margin), cm(box_top),
cm(width), cm(box_h)
)
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = None
# Centrage vertical natif : le texte est centre dans la boite
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
# Paragraphe titre uniquement dans cette textbox
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
run = p.add_run()
run.text = titre
run.font.name = self._font_display
run.font.size = Pt(size_pt)
run.font.bold = bold
run.font.color.rgb = hex_to_rgb(color)
titre_bottom = box_top + box_h
# Sous-titre : textbox independante centree entre bas du titre et bas du slide
if sous_titre:
sub_color = self.theme["colors"]["primary"]["rose"]
sub_size = max(int(size_pt * 0.55), 14)
zone_top = titre_bottom
zone_bot = SLIDE_H - FOOTER_H - 0.30
sub_h = sub_size * 0.038 * 2.5 # hauteur genereusse pour 2 lignes max
sub_top = zone_top + (zone_bot - zone_top) / 2 - sub_h / 2
txBox2 = slide.shapes.add_textbox(
cm(margin), cm(sub_top),
cm(width), cm(sub_h)
)
tf2 = txBox2.text_frame
tf2.word_wrap = True
tf2.auto_size = None
tf2.vertical_anchor = MSO_ANCHOR.MIDDLE
p2 = tf2.paragraphs[0]
p2.alignment = PP_ALIGN.LEFT
run2 = p2.add_run()
run2.text = sous_titre
run2.font.name = self._font_display
run2.font.size = Pt(sub_size)
run2.font.bold = False
run2.font.italic = True
run2.font.color.rgb = hex_to_rgb(sub_color)
return titre_bottom
# Layouts standards
from pptx.enum.text import MSO_ANCHOR as _MSO_STD
left = tz.get("left_cm", 1.80)
top = tz.get("top_cm", 0.45)
width = tz.get("width_cm", 30.00)
height_cm = tz.get("height_cm", 1.90)
# Police 28pt par defaut sauf font_override explicite
if "size_pt" not in font_override:
size_pt = 28
txBox = slide.shapes.add_textbox(
cm(left), cm(top), cm(width), cm(height_cm))
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = None
tf.vertical_anchor = _MSO_STD.MIDDLE
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
run = p.add_run()
run.text = titre
run.font.name = self._font_display
run.font.size = Pt(size_pt)
run.font.bold = bold
run.font.color.rgb = hex_to_rgb(color)
current_bottom = top + height_cm
if sous_titre:
sub_cfg = tz.get("subtitle", {})
sub_size = sub_cfg.get("size_pt",
self.theme["typography"]["sizes"]["slide_subtitle"])
sub_color = self._r(sub_cfg.get("color",
self.theme["colors"]["text"]["subtitle"]))
sub_size = max(int(size_pt * 0.55), 14)
margin_top = sub_cfg.get("margin_top_cm", 0.25)
sub_top = current_bottom + margin_top
max_bottom_sub = 19.05 - FOOTER_H - 0.50
if sub_top + 0.90 > max_bottom_sub:
sub_top = max_bottom_sub - 0.90
add_text_box(slide, left, sub_top, width, 0.90,
sous_titre, self._font_body, sub_size,
color=sub_color)
current_bottom = sub_top + 0.90
return current_bottom
def _zone_has_data(self, zone: dict, slide_data: dict) -> bool:
"""Vérifie si une zone optionnelle a des données dans le JSON."""
comp = zone.get("component", "")
if comp == "C07":
return any(k in slide_data for k in
["description", "situation", "complication", "resolution", "contenu"])
return True
def _render_zone(self, slide, zone: dict, slide_data: dict,
left: float, top: float, width: float, height: float):
"""Dispatche vers le renderer du composant."""
comp = zone.get("component", "")
zone_type = zone.get("type", "")
# Séparateurs (pas de composant associé)
if zone_type == "vertical_line":
add_line(slide, zone.get("x_cm", left),
zone.get("top_cm", top),
zone.get("x_cm", left),
zone.get("top_cm", top) + zone.get("height_cm", height),
self._r(zone.get("color", "#e8e2d6")),
zone.get("width_cm", 0.03))
return
if zone_type == "horizontal_line":
y = zone.get("y_cm", top)
add_line(slide, left, y, left + width, y,
self._r(zone.get("color", "#e8e2d6")),
zone.get("width_cm", 0.03))
return
if zone_type == "section_circle":
self._render_section_circle(slide, zone)
return
dispatch = {
"C06": self._render_bullet_list,
"C07": self._render_text_paragraph,
"C08": self._render_quote_block,
"C09": self._render_kpi_grid,
"C10": self._render_big_stat,
"C11": self._render_data_table,
"C12": self._render_chart_placeholder,
"C13": self._render_callout_box,
"C14": self._render_benchmark,
"C15": self._render_matrix_2x2,
"C16": self._render_pyramid,
"C17": self._render_circular_diagram,
"C18": self._render_from_to_pairs,
"C19": self._render_numbered_steps,
"C20": self._render_chevrons,
"C21": self._render_gantt,
"C22": self._render_timeline,
"C23": self._render_org_chart,
"C24": self._render_raci,
"C25": self._render_decision_tree,
"C26": self._render_recommendation_sidebar,
}
renderer = dispatch.get(comp)
if renderer:
renderer(slide, zone, slide_data, left, top, width, height)
else:
# Composant inconnu → zone grise placeholder
self._render_placeholder(slide, left, top, width, height, comp)
# - Renderers des composants -
def _render_placeholder(self, slide, left, top, width, height, label="?"):
"""Zone placeholder silencieuse — rien n'est affiche."""
pass
def _render_section_circle(self, slide, zone: dict):
"""
Cercle numerote pour section_divider.
Centre sur la separation (x=2/3 slide), centre vertical slide.
Contour blanc pour visibilite sur fond bleu.
"""
from pptx.dml.color import RGBColor as _RGB2
SLIDE_W = 33.87
SLIDE_H = 19.05
numero = str(zone.get("text", zone.get("numero", "1")))
d = zone.get("diameter_cm", 2.80)
fill = self._r(zone.get("fill",
self.theme["colors"]["primary"]["dark_blue"]))
size_pt = zone.get("size_pt", 54)
cx = SLIDE_W * 2 / 3 # centre sur la separation
cy = SLIDE_H / 2 # centre vertical
shape = slide.shapes.add_shape(9,
cm(cx - d/2), cm(cy - d/2), cm(d), cm(d))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(fill)
from pptx.util import Pt as _Pt2
from pptx.dml.color import RGBColor as _RGB3
shape.line.color.rgb = _RGB3(0xFF, 0xFF, 0xFF)
shape.line.width = int(0.20 * CM)
add_text_box(slide,
cx - d/2, cy - d/2 - 0.10,
d, d + 0.10,
numero, self._font_display, size_pt,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
# C06 — bullet_list -
def _render_section_divider_title(self, slide, slide_data: dict):
"""
Titre de section_divider :
- Dans le bloc bleu gauche (0 -> 2/3 slide)
- Marges laterales identiques gauche et droite
- S'arrete avant le rond (securite d/2 + 0.5 cm)
- Centre verticalement via MSO_ANCHOR.MIDDLE
"""
from pptx.enum.text import MSO_ANCHOR as _MSO_SD
SLIDE_W = 33.87
SLIDE_H = 19.05
titre = slide_data.get("titre", "")
if not titre:
return
margin = 1.80
d_rond = 2.80
marge_securite = 0.50
max_x_titre = (SLIDE_W * 2/3) - (d_rond/2) - marge_securite
width = max_x_titre - margin
size_pt = 40
box_h = SLIDE_H * 0.50
box_top = SLIDE_H / 2 - box_h / 2
txBox = slide.shapes.add_textbox(
cm(margin), cm(box_top),
cm(width), cm(box_h))
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = None
tf.vertical_anchor = _MSO_SD.MIDDLE
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
run = p.add_run()
run.text = titre
run.font.name = self._font_display
run.font.size = Pt(size_pt)
run.font.bold = True
run.font.color.rgb = hex_to_rgb("#ffffff")
# Rond numero -- appel direct avec numero_section du YAML
numero = str(slide_data.get("numero_section", ""))
if numero:
zone_circle = {
"text": numero,
"diameter_cm": 2.80,
"size_pt": 54,
"fill": self.theme["colors"]["primary"]["dark_blue"],
}
self._render_section_circle(slide, zone_circle)
def _render_two_cols_text(self, slide, slide_data: dict,
layout_cfg: dict, title_bottom: float):
"""
Renderer specialise two_cols_text avec centrage vertical dynamique.
- Calcule la hauteur reelle de chaque colonne
- Centre la plus haute dans la zone utile
- Aligne le haut de la plus petite sur le haut de la plus haute centree
"""
FOOTER_TOP = 18.35
zone_top = title_bottom + 0.60 # marge sous le titre slide
zone_bot = FOOTER_TOP - 0.30
zone_h = zone_bot - zone_top
# Recuperer les zones depuis le layout
zones = layout_cfg.get("content_zones", [])
z_left = next((z for z in zones if z.get("id") == "col_left"), {})
z_right = next((z for z in zones if z.get("id") == "col_right"), {})
z_sep = next((z for z in zones if z.get("type") == "vertical_line"), None)
left_x = z_left.get("left_cm", 1.50)
left_w = z_left.get("width_cm", 14.80)
right_x = z_right.get("left_cm", 17.50)
right_w = z_right.get("width_cm", 14.87)
# Donnees des colonnes
data_l = slide_data.get("left", {})
data_r = slide_data.get("right", {})
titre_size = 26
body_size = 16
titre_h = 0.90
gap = 1.40 # marge titre -> texte
def col_height(data):
titre = data.get("titre", "") if isinstance(data, dict) else ""
texte = data.get("contenu", "") if isinstance(data, dict) else str(data)
h = 0
if titre:
h += titre_h + gap
if texte:
h += estimate_text_height(texte, body_size, left_w, 1.2)
h = max(h, h + 0.20)
return h
h_left = col_height(data_l)
h_right = col_height(data_r)
# Centrage de la plus haute, alignement haut de la plus petite
if h_left >= h_right:
# Centrer gauche, aligner droite sur son haut
top_left = zone_top + max(0, (zone_h - h_left) / 2)
top_right = top_left
else:
# Centrer droite, aligner gauche sur son haut
top_right = zone_top + max(0, (zone_h - h_right) / 2)
top_left = top_right
# Renderer chaque colonne
def render_col(data, x, w, y_start, titre_couleur):
cur = y_start
titre = data.get("titre", "") if isinstance(data, dict) else ""
texte = data.get("contenu", "") if isinstance(data, dict) else str(data)
if titre:
add_text_box(slide, x, cur, w, titre_h,
titre, self._font_body, titre_size,
bold=True, color=titre_couleur,
align=PP_ALIGN.CENTER)
cur += gap
if texte:
if isinstance(texte, str):
lines_clean = [l.strip().lstrip("- ").strip()
for l in texte.splitlines() if l.strip()]
texte = "\n".join(lines_clean)
rem_h = max(0.5, zone_bot - cur)
add_text_box(slide, x, cur, w, rem_h,
texte, self._font_body, body_size,
color=self.theme["colors"]["text"]["body"])
color_left = self.theme["colors"]["primary"]["bright_blue"]
color_right = self.theme["colors"]["primary"]["rose"]
render_col(data_l, left_x, left_w, top_left, color_left)
render_col(data_r, right_x, right_w, top_right, color_right)
# Barre separatrice verticale — hauteur = bloc le plus haut + buffer
if z_sep is not None:
sep_x = z_sep.get("x_cm", 16.935)
sep_top = min(top_left, top_right)
sep_bot = max(top_left + h_left, top_right + h_right) + 0.60
sep_bot = min(sep_bot, zone_bot)
add_line(slide, sep_x, sep_top, sep_x, sep_bot,
self._r(z_sep.get("color", "#e8e2d6")),
z_sep.get("width_cm", 0.03))
def _render_bullet_list(self, slide, zone, slide_data,
left, top, width, height, no_bold=False):
"""Bullets hiérarchisés L1/L2/L3."""
# Cherche les bullets dans le JSON (champ direct ou dans une colonne)
zone_id = zone.get("id", "")
if "col_left" in zone_id:
col_data = slide_data.get("left", {})
elif "col_right" in zone_id:
col_data = slide_data.get("right", {})
else:
col_data = slide_data
bullets = col_data.get("bullets", [])
if not bullets:
return
# Centrage vertical dynamique
_pt2cm = 0.03528
_sz = {1: (20+4)*_pt2cm, 2: (18+2)*_pt2cm, 3: (16+1)*_pt2cm}
_content_h = sum(_sz.get(b.get('niveau', 1), _sz[1]) for b in bullets)
_content_h = min(_content_h, height)
_voff = max(0.0, (height - _content_h) / 2)
txBox = slide.shapes.add_textbox(
cm(left), cm(top + _voff), cm(width), cm(_content_h))
tf = txBox.text_frame
tf.word_wrap = True
sizes = {1: 20, 2: 18, 3: 16}
colors = {
1: "#000a32",
2: self.theme["colors"]["text"]["body"],
3: self.theme["colors"]["text"]["body"],
}
indents = {1: 0, 2: 0.5, 3: 1.0}
markers = {1: "", 2: " ", 3: ""}
space_before = {1: Pt(4), 2: Pt(2), 3: Pt(1)}
# Calcul padding haut pour centrage vertical
_pt_per_lvl = {1: 24, 2: 20, 3: 17}
_content_pt = sum(_pt_per_lvl.get(b.get('niveau', 1), 24) for b in bullets)
_zone_pt = height * 28.35
_top_padding_pt = max(0.0, (_zone_pt - _content_pt) / 2)
first = True
for b in bullets:
lvl = b.get("niveau", 1)
text = b.get("texte", "")
p = tf.paragraphs[0] if first else tf.add_paragraph()
if first:
# Centrage vertical : espace haut = (zone_reelle - contenu) / 2
_real_zone_pt = (FOOTER_TOP - 0.30 - top) * 28.35
_pt_per_lvl = {1: 24, 2: 20, 3: 17}
_content_pt = sum(_pt_per_lvl.get(b.get('niveau', 1), 24) for b in bullets)
_pad = max(0.0, (_real_zone_pt - _content_pt) / 2)
print(f"[bullet debug] top={top:.2f} FOOTER_TOP={FOOTER_TOP:.2f} _real_zone_pt={_real_zone_pt:.1f}pt _content_pt={_content_pt:.1f}pt _pad={_pad:.1f}pt")
p.space_before = Pt(_pad)
first = False
else:
p.space_before = space_before.get(lvl, Pt(4))
p.alignment = PP_ALIGN.LEFT
# Indentation via l'XML (level)
pPr = p._p.get_or_add_pPr()
pPr.set("lvl", str(lvl - 1))
run = p.add_run()
run.text = markers[lvl] + text
run.font.name = self._font_body
run.font.size = Pt(sizes[lvl])
run.font.bold = (lvl == 1) and not no_bold
run.font.color.rgb = hex_to_rgb(colors[lvl])
# Sous-items récursifs
for sub in b.get("sous_items", []) or []:
p2 = tf.add_paragraph()
p2.alignment = PP_ALIGN.LEFT
run2 = p2.add_run()
run2.text = " " + sub
run2.font.name = self._font_body
run2.font.size = Pt(9)
run2.font.color.rgb = hex_to_rgb(self.theme["colors"]["text"]["body"])
# C07 variant — executive_summary -
def _render_executive_summary(self, slide, slide_data: dict,
title_bottom: float):
"""
Rendu specialise pour le layout executive_summary (L09).
3 blocs SCR equidistants verticalement entre bas du titre et footer.
Chaque bloc = label colore + description en une seule textbox.
Pas de box de fond — texte direct sur fond blanc.
"""
from pptx.enum.text import MSO_ANCHOR
SLIDE_W = 33.87
FOOTER_TOP = 18.35
margin = self.theme.get("spacing", {}).get("slide_margin_cm", 1.80)
box_w = SLIDE_W - 2 * margin
# Donnees SCR
blocs = [
{
"label": "Situation",
"champ": "situation",
"couleur": self.theme["colors"]["primary"]["dark_blue"],
},
{
"label": "Complication",
"champ": "complication",
"couleur": self.theme["colors"]["primary"]["rose"],
},
{
"label": "Resolution",
"champ": "resolution",
"couleur": self.theme["colors"]["primary"]["bright_blue"],
},
]
# Tailles de police
label_size = 18 # label SCR
desc_size = 14 # description
# Zone utile : sous le titre jusqu'au footer
zone_top = title_bottom + 0.30
zone_bot = FOOTER_TOP - 0.50
zone_h = zone_bot - zone_top
# Hauteur de chaque bloc (label + description + gap interne)
# On alloue 1/3 de la zone a chaque bloc
bloc_h = zone_h / 3
for i, bloc in enumerate(blocs):
texte = slide_data.get(bloc["champ"], "")
if not texte:
continue
# Position verticale : equidistance
y = zone_top + i * bloc_h
# Une seule textbox : label (bold) + newline + description
txBox = slide.shapes.add_textbox(
cm(margin), cm(y),
cm(box_w), cm(bloc_h - 0.10)
)
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = None
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
# Paragraphe 1 : label
p1 = tf.paragraphs[0]
p1.alignment = PP_ALIGN.LEFT
run1 = p1.add_run()
run1.text = bloc["label"]
run1.font.name = self._font_body
run1.font.size = Pt(label_size)
run1.font.bold = True
run1.font.color.rgb = hex_to_rgb(bloc["couleur"])
# Paragraphe 2 : description
p2 = tf.add_paragraph()
p2.alignment = PP_ALIGN.LEFT
p2.space_before = Pt(2)
run2 = p2.add_run()
run2.text = texte
run2.font.name = self._font_body
run2.font.size = Pt(desc_size)
run2.font.bold = False
run2.font.color.rgb = hex_to_rgb(
self.theme["colors"]["text"]["body"])
# Separateur horizontal entre blocs (sauf le dernier)
if i < len(blocs) - 1:
sep_y = y + bloc_h
add_line(slide, margin, sep_y, SLIDE_W - margin, sep_y,
"#e8e2d6", 0.03)
# C07 — text_paragraph -
def _render_text_paragraph(self, slide, zone, slide_data,
left, top, width, height):
"""Bloc de texte libre avec titre de bloc optionnel."""
zone_id = zone.get("id", "")
# Mapping zone_id → champ JSON
field_map = {
"bloc_situation": ("Situation", "situation"),
"bloc_complication": ("Complication", "complication"),
"bloc_resolution": ("Résolution", "resolution"),
"col_left": (None, "left"),
"col_right": (None, "right"),
"description_bloc": (None, "description"),
"contact": (None, "contacts"),
"next_steps": (None, "message"),
}
titre_bloc, field = field_map.get(zone_id, (None, "contenu"))
titre_couleur = self._r(zone.get("titre_couleur",
self.theme["colors"]["primary"]["dark_blue"]))
font_override = zone.get("font_override", {})
cur_top = top
# Titre de bloc
if titre_bloc:
add_text_box(slide, left, cur_top, width, 0.50,
titre_bloc, self._font_body, 13,
bold=True, color=titre_couleur)
cur_top += 0.55
# Contenu
raw = slide_data.get(field, "")
if isinstance(raw, dict):
titre_col = raw.get("titre", "")
contenu = raw.get("contenu", "")
if titre_col:
add_text_box(slide, left, cur_top, width, 0.90,
titre_col, self._font_body, 26,
bold=True, color=titre_couleur)
cur_top += 1.40
raw = contenu
if raw:
color = font_override.get("color", self.theme["colors"]["text"]["body"])
size_pt = font_override.get("size_pt", 16)
add_text_box(slide, left, cur_top, width,
height - (cur_top - top),
str(raw), self._font_body, size_pt,
color=color)
# C08 — quote_block -
def _render_quote_block(self, slide, zone, slide_data,
left, top, width, height):
"""
Layout key_message (L08).
Titre + message dans une seule textbox pour proximite native.
Fond beige via slide.background (deja pose par _render_background).
"""
from pptx.enum.text import MSO_ANCHOR
from pptx.util import Pt as _Pt
SLIDE_W = 33.87
SLIDE_H = 19.05
FOOTER_TOP = 18.35
margin = 8.00
titre = slide_data.get("titre", "")
message = slide_data.get("message", "") or slide_data.get("citation", "")
auteur = slide_data.get("auteur", "")
if not titre and not message:
return
box_w = SLIDE_W - 2 * margin
# Taille titre
n = len(titre)
if n < 50: titre_size = 40
elif n < 90: titre_size = 32
else: titre_size = 26
# Taille message : 65% du titre, min 16pt
msg_size = max(int(titre_size * 0.65), 16)
# Guillemets : coin bas-droit touche coin haut-gauche du bloc
# On estime le debut du bloc a ~35% de la hauteur utile
zone_h = FOOTER_TOP - 0.80 - 1.50
bloc_est = 1.50 + zone_h * 0.20 # estimation bloc_top
g_size = titre_size + 36
g_w = g_size * 0.025 + 0.80
g_h = g_size * 0.038 + 0.20
g_left = max(0.30, margin - g_w)
g_top = max(0.50, bloc_est - g_h * 0.70)
add_text_box(slide, g_left, g_top, g_w + 0.50, g_h,
"\u201C", self._font_display, g_size,
bold=False,
color=self.theme["colors"]["primary"]["bright_blue"])
# Une seule textbox : titre + message (proximite native python-pptx)
# Hauteur = 60% de la zone utile pour centrage approximatif
box_h = zone_h * 0.60
box_top = 1.50 + max(0, (zone_h - box_h) / 2)
txBox = slide.shapes.add_textbox(
cm(margin), cm(box_top),
cm(box_w), cm(box_h))
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = None
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
# Paragraphe titre
p1 = tf.paragraphs[0]
p1.alignment = PP_ALIGN.LEFT
p1.space_before = _Pt(0)
p1.space_after = _Pt(4)
r1 = p1.add_run()
r1.text = titre
r1.font.name = self._font_display
r1.font.size = _Pt(titre_size)
r1.font.bold = True
r1.font.color.rgb = hex_to_rgb(
self.theme["colors"]["primary"]["dark_blue"])
# Paragraphe message (dans la meme textbox)
if message:
p2 = tf.add_paragraph()
p2.alignment = PP_ALIGN.LEFT
p2.space_before = _Pt(6)
p2.space_after = _Pt(0)
r2 = p2.add_run()
r2.text = message
r2.font.name = self._font_display
r2.font.size = _Pt(msg_size)
r2.font.bold = False
r2.font.color.rgb = hex_to_rgb(
self.theme["colors"]["text"]["body"])
# Attribution
if auteur:
attr_top = box_top + box_h + 0.40
if attr_top + 0.60 < FOOTER_TOP - 0.50:
add_text_box(slide, margin, attr_top, box_w, 0.60,
auteur, self._font_body, 10,
color=self.theme["colors"]["text"]["caption"])
def _render_kpi_grid(self, slide, zone, slide_data,
left, top, width, height):
"""Grille de cartes KPI adaptative."""
items = slide_data.get("items", [])
if not items:
return
n = len(items)
grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)}
cols, rows = grid.get(n, (3, 2))
gap = 0.40
header_h = 0.80
card_w = (width - (cols - 1) * gap) / cols
card_h = (height - (rows - 1) * gap) / rows
# Centrage vertical absolu (zone utile = bas titre slide -> haut footer)
FOOTER_TOP = 18.35
TITLE_BOTTOM = top # top = cursor_y = bas du titre de la slide
zone_h_abs = FOOTER_TOP - 0.30 - TITLE_BOTTOM
# Hauteur reelle des cartes : on redimensionne pour laisser de l'espace
card_h_real = min(card_h, zone_h_abs / rows - gap)
grid_h = rows * card_h_real + (rows - 1) * gap
def row_top_offset(row):
if rows == 1:
# Centrer la ligne unique dans la zone utile
return TITLE_BOTTOM + max(0, (zone_h_abs - grid_h) / 2)
else:
# Diviser la zone en 2, centrer chaque ligne dans sa moitie
half_h = zone_h_abs / 2
if row == 0:
return TITLE_BOTTOM + max(0, (half_h - card_h_real) / 2)
else:
return TITLE_BOTTOM + half_h + max(0, (half_h - card_h_real) / 2)
card_h = card_h_real
# Centrage horizontal de la grille dans la largeur disponible
grid_w = cols * card_w + (cols - 1) * gap
h_offset = max(0, (width - grid_w) / 2)
for i, item in enumerate(items):
col = i % cols
row = i // cols
x = left + h_offset + col * (card_w + gap)
y = row_top_offset(row)
color = item.get("couleur") or self._cycle_color()
color = self._r(color)
# Header colore
add_rect(slide, x, y, card_w, header_h, color)
# Titre centre V+H via textbox MSO_ANCHOR.MIDDLE
from pptx.enum.text import MSO_ANCHOR as _MSO_KPI
txKpi = slide.shapes.add_textbox(
cm(x), cm(y), cm(card_w), cm(header_h))
tfKpi = txKpi.text_frame
tfKpi.word_wrap = True
tfKpi.auto_size = None
tfKpi.vertical_anchor = _MSO_KPI.MIDDLE
pKpi = tfKpi.paragraphs[0]
pKpi.alignment = PP_ALIGN.CENTER
rKpi = pKpi.add_run()
rKpi.text = item.get("titre", "")
rKpi.font.name = self._font_body
rKpi.font.size = Pt(16)
rKpi.font.bold = True
rKpi.font.color.rgb = hex_to_rgb("#ffffff")
# Body beige
add_rect(slide, x, y + header_h, card_w,
card_h - header_h,
self.theme["colors"]["backgrounds"]["content_area"])
# Valeur en gros
val_h = card_h - header_h - 1.20
add_text_box(slide, x + 0.2, y + header_h + 0.3,
card_w - 0.4, val_h,
item.get("valeur", ""),
self._font_display, 32,
bold=True,
color=self.theme["colors"]["primary"]["rose"],
align=PP_ALIGN.CENTER)
# Sous-titre
if item.get("sous_titre"):
add_text_box(slide, x + 0.2,
y + card_h - 1.10,
card_w - 0.4, 1.00,
item["sous_titre"],
self._font_body, 13,
color=self.theme["colors"]["text"]["body"])
# C10 — big_stat_display -
def _render_big_stat(self, slide, zone, slide_data,
left, top, width, height):
"""Chiffre unique centré en très grand format."""
valeur = slide_data.get("valeur", "")
label = slide_data.get("label", "")
source = slide_data.get("source", "")
# Taille dynamique selon longueur de la valeur
val_len = len(str(valeur))
if val_len <= 4:
val_size = 144
elif val_len <= 7:
val_size = 120
else:
val_size = 96
val_h = val_size * 0.038
center_top = top + (height - val_h - 1.5) / 2
# Centrage vertical du bloc stat + message
label_h = 1.40 if label else 0
gap = 0.70 if label else 0
bloc_h = val_h + gap + label_h
center_top = top + (height - bloc_h) / 2
add_text_box(slide, left, center_top, width, val_h + 0.3,
valeur, self._font_display, val_size,
bold=True,
color=self.theme["colors"]["primary"]["rose"],
align=PP_ALIGN.CENTER)
if label:
label_margin = 3.00
label_left = left + label_margin
label_w = width - 2 * label_margin
label_top = center_top + val_h + gap
add_text_box(slide, label_left, label_top, label_w, label_h,
label, self._font_body, 20,
color=self.theme["colors"]["text"]["body"],
align=PP_ALIGN.CENTER)
if source:
src_top = center_top + val_h + 1.30
add_text_box(slide, left, src_top, width, 0.50,
"Source : " + source, self._font_body, 9,
color=self.theme["colors"]["text"]["caption"],
align=PP_ALIGN.CENTER)
# C11 — data_table -
def _render_data_table(self, slide, zone, slide_data,
left, top, width, height):
"""Tableau structuré avec header bleu foncé et lignes alternées."""
headers = slide_data.get("headers", [])
rows = slide_data.get("rows", [])
if not headers:
return
highlight_col = slide_data.get("highlight_col")
col_widths_pct = slide_data.get("col_widths")
n_cols = len(headers)
header_h = 0.65
available_h = height - header_h
row_h = min(available_h / max(len(rows), 1), 0.80)
# Largeurs de colonnes
if col_widths_pct:
col_widths = [w * width for w in col_widths_pct]
else:
col_widths = [width / n_cols] * n_cols
# Header
x = left
for j, h in enumerate(headers):
add_rect(slide, x, top, col_widths[j], header_h,
self.theme["colors"]["primary"]["dark_blue"])
add_text_box(slide, x + 0.15, top + 0.10,
col_widths[j] - 0.3, header_h - 0.15,
str(h), self._font_body, 9,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
x += col_widths[j]
# Lignes
odd_bg = "#ffffff"
even_bg = self.theme["colors"]["backgrounds"]["content_area"]
highlight_bg = self.theme["colors"]["backgrounds"]["highlight_box"]
for i, row in enumerate(rows):
y = top + header_h + i * row_h
x = left
for j, cell in enumerate(row):
bg = highlight_bg if j == highlight_col else (
odd_bg if i % 2 == 0 else even_bg)
add_rect(slide, x, y, col_widths[j], row_h, bg)
add_text_box(slide, x + 0.15, y + 0.08,
col_widths[j] - 0.3, row_h - 0.10,
str(cell), self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
x += col_widths[j]
# Ligne séparatrice
add_line(slide, left, y + row_h, left + width, y + row_h,
"#e8e2d6", 0.02)
# C12 — chart_placeholder -
def _render_chart_placeholder(self, slide, zone, slide_data,
left, top, width, height):
"""
Graphique simplifié (bar chart) généré avec python-pptx Chart.
Pour un rendu avancé, remplacer par openpyxl + pptx chart data.
"""
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE
data_items = slide_data.get("data", [])
chart_type = slide_data.get("chart_type", "bar")
if not data_items:
self._render_placeholder(slide, left, top, width, height, "C12 chart")
return
chart_data = ChartData()
chart_data.categories = [str(d.get("label", f"Item {i+1}"))
for i, d in enumerate(data_items)]
chart_data.add_series("", [float(d.get("valeur", 0))
for d in data_items])
xl_type = {
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
"line": XL_CHART_TYPE.LINE,
"pie": XL_CHART_TYPE.PIE,
"donut": XL_CHART_TYPE.DOUGHNUT,
}.get(chart_type, XL_CHART_TYPE.BAR_CLUSTERED)
chart = slide.shapes.add_chart(
xl_type,
cm(left), cm(top), cm(width), cm(height),
chart_data
).chart
# Supprimer le titre du chart (on a déjà le titre du slide)
chart.has_title = False
chart.has_legend = False
# Couleur des barres
series = chart.series[0]
fill = series.format.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb(
self.theme["colors"]["primary"]["bright_blue"])
# C13 — callout_box -
def _render_callout_box(self, slide, zone, slide_data,
left, top, width, height):
"""Encadré d'insight jaune."""
insight = slide_data.get("insight", "")
titre = slide_data.get("titre_insight", "")
# Fond
add_rect(slide, left, top, width, height,
self.theme["colors"]["backgrounds"]["highlight_box"],
self.theme["colors"]["secondary"]["maize_yellow"], 0.05)
cur_top = top + 0.30
if titre:
add_text_box(slide, left + 0.30, cur_top,
width - 0.60, 0.50,
titre, self._font_body, 12,
bold=True,
color=self.theme["colors"]["primary"]["rose"])
cur_top += 0.55
if insight:
add_text_box(slide, left + 0.30, cur_top,
width - 0.60, height - (cur_top - top) - 0.30,
insight, self._font_body, 10,
color=self.theme["colors"]["text"]["body"])
# C14 — benchmark_bar -
def _render_benchmark(self, slide, zone, slide_data,
left, top, width, height):
"""Barres horizontales de benchmark."""
criteria = slide_data.get("criteria", [])
actors = slide_data.get("actors", [])
scores = slide_data.get("scores", [])
if not criteria or not actors:
return
colors_actors = slide_data.get("couleurs_acteurs") or [
self.theme["colors"]["primary"]["bright_blue"],
self.theme["colors"]["primary"]["rose"],
self.theme["colors"]["secondary"]["barley_green"],
self.theme["colors"]["secondary"]["maize_yellow"],
]
n_crit = len(criteria)
n_act = len(actors)
label_w = 5.00
bar_area_w = width - label_w
row_h = height / n_crit
bar_h = 0.30
bar_gap = 0.10
# Légende acteurs (en haut)
for j, actor in enumerate(actors):
add_rect(slide, left + label_w + j * 2.0, top - 0.50,
0.25, 0.25, colors_actors[j % len(colors_actors)])
add_text_box(slide, left + label_w + j * 2.0 + 0.30,
top - 0.55, 1.5, 0.35,
actor, self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
for i, crit in enumerate(criteria):
y = top + i * row_h
# Label critère
add_text_box(slide, left, y + row_h / 2 - 0.20,
label_w - 0.30, 0.40,
crit, self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
# Barres par acteur
for j in range(n_act):
score = 0
if i < len(scores) and j < len(scores[i]):
score = float(scores[i][j])
bar_w = (score / 100) * bar_area_w
bar_y = y + (row_h - n_act * (bar_h + bar_gap)) / 2 + j * (bar_h + bar_gap)
add_rect(slide, left + label_w, bar_y,
max(bar_w, 0.05), bar_h,
colors_actors[j % len(colors_actors)])
# C15 — matrix_bubble -
def _render_matrix_2x2(self, slide, zone, slide_data,
left, top, width, height):
"""Matrice 2×2 avec bulles positionnées."""
axis_x = slide_data.get("axis_x", {})
axis_y = slide_data.get("axis_y", {})
items = slide_data.get("items", [])
ax_label = str(axis_x.get("label", ""))
ay_label = str(axis_y.get("label", ""))
# Marges pour les labels d'axes
margin_left = 1.50
margin_bottom = 0.80
plot_w = width - margin_left
plot_h = height - margin_bottom
# Axes
add_line(slide, left + margin_left, top,
left + margin_left, top + plot_h,
"#000a32", 0.05)
add_line(slide, left + margin_left, top + plot_h,
left + width, top + plot_h,
"#000a32", 0.05)
# Lignes de quadrant
mid_x = left + margin_left + plot_w / 2
mid_y = top + plot_h / 2
add_line(slide, mid_x, top, mid_x, top + plot_h, "#48545a", 0.02)
add_line(slide, left + margin_left, mid_y,
left + width, mid_y, "#48545a", 0.02)
# Labels axes
add_text_box(slide, left + margin_left + plot_w / 2 - 2,
top + plot_h + 0.10,
4, 0.40, ax_label,
self._font_body, 9, color="#48545a",
align=PP_ALIGN.CENTER)
add_text_box(slide, left, top + plot_h / 2 - 0.30,
margin_left - 0.10, 0.60, ay_label,
self._font_body, 9, color="#48545a",
align=PP_ALIGN.RIGHT)
# Labels extremes
add_text_box(slide, left + margin_left - 0.5, top + plot_h - 0.20,
0.8, 0.30, "Low", self._font_body, 8, color="#48545a")
add_text_box(slide, left + margin_left - 0.5, top,
0.8, 0.30, "High", self._font_body, 8, color="#48545a")
add_text_box(slide, left + margin_left, top + plot_h,
0.8, 0.30, "Low", self._font_body, 8, color="#48545a")
add_text_box(slide, left + width - 1.0, top + plot_h,
1.0, 0.30, "High", self._font_body, 8, color="#48545a",
align=PP_ALIGN.RIGHT)
colors = self.theme["colors"]["cycle"]
for i, item in enumerate(items):
x_pct = item.get("x", 50) / 100
y_pct = 1 - item.get("y", 50) / 100 # inverser y (0 = bas)
size_factor = item.get("taille", 2)
diameter = 0.30 + (size_factor - 1) * 0.15
color = self._r(item.get("couleur") or colors[i % len(colors)])
bx = left + margin_left + x_pct * plot_w - diameter / 2
by = top + y_pct * plot_h - diameter / 2
shape = slide.shapes.add_shape(9, # ellipse
cm(bx), cm(by), cm(diameter), cm(diameter))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(color)
shape.fill.fore_color.theme_color
shape.line.fill.background()
# Opacité via XML
spPr = shape._element.spPr
solidFill = spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill')
if solidFill is not None:
srgbClr = solidFill.find('{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr')
if srgbClr is not None:
alpha = etree.SubElement(srgbClr,
'{http://schemas.openxmlformats.org/drawingml/2006/main}alpha')
alpha.set('val', '75000') # 75% opacité
# Label
add_text_box(slide, bx - 0.5, by + diameter + 0.05,
diameter + 1.0, 0.35,
item.get("label", ""),
self._font_body, 8,
color=self.theme["colors"]["primary"]["dark_blue"],
align=PP_ALIGN.CENTER)
# C16 — pyramid_level -
def _render_pyramid(self, slide, zone, slide_data,
left, top, width, height):
"""Pyramide hiérarchique."""
levels = slide_data.get("levels", [])
if not levels:
return
n = len(levels)
colors_default = [
"#7fa5d0", "#000a32", "#bad9ff", "#ffcf0f", "#d9d9c4"
]
level_h = height / n
center_x = left + width / 2
max_w = width * 0.55
callout_right_x = left + width * 0.70
for i, level in enumerate(levels):
rank = i + 1
frac = rank / n
lvl_w = max_w * frac
lvl_left = center_x - lvl_w / 2
lvl_top = top + i * level_h
color = self._r(level.get("couleur") or colors_default[i % len(colors_default)])
add_rect(slide, lvl_left, lvl_top, lvl_w, level_h - 0.05, color)
add_text_box(slide, lvl_left, lvl_top + level_h / 2 - 0.20,
lvl_w, 0.40,
level.get("label", ""),
self._font_body, 9,
bold=True,
color="#ffffff" if i in [1] else "#000a32",
align=PP_ALIGN.CENTER)
# Callout latéral
if level.get("description"):
side = "right" if i % 2 == 0 else "left"
if side == "right":
add_line(slide, lvl_left + lvl_w, lvl_top + level_h / 2,
callout_right_x, lvl_top + level_h / 2,
"#48545a", 0.02)
add_text_box(slide, callout_right_x + 0.10,
lvl_top + level_h / 2 - 0.20,
left + width - callout_right_x - 0.20,
0.60, level["description"],
self._font_body, 8,
color=self.theme["colors"]["text"]["body"])
else:
callout_left_x = left
add_line(slide, lvl_left, lvl_top + level_h / 2,
callout_left_x + width * 0.25,
lvl_top + level_h / 2, "#48545a", 0.02)
add_text_box(slide, callout_left_x,
lvl_top + level_h / 2 - 0.20,
width * 0.24, 0.60,
level["description"],
self._font_body, 8,
color=self.theme["colors"]["text"]["body"],
align=PP_ALIGN.RIGHT)
# C17 — circular_segment -
def _render_circular_diagram_full(self, slide, slide_data: dict,
layout_cfg: dict, title_bottom: float):
"""
Wrapper unique pour circular_diagram.
Appelle _render_circular_diagram une seule fois avec la zone complete.
Evite le double rendu du aux zones circle+legend dans content_zones.
"""
zones = layout_cfg.get("content_zones", []) if layout_cfg else []
# Prendre la zone circle comme zone principale
zone = next((z for z in zones if z.get("id") == "circle"), {})
FOOTER_TOP = 18.35
left = zone.get("left_cm", 1.50)
top = max(title_bottom + 0.30, zone.get("top_cm", 2.00))
width = zone.get("width_cm", 30.87)
height = min(zone.get("height_cm", 14.85), FOOTER_TOP - 0.30 - top)
self._render_circular_diagram(slide, zone, slide_data,
left, top, width, height)
def _render_circular_diagram(self, slide, zone, slide_data,
left, top, width, height):
"""
Diagramme circulaire forme fleur.
Grands cercles positiones en arc autour d un centre commun.
Chaque cercle contient son numero en haut gauche.
Legende a droite : pastille + titre bold + description.
"""
segments = slide_data.get("segments", [])
if not segments:
return
colors_default = self.theme["colors"]["cycle"]
n = len(segments)
FOOTER_TOP = 18.35
# Zone utile
usable_h = min(height, FOOTER_TOP - 0.30 - top)
# Dimensions de la zone diagramme (gauche 55%) et legende (droite 45%)
diag_w = width * 0.52
leg_x = left + diag_w + 0.50
# Centre du diagramme fleur
cx = left + diag_w * 0.50
cy = top + usable_h * 0.50
# Rayon des grands cercles et rayon d orbite
r_cercle = min(diag_w * 0.28, usable_h * 0.28) * 0.67 # reduit d 1/3
r_orbite = r_cercle * 0.85 # chevauchement : les cercles se touchent au centre
# Cercle central blanc (medaillon)
r_med = r_cercle * 0.40
shape_med = slide.shapes.add_shape(9,
cm(cx - r_med), cm(cy - r_med),
cm(r_med * 2), cm(r_med * 2))
shape_med.fill.solid()
shape_med.fill.fore_color.rgb = hex_to_rgb("#ffffff")
shape_med.line.fill.background()
shape_med.line.fill.background()
# Angles de depart selon le nombre de segments
# On commence en haut gauche pour 3 segments, en haut pour 4 et 5
angle_offsets = {
2: -90,
3: -150,
4: -135,
5: -90,
6: -90,
}
angle_start = angle_offsets.get(n, -90)
angle_step = 360 / n
for i, seg in enumerate(segments):
color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)])
angle_rad = math.radians(angle_start + i * angle_step)
# Centre du cercle colore
bx = cx + r_orbite * math.cos(angle_rad)
by = cy + r_orbite * math.sin(angle_rad)
# Grand cercle colore
shape = slide.shapes.add_shape(9,
cm(bx - r_cercle), cm(by - r_cercle),
cm(r_cercle * 2), cm(r_cercle * 2))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(color)
shape.line.fill.background()
# Numero dans le cercle - positionnement selon quadrant
num_size = max(14, int(r_cercle * 8))
num_w = r_cercle * 1.40
num_h = r_cercle * 0.80
marge = r_cercle * 0.15
sin_a = math.sin(angle_rad)
cos_a = math.cos(angle_rad)
if sin_a < -0.3:
# Petale au-dessus de l horizontale -> numero en haut du rond
num_x = bx - num_w / 2
num_y = by - r_cercle + marge
elif sin_a > 0.3:
# Petale en dessous de l horizontale -> numero en bas du rond
num_x = bx - num_w / 2
num_y = by + r_cercle - marge - num_h
elif cos_a > 0:
# Petale exactement sur l horizontale, cote droit -> num a droite
num_x = bx + r_cercle - marge - num_w
num_y = by - num_h / 2
else:
# Petale exactement sur l horizontale, cote gauche -> num a gauche
num_x = bx - r_cercle + marge
num_y = by - num_h / 2
_tb_num = add_text_box(slide, num_x, num_y,
num_w, num_h,
f"0{i+1}",
self._font_display, num_size,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
from pptx.util import Pt as _Pt
from pptx.enum.text import MSO_ANCHOR
_tb_num.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
# Redessiner le medaillon central par dessus les cercles
shape_med2 = slide.shapes.add_shape(9,
cm(cx - r_med), cm(cy - r_med),
cm(r_med * 2), cm(r_med * 2))
shape_med2.fill.solid()
shape_med2.fill.fore_color.rgb = hex_to_rgb("#ffffff")
shape_med2.line.fill.background()
# Legende a droite
leg_item_h = min(1.60, usable_h / n)
leg_total = n * leg_item_h
leg_top = top + (usable_h - leg_total) / 2
leg_w = left + width - leg_x - 0.20
for i, seg in enumerate(segments):
color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)])
y = leg_top + i * leg_item_h
# Pastille coloree
p_r = 0.30
shape_p = slide.shapes.add_shape(9,
cm(leg_x), cm(y + 0.15),
cm(p_r * 2), cm(p_r * 2))
shape_p.fill.solid()
shape_p.fill.fore_color.rgb = hex_to_rgb(color)
shape_p.line.fill.background()
# Numero + label bold
add_text_box(slide, leg_x + p_r * 2 + 0.20, y,
leg_w - p_r * 2 - 0.20, 0.55,
f"0{i+1} {seg.get('label', '')}",
self._font_body, 13,
bold=True,
color=self.theme["colors"]["primary"]["dark_blue"])
# Description
if seg.get("description"):
add_text_box(slide, leg_x + p_r * 2 + 0.20, y + 0.55,
leg_w - p_r * 2 - 0.20, 0.85,
seg["description"],
self._font_body, 11,
color=self.theme["colors"]["text"]["body"])
# C18 — from_to_pair -
def _render_from_to_pairs(self, slide, zone, slide_data,
left, top, width, height):
"""Paires FROM → TO."""
pairs = slide_data.get("pairs", [])
if not pairs:
return
# En-tête FROM / TO
mid_x = left + width * 0.42
add_text_box(slide, left, top, width * 0.40, 0.50,
"FROM", self._font_body, 11,
bold=True,
color=self.theme["semantic"]["arrow_color"])
add_text_box(slide, mid_x + 0.80, top, width * 0.40, 0.50,
"TO", self._font_body, 11,
bold=True,
color=self.theme["semantic"]["arrow_color"])
row_h = (height - 0.60) / max(len(pairs), 1)
for i, pair in enumerate(pairs):
y = top + 0.60 + i * row_h
# FROM (atténué)
add_text_box(slide, left, y + 0.08,
width * 0.38, row_h - 0.15,
pair.get("from", ""),
self._font_body, 11,
color=self.theme["semantic"]["from_color"])
# Flèche
add_text_box(slide, mid_x - 0.20, y + 0.05, 0.60, 0.40,
"", self._font_body, 18, bold=True,
color=self.theme["semantic"]["arrow_color"],
align=PP_ALIGN.CENTER)
# TO (affirmé)
add_text_box(slide, mid_x + 0.50, y + 0.08,
width - mid_x - 0.50, row_h - 0.15,
pair.get("to", ""),
self._font_body, 11,
bold=True,
color=self.theme["semantic"]["to_color"])
# Séparateur
if i < len(pairs) - 1:
add_line(slide, left, y + row_h,
left + width, y + row_h,
"#e8e2d6", 0.02)
# C19 — step_item -
def _render_numbered_steps(self, slide, zone, slide_data,
left, top, width, height):
"""Étapes numérotées verticalement."""
steps = slide_data.get("steps", [])
if not steps:
return
row_h = height / max(len(steps), 1)
badge_size = 1.40
for i, step in enumerate(steps):
y = top + i * row_h
# Badge carré
add_rect(slide, left, y + (row_h - badge_size) / 2,
badge_size, badge_size,
self.theme["colors"]["primary"]["dark_blue"])
add_text_box(slide, left, y + (row_h - badge_size) / 2,
badge_size, badge_size,
str(step.get("numero", i + 1)),
self._font_body, 30,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
# Titre
add_text_box(slide, left + badge_size + 0.25,
y + (row_h - badge_size) / 2,
width * 0.35, badge_size,
step.get("titre", ""),
self._font_body, 17,
bold=True,
color=self.theme["colors"]["primary"]["dark_blue"])
# Description
if step.get("description"):
add_text_box(slide, left + badge_size + 0.25 + width * 0.36,
y + (row_h - badge_size) / 2,
width - badge_size - 0.25 - width * 0.36,
badge_size,
step["description"],
self._font_body, 13,
color=self.theme["colors"]["text"]["body"])
# Séparateur
if i < len(steps) - 1:
add_line(slide, left, y + row_h,
left + width, y + row_h,
"#e8e2d6", 0.02)
# C20 — chevron_step -
def _render_chevrons(self, slide, zone, slide_data,
left, top, width, height):
"""Chevrons horizontaux de process."""
phases = slide_data.get("phases", [])
if not phases:
return
n = len(phases)
tip = 0.40 # largeur de la pointe
chev_h = 1.00
total_w = width - 0.50
chev_w = total_w / n
bullets_top = top + chev_h + 0.30
for i, phase in enumerate(phases):
x = left + i * chev_w
is_active = phase.get("actif", False)
is_last = (i == n - 1)
fill = (self.theme["colors"]["primary"]["dark_blue"]
if is_active else
self.theme["colors"]["primary"]["hague_grey"])
# Rectangle du chevron
add_rect(slide, x, top, chev_w - 0.10, chev_h, fill)
# Texte
add_text_box(slide, x + 0.20, top + 0.20,
chev_w - 0.60, 0.60,
phase.get("label", ""),
self._font_display, 13,
bold=True,
color=self.theme["colors"]["primary"]["rose"]
if is_active else "#ffffff",
align=PP_ALIGN.CENTER)
# Durée sous le chevron
if phase.get("duree"):
add_text_box(slide, x, top + chev_h + 0.05,
chev_w, 0.30,
phase["duree"],
self._font_body, 8,
color=self.theme["colors"]["text"]["body"],
align=PP_ALIGN.CENTER)
# Bullets sous la phase
if phase.get("bullets"):
for j, bullet in enumerate(phase["bullets"]):
add_text_box(slide, x + 0.15,
bullets_top + j * 0.55,
chev_w - 0.30, 0.50,
"" + bullet,
self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
# C21 — gantt_bar -
def _render_gantt(self, slide, zone, slide_data,
left, top, width, height):
"""Gantt simplifié."""
period = slide_data.get("period", {})
workstreams = slide_data.get("workstreams", [])
if not workstreams:
return
label_w = zone.get("label_col_width_cm", 5.50)
header_h = zone.get("header_height_cm", 0.60)
stream_h = zone.get("workstream_height_cm", 2.80)
timeline_w = width - label_w
# Parse période
def parse_ym(s):
parts = str(s).split("-")
return int(parts[0]) * 12 + int(parts[1]) if len(parts) == 2 else 0
p_start = parse_ym(period.get("start", "2026-01"))
p_end = parse_ym(period.get("end", "2026-12"))
total_months = max(p_end - p_start + 1, 1)
# Header mois
import calendar
months_short = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
add_rect(slide, left + label_w, top, timeline_w, header_h,
self.theme["colors"]["backgrounds"]["content_area"])
for m in range(total_months):
mx = left + label_w + (m / total_months) * timeline_w
mw = timeline_w / total_months
ym = p_start + m
month_name = months_short[(ym - 1) % 12]
add_text_box(slide, mx, top + 0.08, mw, 0.40,
month_name, self._font_body, 7,
color="#48545a", align=PP_ALIGN.CENTER)
colors = self.theme["colors"]["cycle"]
for i, ws in enumerate(workstreams):
y = top + header_h + i * stream_h
color = colors[i % len(colors)]
# Label workstream (optionnel)
if ws.get("label"):
add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10,
self.theme["colors"]["backgrounds"]["content_area"])
add_text_box(slide, left + 0.15, y + stream_h / 2 - 0.20,
label_w - 0.40, 0.40,
ws["label"], self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
else:
add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10,
"#e8e2d6")
# Barres de tâches
for task in ws.get("tasks", []):
t_start = parse_ym(task.get("start", period.get("start")))
t_end = parse_ym(task.get("end", period.get("end")))
row = task.get("row", 1)
offset_x = ((t_start - p_start) / total_months) * timeline_w
bar_w = max(((t_end - t_start + 1) / total_months) * timeline_w, 0.30)
bar_y = y + (row - 1) * (stream_h / 2) + 0.25
bar_h = stream_h / 2 - 0.35
tc = self._r(task.get("couleur") or color)
add_rect(slide, left + label_w + offset_x, bar_y,
bar_w, bar_h, tc)
# C22 — timeline_milestone -
def _render_timeline(self, slide, zone, slide_data,
left, top, width, height):
"""Timeline horizontale (yearly ou phases)."""
milestones = slide_data.get("milestones", [])
if not milestones:
# phases_timeline variant
self._render_phases_timeline(slide, zone, slide_data, left, top, width, height)
return
n = len(milestones)
axis_y = top + height / 2
spacing = width / (n + 1)
# Axe
add_line(slide, left, axis_y, left + width, axis_y, "#48545a", 0.04)
# Flèche →
add_text_box(slide, left + width - 0.30, axis_y - 0.20,
0.40, 0.40, "", self._font_body, 10, color="#48545a")
colors = self.theme["colors"]
for i, m in enumerate(milestones):
mx = left + (i + 1) * spacing
is_active = m.get("actif", False)
circle_color = (colors["primary"]["rose"] if is_active
else colors["primary"]["dark_blue"])
year_color = (colors["primary"]["rose"] if is_active
else colors["primary"]["bright_blue"])
# Cercle sur l'axe
r = 0.18
shape = slide.shapes.add_shape(9,
cm(mx - r), cm(axis_y - r), cm(r * 2), cm(r * 2))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(circle_color)
shape.line.fill.background()
# Année au-dessus
add_text_box(slide, mx - 1.0, axis_y - 1.20,
2.0, 0.50, str(m.get("annee", "")),
self._font_display, 13,
bold=True, color=year_color,
align=PP_ALIGN.CENTER)
# Label
add_text_box(slide, mx - 1.5, axis_y + 0.30,
3.0, 0.40, m.get("label", ""),
self._font_body, 9,
bold=True if is_active else False,
color=colors["primary"]["dark_blue"],
align=PP_ALIGN.CENTER)
# Description
if m.get("description"):
add_text_box(slide, mx - 1.5, axis_y + 0.75,
3.0, 0.70, m["description"],
self._font_body, 8,
color=colors["text"]["body"],
align=PP_ALIGN.CENTER)
def _render_phases_timeline(self, slide, zone, slide_data,
left, top, width, height):
"""Phases horizontales contiguës (L24)."""
phases = slide_data.get("phases", [])
if not phases:
return
n = len(phases)
phase_h = 1.40
period_h = 0.80
colors = self.theme["colors"]["cycle"]
# Largeur proportionnelle ou égale
phase_w = width / n
# Calcul hauteur totale du contenu pour centrage vertical
max_items = max((len(ph.get("items", [])) for ph in phases), default=0)
total_h = phase_h + period_h + max_items * 0.70
_voff = max(0.0, (height - total_h) / 2)
top_c = top + _voff
from pptx.enum.text import MSO_ANCHOR
for i, phase in enumerate(phases):
x = left + i * phase_w
color = colors[i % len(colors)]
add_rect(slide, x, top_c, phase_w - 0.10, phase_h, color)
_tb = add_text_box(slide, x + 0.10, top_c,
phase_w - 0.20, phase_h,
phase.get("label", ""),
self._font_body, 16,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
_tb.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
if phase.get("periode"):
add_text_box(slide, x, top_c + phase_h + 0.05,
phase_w, period_h,
phase["periode"],
self._font_body, 14,
color=self._r(color),
align=PP_ALIGN.CENTER)
items = phase.get("items", [])
for j, item in enumerate(items):
add_text_box(slide, x + 0.10,
top_c + phase_h + period_h + 0.20 + j * 0.70,
phase_w - 0.20, 0.65,
"" + item,
self._font_body, 12,
color=self.theme["colors"]["text"]["body"])
# C23 — org_node -
def _render_org_chart(self, slide, zone, slide_data,
left, top, width, height):
"""Organigramme hiérarchique top-down."""
root = slide_data.get("root", {})
if not root:
return
colors_by_level = [
self.theme["colors"]["primary"]["dark_blue"],
self.theme["colors"]["secondary"]["barley_green"],
self.theme["colors"]["secondary"]["cool_blue"],
self.theme["colors"]["primary"]["bright_warm"],
]
text_by_level = ["#ffffff", "#ffffff", "#000a32", "#000a32"]
node_h = 0.65
level_gap = 1.20
def draw_tree(node, level, x_center, y):
color = colors_by_level[min(level, len(colors_by_level) - 1)]
txt_color = text_by_level[min(level, len(text_by_level) - 1)]
node_w = max(3.0 - level * 0.3, 2.0)
nx = x_center - node_w / 2
add_rect(slide, nx, y, node_w, node_h, color)
add_text_box(slide, nx + 0.10, y + 0.12,
node_w - 0.20, node_h - 0.15,
node.get("label", ""),
self._font_body, 8,
bold=True, color=txt_color,
align=PP_ALIGN.CENTER)
children = node.get("children", [])
if not children:
return
nc = len(children)
child_span = min(width / max(nc, 1), 6.0)
children_total_w = child_span * nc
child_start_x = x_center - children_total_w / 2 + child_span / 2
child_y = y + node_h + level_gap
# Ligne verticale descendante
add_line(slide, x_center, y + node_h,
x_center, y + node_h + level_gap / 2,
"#48545a", 0.03)
# Ligne horizontale
add_line(slide,
child_start_x, y + node_h + level_gap / 2,
child_start_x + children_total_w - child_span,
y + node_h + level_gap / 2,
"#48545a", 0.03)
for i, child in enumerate(children):
cx = child_start_x + i * child_span
add_line(slide, cx, y + node_h + level_gap / 2,
cx, child_y, "#48545a", 0.03)
draw_tree(child, level + 1, cx, child_y)
draw_tree(root, 0, left + width / 2, top)
# C24 — raci_cell -
def _render_raci(self, slide, zone, slide_data,
left, top, width, height):
"""Matrice RACI."""
roles = slide_data.get("roles", [])
tasks = slide_data.get("tasks", [])
if not roles or not tasks:
return
task_col_w = zone.get("task_col_width_cm", 8.00)
header_h = 0.65
role_col_w = (width - task_col_w) / max(len(roles), 1)
row_h = min((height - header_h) / max(len(tasks), 1), 0.80)
raci_colors = {
"R": self.theme["semantic"]["responsible"],
"A": self.theme["semantic"]["accountable"],
"C": self.theme["semantic"]["consulted"],
"I": self.theme["semantic"]["informed"],
}
# Header
add_rect(slide, left, top, task_col_w, header_h,
self.theme["colors"]["backgrounds"]["content_area"])
for j, role in enumerate(roles):
x = left + task_col_w + j * role_col_w
add_rect(slide, x, top, role_col_w, header_h,
self.theme["colors"]["primary"]["dark_blue"])
add_text_box(slide, x, top + 0.10, role_col_w, 0.45,
role, self._font_body, 9,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
for i, task in enumerate(tasks):
y = top + header_h + i * row_h
bg = ("#ffffff" if i % 2 == 0
else self.theme["colors"]["backgrounds"]["content_area"])
add_rect(slide, left, y, task_col_w, row_h, bg)
add_text_box(slide, left + 0.20, y + 0.12,
task_col_w - 0.30, row_h - 0.15,
task.get("label", ""),
self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
raci_vals = task.get("raci", [])
for j in range(len(roles)):
x = left + task_col_w + j * role_col_w
add_rect(slide, x, y, role_col_w, row_h, bg)
val = raci_vals[j] if j < len(raci_vals) else ""
if val in raci_colors:
r_d = 0.35
r_x = x + role_col_w / 2 - r_d / 2
r_y = y + row_h / 2 - r_d / 2
opacity = 1.0 if val in ("R", "A", "C") else 0.45
shape = slide.shapes.add_shape(9,
cm(r_x), cm(r_y), cm(r_d), cm(r_d))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(raci_colors[val])
shape.line.fill.background()
add_text_box(slide, r_x, r_y + 0.04,
r_d, r_d - 0.08,
val, self._font_body, 8,
bold=True,
color="#ffffff" if val in ("R", "A") else "#000a32",
align=PP_ALIGN.CENTER)
add_line(slide, left, y + row_h, left + width, y + row_h, "#e8e2d6", 0.02)
# C25 — decision_node -
def _render_decision_tree(self, slide, zone, slide_data,
left, top, width, height):
"""Arbre de décision YES/NO."""
question = slide_data.get("question", "")
branches = slide_data.get("branches", {})
# Question centrale
q_w, q_h = 7.0, 2.80
q_x = left + 0.50
q_y = top + height / 2 - q_h / 2
add_rect(slide, q_x, q_y, q_w, q_h,
self.theme["colors"]["backgrounds"]["content_area"],
"#48545a", 0.03)
add_text_box(slide, q_x + 0.30, q_y + 0.30,
q_w - 0.60, q_h - 0.60,
question, self._font_body, 10,
color=self.theme["colors"]["primary"]["dark_blue"])
branch_configs = [
("yes", "YES", top + height * 0.20),
("no", "NO", top + height * 0.65),
]
colors_branch = {
"yes": self.theme["colors"]["primary"]["bright_warm"],
"no": self.theme["colors"]["primary"]["rose"],
}
for key, label, branch_y in branch_configs:
branch = branches.get(key, {})
if not branch:
continue
# Connecteur + label YES/NO
add_line(slide, q_x + q_w, q_y + q_h / 2,
left + q_w + 2.0, branch_y + 1.0,
"#48545a", 0.03)
add_text_box(slide, q_x + q_w + 0.20,
(q_y + q_h / 2 + branch_y + 1.0) / 2 - 0.15,
0.80, 0.30, label,
self._font_body, 8, bold=True,
color="#48545a")
# Nœud branche
b_x = left + q_w + 2.0
b_w, b_h = 6.0, 2.20
branch_color = colors_branch.get(key, "#d9d9c4")
add_rect(slide, b_x, branch_y, b_w, b_h,
branch_color, "#48545a", 0.03)
add_text_box(slide, b_x + 0.25, branch_y + 0.25,
b_w - 0.50, b_h - 0.50,
branch.get("label", ""),
self._font_body, 10,
color=self.theme["colors"]["primary"]["dark_blue"])
# Options terminales
options = branch.get("options", [])
opt_x = b_x + b_w + 0.80
opt_w = left + width - opt_x - 0.20
for k, opt in enumerate(options[:2]):
opt_y = branch_y + k * 1.20
add_line(slide, b_x + b_w, branch_y + b_h / 2,
opt_x, opt_y + 0.40, "#48545a", 0.02)
add_rect(slide, opt_x, opt_y, opt_w, 1.0,
self.theme["colors"]["backgrounds"]["content_area"],
"#e8e2d6", 0.02)
add_text_box(slide, opt_x + 0.20, opt_y + 0.15,
opt_w - 0.40, 0.70,
opt, self._font_body, 9,
color=self.theme["colors"]["primary"]["dark_blue"])
# C26 — recommendation_sidebar -
def _render_recommendation_sidebar(self, slide, zone, slide_data,
left, top, width, height):
"""Sidebar jaune + corps de la recommandation."""
numero = slide_data.get("numero", 1)
titre = slide_data.get("titre", "")
resume = slide_data.get("resume", "")
cta = slide_data.get("cta", "")
headline = slide_data.get("headline", "")
bullets = slide_data.get("bullets", [])
# Sidebar fond lin
sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml)
add_rect(slide, left, top, sidebar_w, height, "#E8DCC8")
# --- Centrage vertical dynamique des 3 blocs ---
# Bloc 1 : cercle numéro (diamètre = r*2)
# Bloc 2 : titre (hauteur estimée ~1.50cm)
# Bloc 3 : subtitle (hauteur estimée ~1.0cm si présent)
from pptx.enum.text import MSO_ANCHOR
r = 1.25
circle_h = r * 2
titre_h = 1.80
subtitle = slide_data.get("subtitle", resume)
sub_h = 1.0 if subtitle else 0.0
gap = 0.40
n_gaps = 2 if subtitle else 1
total_h = circle_h + titre_h + sub_h + n_gaps * gap
_voff = max(0.0, (height - total_h) / 2)
# Cercle numéro - centré horizontalement
cx = left + sidebar_w / 2
circle_top = top + _voff
shape = slide.shapes.add_shape(9,
cm(cx - r), cm(circle_top), cm(r * 2), cm(r * 2))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(
self.theme["colors"]["primary"]["dark_blue"])
shape.line.fill.background()
_tb_num = add_text_box(slide, cx - r, circle_top, r * 2, r * 2,
str(numero), self._font_display, 40,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
_tb_num.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
# Titre sidebar - centré horizontalement
titre_top = circle_top + circle_h + gap
_tb_titre = add_text_box(slide, left + 0.30, titre_top,
sidebar_w - 0.60, titre_h,
titre, self._font_display, 24,
bold=True,
color=self.theme["colors"]["primary"]["dark_blue"],
align=PP_ALIGN.CENTER)
_tb_titre.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
# Subtitle (sous-topic)
if subtitle:
sub_top = titre_top + titre_h + gap
_tb_sub = add_text_box(slide, left + 0.30, sub_top,
sidebar_w - 0.60, sub_h,
subtitle, self._font_body, 12,
color=self.theme["colors"]["text"]["body"],
align=PP_ALIGN.CENTER)
_tb_sub.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
# CTA integre dans le centrage vertical du volet gauche
if cta:
# Hauteur CTA dynamique selon longueur du texte
cta_w = sidebar_w - 0.60
cta_h = max(0.80, estimate_text_height(cta, 12, cta_w) + 0.30)
# CTA ancré directement sous le bloc titre (+ subtitle si présent)
cta_top = titre_top + titre_h + (gap + sub_h if subtitle else 0)
add_rect(slide, left + 0.30, cta_top,
cta_w, cta_h,
self.theme["colors"]["primary"]["dark_blue"])
_tb_cta = add_text_box(slide, left + 0.30, cta_top,
cta_w, cta_h,
cta, self._font_body, 12,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
_tb_cta.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
# - Volet droit -
content_left = left + sidebar_w + 0.50
content_w = 33.87 - content_left - 0.50
marge_lat = 0.60 # marge gauche et droite identique dans le volet droit
# Calcul hauteur totale volet droit pour centrage vertical
headline_h = 1.20 if headline else 0.0
pad_v = 1.20 # padding haut et bas dans le bloc lin
_pt_per_lvl = {1: 24, 2: 20, 3: 17}
bullets_dedup = []
seen = set()
for b in bullets:
key = b.get("texte", "")
if key not in seen:
seen.add(key)
bullets_dedup.append(b)
bullets_pt = sum(_pt_per_lvl.get(b.get("niveau", 1), 24) for b in bullets_dedup)
bullets_cm = bullets_pt * 0.03528
lin_h = bullets_cm + pad_v * 2
total_right_h = headline_h + lin_h
_voff_right = max(0.0, (height - total_right_h) / 2)
right_top = top + _voff_right
inner_w = content_w - marge_lat * 2
LIN_COLOR = "#E8DCC8" # couleur lin authentique
# Header band bleu
if headline:
add_rect(slide, content_left + marge_lat, right_top,
inner_w, headline_h,
self.theme["colors"]["primary"]["dark_blue"])
_tb_hl = add_text_box(slide,
content_left + marge_lat + 0.30, right_top,
inner_w - 0.60, headline_h,
headline, self._font_body, 20,
bold=True, color="#ffffff")
_tb_hl.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
# Bloc lin avec bullets
if bullets_dedup:
lin_top = right_top + headline_h
add_rect(slide, content_left + marge_lat, lin_top,
inner_w, lin_h,
LIN_COLOR)
# Bullets avec padding interne
zone_fake = {"id": "main_content", "component": "C06",
"width_cm": inner_w - 0.60}
slide_fake = {"bullets": bullets_dedup}
# height = bullets_cm exact pour neutraliser le calcul space_before interne
self._render_bullet_list(slide, zone_fake, slide_fake,
content_left + marge_lat + 0.30,
lin_top + pad_v,
inner_w - 0.60,
bullets_cm,
no_bold=True)
# -
# CLI
# -
def main():
import argparse
parser = argparse.ArgumentParser(
description="Sliding render_engine — JSON → PPTX Pernod Ricard")
parser.add_argument("json_file",
help="Fichier JSON de la présentation (sortie Agent 3)")
parser.add_argument("output",
help="Chemin du fichier PPTX à générer")
parser.add_argument("--theme",
default="theme.yaml",
help="Chemin vers theme.yaml (défaut: ./theme.yaml)")
parser.add_argument("--components",
default="components.yaml",
help="Chemin vers components.yaml")
parser.add_argument("--layouts",
default="layouts.yaml",
help="Chemin vers layouts.yaml")
args = parser.parse_args()
if not os.path.exists(args.json_file):
print(f"✗ Fichier JSON introuvable : {args.json_file}")
sys.exit(1)
for f in [args.theme, args.components, args.layouts]:
if not os.path.exists(f):
print(f"✗ Fichier YAML introuvable : {f}")
sys.exit(1)
engine = RenderEngine(args.theme, args.components, args.layouts)
with open(args.json_file, encoding="utf-8") as f:
raw = f.read().strip()
if not raw:
print(f"\u2717 Fichier d\'entr\u00e9e vide : {args.json_file}")
sys.exit(1)
# Accepte YAML ou JSON indiff\u00e9remment
try:
import yaml as _yaml
json_data = _yaml.safe_load(raw)
except Exception:
json_data = json.loads(raw)
engine.render(json_data, args.output)
if __name__ == "__main__":
main()