2655 lines
103 KiB
Python
2655 lines
103 KiB
Python
"""
|
||
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() |