Files
sliding-automation/archive/render_engine_v1.1.py
T

1925 lines
81 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.
"""
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:
"""Retourne le nom de police à utiliser (tentative + fallback)."""
font_cfg = self.theme["typography"][role]
primary = font_cfg["family"]
fallback = font_cfg["fallback"]
# On tente d'utiliser la police primaire. python-pptx l'intègre par
# nom — si elle n'est pas installée sur la machine cible, PowerPoint
# utilisera la police système la plus proche.
return primary # fallback géré à l'ouverture du PPTX côté utilisateur
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 = [11, 10, 9][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)
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 ─────────────────────────────────────────────
title_h = self._measure_title(layout_cfg, slide_data)
title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h)
self._render_accent_bar(slide, layout_name, title_h)
# ── 4. Content zones ──────────────────────────────────────────────
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:
add_rect(slide, 0, 0, 33.87, 19.05, color)
def _render_diagonal_background(self, slide, color_left: str,
color_right: str, angle_deg: float):
"""Fond splitté diagonal : rectangle gauche + triangle droit."""
# Panneau gauche plein
add_rect(slide, 0, 0, 33.87, 19.05, color_left)
# Panneau droit via freeform (triangle)
# La diagonale va du point (split_x, 0) au point (split_x - offset, 19.05)
split_x = 20.0 # cm — point haut de la diagonale
offset = 19.05 * math.tan(math.radians(angle_deg))
split_x_bottom = split_x - offset
from pptx.util import Emu
from pptx.oxml.ns import qn
# Utilise add_shape freeform via XML pour le triangle
sp = slide.shapes.add_shape(1,
cm(split_x_bottom), cm(0),
cm(33.87 - split_x_bottom), cm(19.05))
sp.fill.solid()
sp.fill.fore_color.rgb = hex_to_rgb(color_right)
sp.line.fill.background()
# Note : python-pptx ne supporte pas les freeforms nativement.
# Pour un vrai triangle, il faudrait manipuler l'XML OOXML directement.
# Cette version utilise un rectangle approché — suffisant pour l'aperçu.
# TODO : implémenter la forme triangulaire via lxml si rendu exact requis.
# ── 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):
# Logo absent → on dessine un proxy (cercle orange petit)
shape = slide.shapes.add_shape(9, # ellipse
cm(logo_cfg["position_left_cm"]),
cm(logo_cfg["position_top_cm"]),
cm(logo_cfg["height_cm"]),
cm(logo_cfg["height_cm"]))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb("#ff9166")
shape.line.fill.background()
return
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.60)
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. 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", "")
left = tz.get("left_cm", 1.80)
top = tz.get("top_cm", 0.45)
width = tz.get("width_cm", 30.00)
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"]))
# Titre principal
h_titre = estimate_text_height(titre, size_pt, width, 1.1)
h_titre = max(h_titre, size_pt * 0.035 + 0.1)
add_text_box(slide, left, top, width, h_titre + 0.20,
titre, self._font_display, size_pt,
bold=bold, color=color)
current_bottom = top + h_titre + 0.20
# Sous-titre
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"]))
margin = sub_cfg.get("margin_top_cm", 0.10)
add_text_box(slide, left, current_bottom + margin,
width, 0.60,
sous_titre, self._font_body, sub_size,
color=sub_color)
current_bottom += margin + 0.60
return current_bottom
# ── Dispatch des zones ────────────────────────────────────────────────
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
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 pour composants non encore implémentés."""
add_rect(slide, left, top, width, height, "#f5f1ea")
add_text_box(slide, left + 0.5, top + height / 2 - 0.3,
width - 1, 0.6,
f"[ {label} — à implémenter ]",
self._font_body, 10, color="#9a9a9a",
align=PP_ALIGN.CENTER)
# C06 — bullet_list ────────────────────────────────────────────────────
def _render_bullet_list(self, slide, zone, slide_data,
left, top, width, height):
"""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
txBox = slide.shapes.add_textbox(
cm(left), cm(top), cm(width), cm(height))
tf = txBox.text_frame
tf.word_wrap = True
sizes = {1: 11, 2: 10, 3: 9}
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)}
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()
first = False
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)
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 — 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.45,
titre_col, self._font_body, 13,
bold=True, color=titre_couleur)
cur_top += 0.50
raw = contenu
if raw:
color = font_override.get("color", self.theme["colors"]["text"]["body"])
size_pt = font_override.get("size_pt", 11)
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):
"""Citation / key message avec guillemets Cormorant."""
citation = slide_data.get("message") or slide_data.get("citation", "")
auteur = slide_data.get("auteur", "")
fonction = slide_data.get("fonction", "")
# Guillemet décoratif
add_text_box(slide, left, top + 0.3, 2.0, 1.5,
"\u201C", self._font_display, 72,
color=self.theme["colors"]["primary"]["bright_blue"])
# Message
add_text_box(slide, left + 1.5, top + 1.2,
width - 1.5, height - 2.0,
citation, self._font_display, 22,
color=self.theme["colors"]["primary"]["dark_blue"])
# Attribution
if auteur or fonction:
attr = f"{auteur} {fonction}".strip()
add_text_box(slide, left + 1.5,
top + height - 1.2,
width - 1.5, 0.60,
attr, self._font_body, 10,
color=self.theme["colors"]["text"]["body"])
# C09 — kpi_grid ───────────────────────────────────────────────────────
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
card_w = (width - (cols - 1) * gap) / cols
card_h = (height - (rows - 1) * gap) / rows
for i, item in enumerate(items):
col = i % cols
row = i // cols
x = left + col * (card_w + gap)
y = top + row * (card_h + gap)
color = item.get("couleur") or self._cycle_color()
color = self._r(color)
header_h = 0.55
# Header coloré
add_rect(slide, x, y, card_w, header_h, color)
add_text_box(slide, x + 0.2, y + 0.10,
card_w - 0.4, header_h - 0.10,
item.get("titre", ""),
self._font_body, 9,
bold=True, color="#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.0
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 - 0.8,
card_w - 0.4, 0.70,
item["sous_titre"],
self._font_body, 9,
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", "")
center_top = top + (height - 4.0) / 2
# Valeur
add_text_box(slide, left, center_top, width, 2.80,
valeur, self._font_display, 72,
bold=True,
color=self.theme["colors"]["primary"]["rose"],
align=PP_ALIGN.CENTER)
if label:
add_text_box(slide, left, center_top + 2.90, width, 0.70,
label, self._font_body, 11,
color=self.theme["colors"]["text"]["body"],
align=PP_ALIGN.CENTER)
if source:
add_text_box(slide, left, center_top + 3.70, width, 0.50,
f"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(self, slide, zone, slide_data,
left, top, width, height):
"""Diagramme circulaire (approximation par secteurs via rectangles colorés)."""
segments = slide_data.get("segments", [])
if not segments:
return
colors_default = self.theme["colors"]["cycle"]
n = len(segments)
# Cercle central approximé (zones colorées en 2×N)
# Note : python-pptx ne supporte pas les pie charts custom facilement.
# On utilise des ellipses + médaillon central.
cx = left + width * 0.35
cy = top + height / 2
r = min(height * 0.38, width * 0.25)
# Secteurs simulés par des rectangles colorés en arc
# (approximation visuelle — pour un vrai pie, utiliser chart_data)
angle_step = 360 / n
for i, seg in enumerate(segments):
color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)])
# Ellipse approximant un secteur
angle_rad = math.radians(i * angle_step)
sx = cx + r * 0.5 * math.cos(angle_rad)
sy = cy + r * 0.5 * math.sin(angle_rad)
shape = slide.shapes.add_shape(9,
cm(sx - r * 0.45), cm(sy - r * 0.45),
cm(r * 0.90), cm(r * 0.90))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(color)
shape.line.fill.background()
# Médaillon central blanc
shape = slide.shapes.add_shape(9,
cm(cx - r * 0.35), cm(cy - r * 0.35),
cm(r * 0.70), cm(r * 0.70))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb("#ffffff")
shape.line.fill.background()
# Légende à droite
leg_left = left + width * 0.55
leg_top = top + (height - n * 1.4) / 2
for i, seg in enumerate(segments):
color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)])
y = leg_top + i * 1.40
# Pastille
shape = slide.shapes.add_shape(9,
cm(leg_left), cm(y + 0.05),
cm(0.35), cm(0.35))
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(color)
shape.line.fill.background()
# Num + label
add_text_box(slide, leg_left + 0.50, y,
width - (leg_left - left) - 0.60, 0.40,
f"0{i+1} {seg.get('label', '')}",
self._font_body, 10, bold=True,
color=self.theme["colors"]["primary"]["dark_blue"])
if seg.get("description"):
add_text_box(slide, leg_left + 0.50, y + 0.42,
width - (leg_left - left) - 0.60, 0.70,
seg["description"],
self._font_body, 9,
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 = 0.70
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, 11,
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, 13,
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, 10,
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 = 0.65
period_h = 0.40
colors = self.theme["colors"]["cycle"]
# Largeur proportionnelle ou égale
phase_w = width / n
for i, phase in enumerate(phases):
x = left + i * phase_w
color = colors[i % len(colors)]
add_rect(slide, x, top, phase_w - 0.10, phase_h, color)
add_text_box(slide, x + 0.10, top + 0.10,
phase_w - 0.20, phase_h - 0.15,
phase.get("label", ""),
self._font_body, 8,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
if phase.get("periode"):
add_text_box(slide, x, top + phase_h + 0.05,
phase_w, period_h,
phase["periode"],
self._font_body, 7,
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 + phase_h + period_h + 0.20 + j * 0.55,
phase_w - 0.20, 0.50,
"" + item,
self._font_body, 9,
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 jaune
sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml)
add_rect(slide, left, top, sidebar_w, height,
self.theme["colors"]["backgrounds"]["highlight_box"])
# Cercle numéro
r = 0.55
cx = left + sidebar_w / 2
shape = slide.shapes.add_shape(9,
cm(cx - r), cm(top + 1.0), 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()
add_text_box(slide, cx - r, top + 1.0, r * 2, r * 2,
str(numero), self._font_display, 18,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
# Titre sidebar
add_text_box(slide, left + 0.30, top + 2.30,
sidebar_w - 0.60, 1.50,
titre, self._font_display, 16,
bold=True,
color=self.theme["colors"]["primary"]["dark_blue"],
align=PP_ALIGN.CENTER)
# Résumé
if resume:
add_text_box(slide, left + 0.30, top + 4.0,
sidebar_w - 0.60, 3.0,
resume, self._font_body, 9,
color=self.theme["colors"]["text"]["body"])
# CTA
if cta:
add_rect(slide, left + 0.30, top + height - 2.0,
sidebar_w - 0.60, 0.80,
self.theme["colors"]["primary"]["dark_blue"])
add_text_box(slide, left + 0.30, top + height - 2.0,
sidebar_w - 0.60, 0.80,
cta, self._font_body, 9,
bold=True, color="#ffffff",
align=PP_ALIGN.CENTER)
# Contenu principal à droite de la sidebar
content_left = left + sidebar_w + 0.50
content_w = 33.87 - content_left - 0.50
# Header band
if headline:
add_rect(slide, content_left, top + 0.80,
content_w, 1.10,
self.theme["colors"]["primary"]["dark_blue"])
add_text_box(slide, content_left + 0.30, top + 1.0,
content_w - 0.60, 0.70,
headline, self._font_body, 11,
bold=True, color="#ffffff")
# Bullets
if bullets:
zone_fake = {"id": "main_content", "component": "C06",
"width_cm": content_w}
slide_fake = {"bullets": bullets}
self._render_bullet_list(slide, zone_fake, slide_fake,
content_left, top + 2.20,
content_w, height - 2.50)
# ─────────────────────────────────────────────────────────────────────────────
# 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()