1955 lines
82 KiB
Plaintext
1955 lines
82 KiB
Plaintext
|
|
"""
|
|||
|
|
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 = [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)
|
|||
|
|
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 ─────────────────────────────────────────────
|
|||
|
|
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()
|