""" 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()