#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ============================================================ RENDER ENGINE V2 — Design System "PR Editorial" SlidingAutomation / Pernod Ricard ============================================================ Principes (vs v1) : 1. Grille fixe 3 zones (titre / contenu / footer) définie dans theme_v2.yaml — plus de coordonnées par layout. 2. Centrage vertical SYSTÉMATIQUE : chaque renderer calcule la hauteur de son contenu puis appelle self._cy(h). 3. Sandwich dark/light, motif cercle, pas de barre d'accent. 4. Composants unifiés : _card(), _badge(), _text()… Usage : python3 render_engine_v2.py input.yaml output.pptx (input YAML ou JSON, mêmes champs que v1) ============================================================ """ import json import os import re import sys import yaml from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import MSO_ANCHOR, PP_ALIGN from pptx.oxml.ns import qn from pptx.util import Cm, Emu, Pt # ---------------------------------------------------------------- # Helpers généraux # ---------------------------------------------------------------- def hex_to_rgb(h: str) -> RGBColor: h = (h or "#000000").lstrip("#") return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) def pick(d, *keys, default=""): """Accès tolérant : première clé non vide trouvée. Si d est une chaîne (l'agent a produit 'Texte' au lieu de {label:'Texte'}), on la retourne directement — évite les crashs 'str has no attribute get'.""" if isinstance(d, str): return d if not isinstance(d, dict): return default for k in keys: v = d.get(k) if v not in (None, ""): return v return default def as_label(item, *keys, default=""): """Normalise un élément de liste en texte : 'Texte' ou {label:'Texte'}.""" if isinstance(item, str): return item if isinstance(item, dict): return pick(item, *(keys or ("label", "texte", "titre", "title")), default=default) return default def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float: """Hauteur estimée d'un texte (cm) pour une largeur donnée.""" if not text: return 0.0 char_w_cm = size_pt * 0.0185 # largeur moyenne d'un caractère chars_per_line = max(1, int(width_cm / char_w_cm)) lines = 0 for para in str(text).split("\n"): lines += max(1, -(-len(para) // chars_per_line)) return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3 em # ---------------------------------------------------------------- # Moteur # ---------------------------------------------------------------- class RenderEngineV2: def __init__(self, theme_path="theme_v2.yaml", layouts_path="layouts_v2.yaml", components_path="components_v2.yaml"): with open(theme_path, encoding="utf-8") as f: self.theme = yaml.safe_load(f) with open(layouts_path, encoding="utf-8") as f: self.layouts = yaml.safe_load(f)["layouts"] with open(components_path, encoding="utf-8") as f: self.components = yaml.safe_load(f)["components"] c = self.theme["colors"] self.C = { "navy": c["primary"]["navy"], "navy2": c["primary"]["navy_light"], "coral": c["accent"]["coral"], "glacier": c["secondary"]["glacier"], "slate": c["secondary"]["slate"], "white": c["backgrounds"]["white"], "card": c["backgrounds"]["card"], "card_alt": c["backgrounds"]["card_alt"], "body": c["text"]["body"], "muted": c["text"]["muted"], } self.cycle = c["cycle"] self.F_DISPLAY = self.theme["fonts"]["display"] self.F_BODY = self.theme["fonts"]["body"] self.T = self.theme["typography"] g = self.theme["grid"] self.MX = g["margin_x"] self.TITLE_Y, self.TITLE_H = g["title_y"], g["title_h"] self.CONT_Y, self.CONT_B = g["content_y"], g["content_b"] self.CONT_H = self.CONT_B - self.CONT_Y self.FOOTER_Y = g["footer_y"] self.GAP, self.GAP_S = g["gap"], g["gap_small"] self.SLIDE_W, self.SLIDE_H = 33.87, 19.05 self._section_counter = 0 # ---------------- primitives ---------------- def _cy(self, content_h: float) -> float: """PRINCIPE 2 : top centré verticalement dans la zone contenu.""" return self.CONT_Y + max(0.0, (self.CONT_H - content_h) / 2) def _text(self, slide, x, y, w, h, txt, *, font=None, size=14, bold=False, italic=False, color=None, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing=None): tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h)) tf = tb.text_frame tf.word_wrap = True tf.vertical_anchor = anchor tf.margin_left = tf.margin_right = 0 tf.margin_top = tf.margin_bottom = 0 p = tf.paragraphs[0] p.alignment = align if spacing: p.line_spacing = Pt(spacing) run = p.add_run() run.text = str(txt) run.font.name = font or self.F_BODY run.font.size = Pt(size) run.font.bold = bold run.font.italic = italic run.font.color.rgb = hex_to_rgb(color or self.C["body"]) if char_spacing: run.font._rPr.set("spc", str(int(char_spacing * 100))) return tb def _rich(self, slide, x, y, w, h, parts, *, size=16, anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT): """Texte multi-runs : parts = [(txt, {bold, color, italic}), ...]""" tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h)) tf = tb.text_frame tf.word_wrap = True tf.vertical_anchor = anchor tf.margin_left = tf.margin_right = 0 tf.margin_top = tf.margin_bottom = 0 p = tf.paragraphs[0] p.alignment = align for txt, opt in parts: r = p.add_run() r.text = txt r.font.name = self.F_BODY r.font.size = Pt(size) r.font.bold = opt.get("bold", False) r.font.italic = opt.get("italic", False) r.font.color.rgb = hex_to_rgb(opt.get("color", self.C["body"])) return tb def _rect(self, slide, x, y, w, h, color, *, rounded=False, radius=0.06): shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE sh = slide.shapes.add_shape(shape_type, Cm(x), Cm(y), Cm(w), Cm(h)) if rounded: try: sh.adjustments[0] = radius except Exception: pass sh.fill.solid() sh.fill.fore_color.rgb = hex_to_rgb(color) sh.line.fill.background() sh.shadow.inherit = False return sh def _oval(self, slide, x, y, d, color, *, dy=None): sh = slide.shapes.add_shape(MSO_SHAPE.OVAL, Cm(x), Cm(y), Cm(d), Cm(dy if dy else d)) sh.fill.solid() sh.fill.fore_color.rgb = hex_to_rgb(color) sh.line.fill.background() sh.shadow.inherit = False return sh def _shadow(self, shape): """Ombre douce via OOXML (non exposée par python-pptx).""" cfg = self.theme["card_style"]["shadow"] sp = shape._element.spPr old = sp.find(qn("a:effectLst")) if old is not None: sp.remove(old) el = sp.makeelement(qn("a:effectLst"), {}) shdw = el.makeelement(qn("a:outerShdw"), { "blurRad": str(int(cfg["blur_pt"] * 12700)), "dist": str(int(cfg["dist_pt"] * 12700)), "dir": str(int(cfg["dir_deg"] * 60000)), "rotWithShape": "0", }) clr = shdw.makeelement(qn("a:srgbClr"), {"val": cfg["color"].lstrip("#")}) alpha = clr.makeelement(qn("a:alpha"), {"val": str(int(cfg["alpha_pct"] * 1000))}) clr.append(alpha) shdw.append(clr) el.append(shdw) sp.append(el) def _card(self, slide, x, y, w, h, fill=None): """PRINCIPE 5 : carte unifiée — coins arrondis + ombre douce.""" sh = self._rect(slide, x, y, w, h, fill or self.C["card"], rounded=True) self._shadow(sh) return sh def _badge(self, slide, cx, cy_, d, num, *, fill=None, font_size=None): """Rond numéroté centré sur (cx, cy_).""" self._oval(slide, cx - d / 2, cy_ - d / 2, d, fill or self.C["navy"]) fs = font_size or max(14, int(d * 11)) self._text(slide, cx - d / 2, cy_ - d / 2, d, d, str(num), font=self.F_DISPLAY, size=fs, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) def _title(self, slide, txt): self._text(slide, self.MX, self.TITLE_Y, self.SLIDE_W - 2 * self.MX, self.TITLE_H, txt, font=self.F_DISPLAY, size=self.T["slide_title"], bold=True, color=self.C["navy"]) def _footer(self, slide, num): sig = self.theme["signature"]["footer"] self._text(slide, self.MX, self.FOOTER_Y, 2, 0.7, str(num), size=sig["size"], color=sig["color"]) self._text(slide, self.SLIDE_W - self.MX - 10, self.FOOTER_Y, 10, 0.7, sig["right"], size=sig["size"], color=sig["color"], align=PP_ALIGN.RIGHT) def _bg(self, slide, color): slide.background.fill.solid() slide.background.fill.fore_color.rgb = hex_to_rgb(color) # ---------------- renderers ---------------- def _render_cover_split(self, slide, d): self._bg(slide, self.C["navy"]) for c in self.components["decor_circles"]["cover"]: col = {"theme.primary.navy_light": self.C["navy2"], "theme.accent.coral": self.C["coral"], "theme.secondary.glacier": self.C["glacier"]}[c["color"]] self._oval(slide, c["x"], c["y"], c["d"], col) self._text(slide, self.MX, 2.5, 15, 1.0, "PERNOD RICARD", size=13, bold=True, color=self.C["glacier"], char_spacing=4) self._text(slide, self.MX, 5.8, 24, 6.1, pick(d, "titre", "title"), font=self.F_DISPLAY, size=self.T["cover_title"], bold=True, color=self.C["white"], spacing=52) self._text(slide, self.MX, 12.7, 20.8, 1.5, pick(d, "sous_titre", "subtitle", "tagline", "accroche"), size=18, italic=True, color=self.C["coral"]) def _render_executive_summary(self, slide, d): self._title(slide, pick(d, "titre", "title")) labels = self.layouts["executive_summary"].get( "labels", ["SITUATION", "COMPLICATION", "RÉSOLUTION"]) keys = ["situation", "complication", "resolution"] colors = [self.C["navy"], self.C["coral"], self.C["glacier"]] ch, gap = 3.68, 0.89 rows = [(labels[i], colors[i], pick(d, k)) for i, k in enumerate(keys) if pick(d, k)] tot = len(rows) * ch + (len(rows) - 1) * gap y = self._cy(tot) bd = self.components["badge"]["sizes"]["m"] for i, (label, col, txt) in enumerate(rows): self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, ch) self._badge(slide, self.MX + 1.14 + bd / 2, y + ch / 2, bd, i + 1, fill=col, font_size=22) self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, label, size=14, bold=True, color=col, char_spacing=3) self._text(slide, self.MX + 3.81, y + 1.57, self.SLIDE_W - 2 * self.MX - 5.33, 1.8, txt, size=self.T["body"], color=self.C["body"]) y += ch + gap def _render_section_divider(self, slide, d): self._bg(slide, self.C["navy"]) self._section_counter += 1 num = str(d.get("numero", self._section_counter)).zfill(2) self._text(slide, 20.6, 3.0, 12.7, 12.7, num, font=self.F_DISPLAY, size=self.T["ghost_number"], bold=True, color=self.C["navy2"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"]) self._text(slide, self.MX + 1.4, 4.83, 19.3, 4.1, pick(d, "titre", "title"), font=self.F_DISPLAY, size=self.T["section_title"], bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE) def _render_big_stat(self, slide, d): self._title(slide, pick(d, "titre", "title")) val = pick(d, "valeur", "stat", "chiffre", "value") desc = pick(d, "description", "texte", "label") src = pick(d, "source", "reference") block = 8.13 y = self._cy(block) self._text(slide, 0, y, self.SLIDE_W, 5.1, val, font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True, color=self.C["coral"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, 2.0, desc, size=18, color=self.C["body"], align=PP_ALIGN.CENTER) if src: self._text(slide, 6.86, y + 7.37, self.SLIDE_W - 13.72, 0.9, src, size=11, italic=True, color=self.C["muted"], align=PP_ALIGN.CENTER) def _col_items(self, col_data): """Extrait une liste de textes depuis une colonne two_cols.""" items = col_data.get("bullets") or col_data.get("items") or [] out = [] for b in items: out.append(b.get("texte", "") if isinstance(b, dict) else str(b)) return out def _render_two_cols_text(self, slide, d): self._title(slide, pick(d, "titre", "title")) left, right = d.get("left", {}), d.get("right", {}) col_w = (self.SLIDE_W - 2 * self.MX - 1.27) / 2 ch = 10.67 y = self._cy(ch) hdr_h = self.components["header_band"]["height_cm"] for i, (col, accent) in enumerate( [(left, self.C["navy"]), (right, self.C["coral"])]): x = self.MX + i * (col_w + 1.27) self._card(slide, x, y, col_w, ch) self._rect(slide, x, y, col_w, hdr_h, accent) self._text(slide, x + 1.0, y, col_w - 2.0, hdr_h, pick(col, "titre", "title", "header"), size=18, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE) items = self._col_items(col) tb = slide.shapes.add_textbox(Cm(x + 1.14), Cm(y + hdr_h + 0.6), Cm(col_w - 2.28), Cm(ch - hdr_h - 1.2)) tf = tb.text_frame tf.word_wrap = True tf.vertical_anchor = MSO_ANCHOR.MIDDLE first = True for it in items: p = tf.paragraphs[0] if first else tf.add_paragraph() first = False p.space_after = Pt(14) r = p.add_run() r.text = "• " + it r.font.name = self.F_BODY r.font.size = Pt(15) r.font.color.rgb = hex_to_rgb(self.C["body"]) def _render_kpi_grid(self, slide, d): self._title(slide, pick(d, "titre", "title")) items = d.get("items", []) n = max(1, len(items)) gap = 1.14 col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n ch = 9.14 y = self._cy(ch) for i, it in enumerate(items): x = self.MX + i * (col_w + gap) self._card(slide, x, y, col_w, ch) self._text(slide, x + 0.89, y + 0.89, col_w - 1.78, 1.14, pick(it, "label", "titre"), size=15, bold=True, color=self.C["slate"], char_spacing=2) # Taille adaptative : chiffre court = grand, texte long = réduit _val = str(pick(it, "valeur", "value", "stat")) _vlen = len(_val) if _vlen <= 6: _vsize = self.T["stat_card"] # 54pt — "20-40%", "+13%" elif _vlen <= 12: _vsize = 32 # "Baisse", "Conformité" else: _vsize = 22 # "Données fiables" self._text(slide, x + 0.89, y + 2.41, col_w - 1.78, 3.68, _val, font=self.F_DISPLAY, size=_vsize, bold=True, color=self.C["coral"], anchor=MSO_ANCHOR.MIDDLE) self._text(slide, x + 0.89, y + ch - 2.67, col_w - 1.78, 2.03, pick(it, "description", "source", "detail"), size=12, color=self.C["muted"]) def _render_key_message(self, slide, d): self._bg(slide, self.C["navy"]) self._text(slide, self.MX, 1.78, 5.6, 6.1, "\u201C", font=self.F_DISPLAY, size=200, bold=True, color=self.C["coral"]) self._text(slide, 6.6, 5.84, 23.9, 4.32, pick(d, "message", "texte", "citation"), font=self.F_DISPLAY, size=32, bold=True, color=self.C["white"], spacing=40) detail = pick(d, "detail", "sous_message", "sous_texte", "soutien") if detail: self._text(slide, 6.6, 10.8, 21.6, 1.5, detail, size=18, italic=True, color=self.C["glacier"]) def _render_circular_diagram(self, slide, d): self._title(slide, pick(d, "titre", "title")) segs = d.get("segments", []) n = len(segs) if not n: return D = 6.35 cxc = 8.64 cyc = self.CONT_Y + self.CONT_H / 2 # positions : triangle pour 3, sinon cercle de positions if n == 3: centers = [(cxc - 1.83, cyc - 1.57), (cxc + 1.83, cyc - 1.57), (cxc, cyc + 1.57)] else: import math r_orb = D * 0.62 centers = [(cxc + r_orb * math.cos(2 * math.pi * i / n - math.pi / 2), cyc + r_orb * math.sin(2 * math.pi * i / n - math.pi / 2)) for i in range(n)] for i, seg in enumerate(segs): col = seg.get("couleur") or self.cycle[i % len(self.cycle)] cx0, cy0 = centers[i] sh = self._oval(slide, cx0 - D / 2, cy0 - D / 2, D, col) sh.line.color.rgb = hex_to_rgb(self.C["white"]) sh.line.width = Pt(2) for i, seg in enumerate(segs): cx0, cy0 = centers[i] below = cy0 > cyc # quadrant : numéro haut ou bas ny = cy0 + (D / 2 - 1.83) * (1 if below else -1) - 0.7 self._text(slide, cx0 - 1.78, ny, 3.56, 1.4, str(i + 1).zfill(2), font=self.F_DISPLAY, size=22, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER) # légende droite — centrée verticalement lh = 2.67 leg_h = n * lh ly = self._cy(leg_h) for i, seg in enumerate(segs): col = seg.get("couleur") or self.cycle[i % len(self.cycle)] self._oval(slide, 17.8, ly + 0.2, 1.27, col) self._text(slide, 19.7, ly, 12.7, 1.07, f"{str(i + 1).zfill(2)} {seg.get('label', '')}", size=17, bold=True, color=self.C["navy"]) self._text(slide, 19.7, ly + 1.07, 12.7, 1.0, seg.get("description", ""), size=14, color=self.C["body"]) ly += lh def _render_default_bullets(self, slide, d): self._title(slide, pick(d, "titre", "title")) bullets = d.get("bullets", []) rh = 2.54 tot = len(bullets) * rh y = self._cy(tot) mk = self.components["square_mark"]["size_cm"] for b in bullets: txt = b.get("texte", "") if isinstance(b, dict) else str(b) self._rect(slide, self.MX + 0.25, y + (rh - 0.5) / 2 - mk / 2 + 0.25, mk, mk, self.C["coral"]) # "Mot : explication" → mot en gras navy m = re.match(r"^([^:]{2,30})\s*:\s*(.+)$", txt) if m: parts = [(m.group(1) + " — ", {"bold": True, "color": self.C["navy"]}), (m.group(2), {"color": self.C["body"]})] else: parts = [(txt, {"color": self.C["body"]})] self._rich(slide, self.MX + 1.52, y, self.SLIDE_W - 2 * self.MX - 1.52, rh - 0.5, parts, size=self.T["body_large"], anchor=MSO_ANCHOR.MIDDLE) y += rh def _render_numbered_steps(self, slide, d): self._title(slide, pick(d, "titre", "title")) steps = d.get("steps", []) rh, gap = 3.68, 0.76 tot = len(steps) * rh + (len(steps) - 1) * gap y = self._cy(tot) bd = self.components["badge"]["sizes"]["l"] for i, st in enumerate(steps): if isinstance(st, str): st = {"titre": st} self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, rh) self._badge(slide, self.MX + 1.02 + bd / 2, y + rh / 2, bd, st.get("numero", i + 1), font_size=28) self._text(slide, self.MX + 4.32, y + 0.64, 14.2, 1.27, pick(st, "titre", "title"), size=19, bold=True, color=self.C["navy"]) self._text(slide, self.MX + 4.32, y + 1.98, self.SLIDE_W - 2 * self.MX - 5.84, 1.27, pick(st, "description", "detail"), size=15, color=self.C["body"]) y += rh + gap def _render_phases_timeline(self, slide, d): self._title(slide, pick(d, "titre", "title")) phases = d.get("phases", []) n = max(1, len(phases)) gap = 1.02 col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n bh = 3.81 tot = bh + 2.41 y = self._cy(tot) # ligne pointillée de connexion ln = slide.shapes.add_connector(1, Cm(self.MX + col_w / 2), Cm(y + bh / 2), Cm(self.SLIDE_W - self.MX - col_w / 2), Cm(y + bh / 2)) ln.line.color.rgb = hex_to_rgb(self.C["muted"]) ln.line.width = Pt(1.25) ln.line.dash_style = 4 # MSO_LINE.DASH for i, ph in enumerate(phases): col = self.cycle[i % len(self.cycle)] x = self.MX + i * (col_w + gap) sh = self._rect(slide, x, y, col_w, bh, col, rounded=True, radius=0.08) self._shadow(sh) self._text(slide, x, y, col_w, bh, pick(ph, "label", "titre"), font=self.F_DISPLAY, size=24, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) self._text(slide, x, y + bh + 0.64, col_w, 1.5, pick(ph, "periode", "description"), size=15, color=self.C["body"], align=PP_ALIGN.CENTER) def _render_recommendation_card(self, slide, d): sbw = self.layouts["recommendation_card"].get("sidebar_width_cm", 9.9) self._rect(slide, 0, 0, sbw, self.SLIDE_H, self.C["navy"]) bd = self.components["badge"]["sizes"]["xl"] titre = pick(d, "titre", "title") cta = pick(d, "cta") # contenu sidebar — centré verticalement sur la slide cta_h = max(2.0, estimate_text_height(cta, 13, sbw - 2.3) + 0.8) if cta else 0 inner = bd + 0.89 + 1.78 + (1.27 + cta_h if cta else 0) sy = (self.SLIDE_H - inner) / 2 self._badge(slide, sbw / 2, sy + bd / 2, bd, d.get("numero", 1), fill=self.C["coral"], font_size=40) self._text(slide, 0.76, sy + bd + 0.89, sbw - 1.52, 1.78, titre, font=self.F_DISPLAY, size=24, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER) if cta: self._text(slide, 1.14, sy + bd + 0.89 + 1.78 + 1.27, sbw - 2.28, cta_h, cta, size=13, italic=True, color=self.C["glacier"], align=PP_ALIGN.CENTER) # corps droit cx = sbw + 1.52 cw = self.SLIDE_W - sbw - 3.04 bullets = d.get("bullets", []) texts = [b.get("texte", "") if isinstance(b, dict) else str(b) for b in bullets] # dédoublonnage seen, dedup = set(), [] for t in texts: if t not in seen: seen.add(t) dedup.append(t) hdr_h = 2.16 body_h = len(dedup) * 1.57 + 1.78 tot = hdr_h + body_h y = (self.SLIDE_H - tot) / 2 headline = pick(d, "headline", "header") if headline: self._rect(slide, cx, y, cw, hdr_h, self.C["navy"]) self._text(slide, cx + 1.0, y, cw - 2.0, hdr_h, headline, size=18, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE, char_spacing=2) self._card(slide, cx, y + hdr_h, cw, body_h) tb = slide.shapes.add_textbox(Cm(cx + 1.27), Cm(y + hdr_h + 0.89), Cm(cw - 2.54), Cm(body_h - 1.78)) tf = tb.text_frame tf.word_wrap = True tf.vertical_anchor = MSO_ANCHOR.MIDDLE first = True for t in dedup: p = tf.paragraphs[0] if first else tf.add_paragraph() first = False p.space_after = Pt(12) r = p.add_run() r.text = "• " + t r.font.name = self.F_BODY r.font.size = Pt(16) r.font.color.rgb = hex_to_rgb(self.C["body"]) self._footer(slide, getattr(self, "_slide_num", "")) def _render_end_slide(self, slide, d): self._bg(slide, self.C["navy"]) for c in self.components["decor_circles"]["end"]: col = {"theme.primary.navy_light": self.C["navy2"], "theme.accent.coral": self.C["coral"]}[c["color"]] self._oval(slide, c["x"], c["y"], c["d"], col) self._text(slide, 4.06, 6.86, 25.7, 4.57, pick(d, "message", "titre", "texte"), font=self.F_DISPLAY, size=34, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, spacing=42) self._text(slide, 4.06, 11.9, 25.7, 1.27, "Merci pour votre attention", size=16, italic=True, color=self.C["glacier"], align=PP_ALIGN.CENTER) # ── NEW LAYOUTS — 8 extensions ──────────────────────────────────────────── def _col_positions(self, n_cols, first_col_w=None): """Helper : retourne [(x, width), ...] pour chaque colonne.""" if first_col_w and n_cols > 1: rest = (self.SLIDE_W - 2*self.MX - first_col_w) / (n_cols - 1) return [(self.MX, first_col_w)] + [ (self.MX + first_col_w + i*rest, rest) for i in range(n_cols-1)] col_w = (self.SLIDE_W - 2*self.MX) / max(1, n_cols) return [(self.MX + i*col_w, col_w) for i in range(n_cols)] # ── from_to_pairs ───────────────────────────────────────────────────────── def _render_from_to_pairs(self, slide, d): self._title(slide, pick(d, "titre", "title")) pairs = d.get("pairs", []) if not pairs: return lbl_from = pick(d, "label_from", "ÉTAT ACTUEL") lbl_to = pick(d, "label_to", "ÉTAT CIBLE") ARROW_W = 2.54 col_w = (self.SLIDE_W - 2*self.MX - ARROW_W) / 2 row_h, gap, hdr_h = 1.52, 0.28, 0.76 tot = hdr_h + gap + len(pairs) * (row_h + gap) y = self._cy(tot) ax = self.MX + col_w # arrow zone x tx = ax + ARROW_W # TO column x # Headers self._text(slide, self.MX, y, col_w, hdr_h, lbl_from, size=13, bold=True, color=self.C["muted"], char_spacing=3, anchor=MSO_ANCHOR.MIDDLE) self._text(slide, tx, y, col_w, hdr_h, lbl_to, size=13, bold=True, color=self.C["navy"], char_spacing=3, anchor=MSO_ANCHOR.MIDDLE) y += hdr_h + gap for pair in pairs: # FROM card — blue tint self._card(slide, self.MX, y, col_w, row_h, self.C["card_alt"]) self._text(slide, self.MX + 0.64, y, col_w - 1.27, row_h, pick(pair, "from", "de", "avant"), size=15, italic=True, color=self.C["slate"], anchor=MSO_ANCHOR.MIDDLE) # Arrow self._text(slide, ax, y, ARROW_W, row_h, "→", size=30, bold=True, color=self.C["coral"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # TO card — warm self._card(slide, tx, y, col_w, row_h) self._text(slide, tx + 0.64, y, col_w - 1.27, row_h, pick(pair, "to", "vers", "après"), size=15, bold=True, color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE) y += row_h + gap # ── gantt_timeline ──────────────────────────────────────────────────────── def _render_gantt_timeline(self, slide, d): self._title(slide, pick(d, "titre", "title")) periods = d.get("periods", []) workstreams = d.get("workstreams", []) if not periods or not workstreams: return n_p = len(periods) LBL_W = 4.57 GAP_COL = 0.25 GRID_W = self.SLIDE_W - 2*self.MX - LBL_W - GAP_COL col_w = GRID_W / n_p HDR_H = 0.80 WS_H = 0.68 TASK_H = 0.78 n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams if isinstance(ws, dict)) total_h = HDR_H + len(workstreams)*WS_H + n_tasks*TASK_H y0 = self._cy(total_h) gx = self.MX + LBL_W + GAP_COL # grid x origin # Header self._rect(slide, self.MX, y0, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"]) for i, p in enumerate(periods): self._text(slide, gx + i*col_w, y0, col_w, HDR_H, p, size=10, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Subtle vertical separators over full grid height for i in range(1, n_p): xl = gx + i * col_w ln = slide.shapes.add_connector(1, Cm(xl), Cm(y0 + HDR_H), Cm(xl), Cm(y0 + total_h)) ln.line.color.rgb = hex_to_rgb("#E0DEDB") ln.line.width = Pt(0.5) cur_y = y0 + HDR_H for ws_idx, ws in enumerate(workstreams): if not isinstance(ws, dict): continue ws_col = self.cycle[ws_idx % len(self.cycle)] # Workstream row self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, WS_H, "#EEECEA") self._rect(slide, self.MX, cur_y, 0.28, WS_H, ws_col) self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, WS_H, ws.get("label", ""), size=12, bold=True, color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE) cur_y += WS_H for task in ws.get("tasks", []): if not isinstance(task, dict): continue start = task.get("start", 0) end = task.get("end", start + 1) bg = self.C["white"] self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, TASK_H, bg) self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, TASK_H, " " + task.get("label", ""), size=11, color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE) bx = gx + start * col_w + 0.14 bw = (end - start) * col_w - 0.28 self._rect(slide, bx, cur_y + 0.15, bw, TASK_H - 0.30, ws_col, rounded=True) cur_y += TASK_H # bottom separator ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(cur_y), Cm(self.SLIDE_W - self.MX), Cm(cur_y)) ln.line.color.rgb = hex_to_rgb("#D8D4CF") ln.line.width = Pt(0.5) # ── yearly_timeline ─────────────────────────────────────────────────────── def _render_yearly_timeline(self, slide, d): self._title(slide, pick(d, "titre", "title")) milestones = d.get("milestones", []) if not milestones: return n = len(milestones) D = 1.27 # circle diameter LBL_H = 1.52 LINE_Y_DELTA = LBL_H + 0.38 # gap above line for top labels tot_h = LBL_H * 2 + D + 0.76 y_top = self._cy(tot_h) line_y = y_top + LBL_H + 0.38 # centre of the line / circles usable_w = self.SLIDE_W - 2*self.MX step = usable_w / (n - 1) if n > 1 else usable_w # Horizontal line ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(line_y + D/2), Cm(self.SLIDE_W - self.MX), Cm(line_y + D/2)) ln.line.color.rgb = hex_to_rgb(self.C["navy"]) ln.line.width = Pt(1.75) for i, ms in enumerate(milestones): cx = self.MX + i * step active = ms.get("actif", False) col = self.C["coral"] if active else self.C["navy"] lbl_col = self.C["navy"] if active else self.C["body"] lbl_sz = 13 if active else 12 # Circle self._oval(slide, cx - D/2, line_y, D, col) annee = str(pick(ms, "annee", "year", str(i+1))) self._text(slide, cx - D/2, line_y, D, D, annee, size=10, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Label lw = min(step * 0.85, 5.08) if n > 1 else usable_w label = pick(ms, "label", "evenement", "event") # Clamp : label ne sort jamais du slide lx = max(self.MX, min(cx - lw/2, self.SLIDE_W - self.MX - lw)) if i % 2 == 0: self._text(slide, lx, y_top, lw, LBL_H, label, size=lbl_sz, bold=active, color=lbl_col, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM) else: self._text(slide, lx, line_y + D + 0.25, lw, LBL_H, label, size=lbl_sz, bold=active, color=lbl_col, align=PP_ALIGN.CENTER) # ── comparison_table ────────────────────────────────────────────────────── def _render_comparison_table(self, slide, d): self._title(slide, pick(d, "titre", "title")) headers = d.get("headers", []) rows = d.get("rows", []) if not headers or not rows: return n_cols = len(headers) n_rows = len(rows) HDR_H = 1.02 ROW_H = min(1.27, (self.CONT_H - HDR_H - 0.5) / n_rows) FIRST_W = 5.08 cols = self._col_positions(n_cols, FIRST_W if n_cols > 1 else None) total_h = HDR_H + n_rows * ROW_H y = self._cy(total_h) # Header self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"]) for j, (x, w) in enumerate(cols): self._text(slide, x + 0.33, y, w - 0.33, HDR_H, headers[j], size=13, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE) # Rows for i, row in enumerate(rows): ry = y + HDR_H + i * ROW_H bg = self.C["card"] if i % 2 == 0 else self.C["white"] self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg) cells = list(row) if isinstance(row, (list, tuple)) else ([row.get("label", "")] + row.get("values", [])) if isinstance(row, dict) else [str(row)] for j, (x, w) in enumerate(cols): val = cells[j] if j < len(cells) else "" self._text(slide, x + 0.33, ry, w - 0.33, ROW_H, str(val), size=13, bold=(j == 0), color=self.C["navy"] if j == 0 else self.C["body"], anchor=MSO_ANCHOR.MIDDLE) # separator ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H), Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H)) ln.line.color.rgb = hex_to_rgb("#E8E4E0") ln.line.width = Pt(0.5) # ── raci_table ──────────────────────────────────────────────────────────── def _render_raci_table(self, slide, d): self._title(slide, pick(d, "titre", "title")) roles = d.get("roles", []) tasks = d.get("tasks", []) if not roles or not tasks: return RACI_COLORS = {"R": self.C["coral"], "A": self.C["navy"], "C": self.C["slate"], "I": self.C["muted"]} n_cols = len(roles) + 1 n_rows = len(tasks) HDR_H = 1.02 ROW_H = min(1.14, (self.CONT_H - HDR_H - 0.5) / n_rows) TASK_W = 6.35 ROLE_W = (self.SLIDE_W - 2*self.MX - TASK_W) / len(roles) BADGE = 0.64 total_h = HDR_H + n_rows * ROW_H y = self._cy(total_h) # Header self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"]) self._text(slide, self.MX + 0.33, y, TASK_W - 0.33, HDR_H, "Activité", size=12, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE) for j, role in enumerate(roles): rx = self.MX + TASK_W + j * ROLE_W self._text(slide, rx, y, ROLE_W, HDR_H, role, size=11, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Rows for i, task in enumerate(tasks): if not isinstance(task, dict): continue ry = y + HDR_H + i * ROW_H bg = self.C["card"] if i % 2 == 0 else self.C["white"] self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg) self._text(slide, self.MX + 0.33, ry, TASK_W - 0.33, ROW_H, task.get("label", ""), size=12, color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE) raci = task.get("raci", []) for j, letter in enumerate(raci): if j >= len(roles): break rx = self.MX + TASK_W + j * ROLE_W + (ROLE_W - BADGE) / 2 by = ry + (ROW_H - BADGE) / 2 col = RACI_COLORS.get(letter.upper(), self.C["muted"]) self._rect(slide, rx, by, BADGE, BADGE, col, rounded=True, radius=0.08) self._text(slide, rx, by, BADGE, BADGE, letter.upper(), size=13, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H), Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H)) ln.line.color.rgb = hex_to_rgb("#E8E4E0") ln.line.width = Pt(0.5) # ── process_arrow ───────────────────────────────────────────────────────── def _render_process_arrow(self, slide, d): self._title(slide, pick(d, "titre", "title")) steps = d.get("steps", []) if not steps: return n = len(steps) ARR_W = 0.89 BOX_H = 2.67 DSC_H = 1.0 usable = self.SLIDE_W - 2*self.MX - ARR_W * (n - 1) box_w = usable / n tot_h = BOX_H + DSC_H + 0.38 y0 = self._cy(tot_h) BADGE_D = 0.76 for i, step in enumerate(steps): x = self.MX + i * (box_w + ARR_W) col = self.cycle[i % len(self.cycle)] # Box (dark bg = navy or cycle) self._rect(slide, x, y0, box_w, BOX_H, col, rounded=True) # Badge number top-center self._oval(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2, BADGE_D, self.C["coral"]) self._text(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2, BADGE_D, BADGE_D, str(i + 1), size=14, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Title inside box self._text(slide, x + 0.33, y0 + 0.38, box_w - 0.66, BOX_H - 0.76, pick(step, "titre", "title", "label"), size=15, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Description below self._text(slide, x, y0 + BOX_H + 0.28, box_w, DSC_H, pick(step, "description", "detail"), size=12, color=self.C["body"], align=PP_ALIGN.CENTER) # Connecting arrow (except last) if i < n - 1: ax = x + box_w self._text(slide, ax, y0, ARR_W, BOX_H, "›", size=28, bold=True, color=self.C["coral"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # ── org_chart ───────────────────────────────────────────────────────────── def _render_org_chart(self, slide, d): self._title(slide, pick(d, "titre", "title")) def _node(n): """Normalise un nœud : 'Texte' → {label:'Texte'}, dict inchangé.""" if isinstance(n, str): return {"label": n, "children": []} if isinstance(n, dict): return {"label": pick(n, "label", "titre", "title", "nom"), "children": n.get("children", []) or []} return {"label": str(n), "children": []} root = d.get("root", {}) if isinstance(root, str): root = {"label": root, "children": []} if not root: return root = _node(root) BOX_W, BOX_H = 3.81, 1.0 GAP_V, GAP_H = 1.27, 0.64 children = [_node(c) for c in root.get("children", [])] n_children = len(children) # grandchildren count per child (max) max_gd = max((len(c.get("children", [])) for c in children), default=0) # Heights levels = 1 + (1 if children else 0) + (1 if max_gd > 0 else 0) tot_h = levels * BOX_H + (levels - 1) * GAP_V cy_top = self._cy(tot_h) # Root box (centered, navy) root_x = (self.SLIDE_W - BOX_W) / 2 self._card(slide, root_x, cy_top, BOX_W, BOX_H, self.C["navy"]) self._text(slide, root_x + 0.33, cy_top, BOX_W - 0.66, BOX_H, root.get("label", ""), size=14, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) if not children: return # Children row child_y = cy_top + BOX_H + GAP_V total_children_w = n_children * BOX_W + (n_children - 1) * GAP_H child_x_start = (self.SLIDE_W - total_children_w) / 2 # Connector from root bottom to children row for i, child in enumerate(children): cx = child_x_start + i * (BOX_W + GAP_H) col = self.C["card"] self._card(slide, cx, child_y, BOX_W, BOX_H, col) self._text(slide, cx + 0.33, child_y, BOX_W - 0.66, BOX_H, child.get("label", ""), size=13, bold=True, color=self.C["navy"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Line: root bottom → child top mid_root_x = root_x + BOX_W / 2 mid_child_x = cx + BOX_W / 2 mid_y = cy_top + BOX_H + GAP_V / 2 # Vertical from root ln = slide.shapes.add_connector(1, Cm(mid_root_x), Cm(cy_top + BOX_H), Cm(mid_root_x), Cm(mid_y)) ln.line.color.rgb = hex_to_rgb(self.C["navy"]) ln.line.width = Pt(1.0) # Horizontal to child ln2 = slide.shapes.add_connector(1, Cm(mid_root_x if i == 0 else child_x_start + (n_children-1)*(BOX_W+GAP_H)/2 if i == n_children-1 else mid_child_x), Cm(mid_y), Cm(mid_child_x), Cm(mid_y)) ln2.line.color.rgb = hex_to_rgb(self.C["navy"]) ln2.line.width = Pt(1.0) # Vertical to child ln3 = slide.shapes.add_connector(1, Cm(mid_child_x), Cm(mid_y), Cm(mid_child_x), Cm(child_y)) ln3.line.color.rgb = hex_to_rgb(self.C["navy"]) ln3.line.width = Pt(1.0) # Grandchildren — sizing dynamique pour tenir dans la colonne grandchildren = [_node(g) for g in child.get("children", [])] if grandchildren: gd_y = child_y + BOX_H + GAP_V n_gd = len(grandchildren) col_span = BOX_W + GAP_H # largeur allouée par parent GD_GAP = 0.25 GD_W = min(BOX_W, (col_span - GD_GAP * (n_gd - 1)) / n_gd) GD_W = max(1.78, GD_W) gd_total = n_gd * GD_W + (n_gd - 1) * GD_GAP gd_x_start = cx + BOX_W/2 - gd_total/2 mid_cy_x = cx + BOX_W/2 for k, gc in enumerate(grandchildren): gx = gd_x_start + k * (GD_W + GD_GAP) self._card(slide, gx, gd_y, GD_W, BOX_H, "#EAF0F8") self._text(slide, gx + 0.20, gd_y, GD_W - 0.40, BOX_H, gc.get("label", ""), size=10, color=self.C["navy"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) mid_gx = gx + GD_W/2 ln4 = slide.shapes.add_connector(1, Cm(mid_cy_x), Cm(child_y + BOX_H), Cm(mid_gx), Cm(gd_y)) ln4.line.color.rgb = hex_to_rgb(self.C["muted"]) ln4.line.width = Pt(0.75) # ── matrix_2x2 ──────────────────────────────────────────────────────────── def _render_matrix_2x2(self, slide, d): self._title(slide, pick(d, "titre", "title")) axis_x = d.get("axis_x", {}) axis_y = d.get("axis_y", {}) quadrants = d.get("quadrants", {}) items = d.get("items", []) ITEM_D = 0.89 # Matrix zone AXIS_LBL = 1.27 MAT_W = self.SLIDE_W - 2*self.MX - AXIS_LBL MAT_H = self.CONT_H - AXIS_LBL - 0.3 tot_h = MAT_H + AXIS_LBL y0 = self._cy(tot_h) mx0 = self.MX + AXIS_LBL # matrix x start # Quadrant backgrounds hw, hh = MAT_W/2, MAT_H/2 Q_FILLS = ["#EAF0F8", self.C["card"], "#E8F4EC", "#FFF0EC"] Q_POS = [(mx0, y0), (mx0+hw, y0), (mx0, y0+hh), (mx0+hw, y0+hh)] for fill, (qx, qy) in zip(Q_FILLS, Q_POS): self._rect(slide, qx, qy, hw, hh, fill) # Axis lines for lx, ly, ex, ey in [ (mx0, y0, mx0 + MAT_W, y0), (mx0, y0 + MAT_H, mx0 + MAT_W, y0 + MAT_H), (mx0, y0, mx0, y0 + MAT_H), (mx0 + MAT_W, y0, mx0 + MAT_W, y0 + MAT_H), (mx0 + hw, y0, mx0 + hw, y0 + MAT_H), # vertical mid (mx0, y0 + hh, mx0 + MAT_W, y0 + hh), # horizontal mid ]: ln = slide.shapes.add_connector(1, Cm(lx), Cm(ly), Cm(ex), Cm(ey)) ln.line.color.rgb = hex_to_rgb("#C8C4BE" if "mid" not in str(lx) else "#C8C4BE") ln.line.width = Pt(1.0 if (lx == mx0+hw or ly == y0+hh) else 1.5) # Quadrant labels (corners) QD = { "top_left": (mx0 + 0.3, y0 + 0.2), "top_right": (mx0 + hw + 0.3, y0 + 0.2), "bottom_left": (mx0 + 0.3, y0 + hh + 0.2), "bottom_right": (mx0 + hw + 0.3, y0 + hh + 0.2), } for key, (qx, qy) in QD.items(): label = quadrants.get(key, "") if label: self._text(slide, qx, qy, hw - 0.5, 0.64, label, size=11, italic=True, color=self.C["muted"]) # Axis labels ax_lbl = pick(axis_x, "label", "x") ay_lbl = pick(axis_y, "label", "y") if ax_lbl: self._text(slide, mx0, y0 + MAT_H + 0.12, MAT_W, AXIS_LBL, ax_lbl, size=13, bold=True, color=self.C["navy"], align=PP_ALIGN.CENTER) if ay_lbl: # Axe Y : flèche verticale + label court à gauche de la matrice ln_ay = slide.shapes.add_connector(1, Cm(self.MX + AXIS_LBL * 0.5), Cm(y0 + MAT_H), Cm(self.MX + AXIS_LBL * 0.5), Cm(y0)) ln_ay.line.color.rgb = hex_to_rgb(self.C["navy"]) ln_ay.line.width = Pt(1.5) self._text(slide, self.MX, y0 - 0.76, AXIS_LBL, 0.64, f"↑ {ay_lbl}", size=12, bold=True, color=self.C["navy"]) # Axis extremities for lbl, pos, anchor in [ (pick(axis_x, "low", "low_x", ""), (mx0, y0 + MAT_H + 0.1), PP_ALIGN.LEFT), (pick(axis_x, "high", "high_x", ""), (mx0 + MAT_W - 1.5, y0 + MAT_H + 0.1), PP_ALIGN.RIGHT), (pick(axis_y, "high", "high_y", ""), (self.MX, y0 + 0.1), PP_ALIGN.CENTER), (pick(axis_y, "low", "low_y", ""), (self.MX, y0 + MAT_H - 0.8), PP_ALIGN.CENTER), ]: if lbl: self._text(slide, pos[0], pos[1], 1.5, 0.5, lbl, size=10, italic=True, color=self.C["muted"], align=anchor) # Items as numbered circles for i, item in enumerate(items): if not isinstance(item, dict): continue ix = item.get("x", 50) # 0-100 iy = item.get("y", 50) # 0-100 px = mx0 + (ix / 100) * MAT_W py = y0 + ((100 - iy) / 100) * MAT_H self._oval(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, self.C["coral"]) self._text(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, ITEM_D, str(i + 1), size=12, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # Legend below x-axis label if items: leg_y = y0 + MAT_H + AXIS_LBL * 0.55 leg_x = mx0 for i, item in enumerate(items): lx = leg_x + i * 3.56 if lx + 3.3 > self.SLIDE_W - self.MX: break self._oval(slide, lx, leg_y - 0.3, 0.5, self.C["coral"]) self._text(slide, lx, leg_y - 0.3, 0.5, 0.5, str(i+1), size=9, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) self._text(slide, lx + 0.6, leg_y - 0.35, 2.8, 0.6, as_label(item), size=10, color=self.C["body"]) # ── FLUX LIBRE — freeform (palier 3, charte imposée) ────────────────────── # # Une slide freeform : { layout: freeform, mode: light|dark, blocks: [...] } # Chaque bloc est positionné sur une grille 12x12 (col 0-12, row 0-12), # ce qui borne le positionnement et évite les débordements. # Les couleurs ne peuvent être que des TOKENS de charte (imposé). # Grille libre : 12 colonnes, 12 lignes, sur la zone utile (hors marges) FREE_COLS = 12 FREE_ROWS = 12 # Tokens de couleur autorisés (charte imposée — aucune couleur arbitraire) def _token_color(self, token: str, default: str = None) -> str: mapping = { "navy": self.C["navy"], "navy_light": self.C["navy2"], "coral": self.C["coral"], "glacier": self.C["glacier"], "slate": self.C["slate"], "card": self.C["card"], "white": self.C["white"], "body": self.C["body"], "muted": self.C["muted"], } return mapping.get((token or "").strip().lower(), default or self.C["navy"]) def _free_x(self, col: float) -> float: """Colonne de grille (0-12) → position x en cm (dans la zone utile).""" usable = self.SLIDE_W - 2 * self.MX return self.MX + (col / self.FREE_COLS) * usable def _free_y(self, row: float) -> float: """Ligne de grille (0-12) → position y en cm (zone titre→footer).""" top = self.TITLE_Y usable = self.FOOTER_Y - 0.4 - top return top + (row / self.FREE_ROWS) * usable def _free_w(self, cols: float) -> float: usable = self.SLIDE_W - 2 * self.MX return (cols / self.FREE_COLS) * usable def _free_h(self, rows: float) -> float: usable = self.FOOTER_Y - 0.4 - self.TITLE_Y return (rows / self.FREE_ROWS) * usable def _render_freeform(self, slide, d): mode = d.get("mode", "light") on_dark = (mode == "dark") if on_dark: self._bg(slide, self.C["navy"]) default_text = self.C["white"] if on_dark else self.C["body"] for blk in d.get("blocks", []): btype = (blk.get("type") or "text").lower() col = float(blk.get("col", 0)) row = float(blk.get("row", 0)) w_cols = float(blk.get("w", 4)) h_rows = float(blk.get("h", 1)) x, y = self._free_x(col), self._free_y(row) w, h = self._free_w(w_cols), self._free_h(h_rows) if btype == "rect": self._rect(slide, x, y, w, h, self._token_color(blk.get("color"), self.C["card"]), rounded=blk.get("rounded", False)) elif btype == "card": self._card(slide, x, y, w, h, self._token_color(blk.get("color"), self.C["card"])) elif btype == "circle": d_cm = min(w, h) self._oval(slide, x, y, d_cm, self._token_color(blk.get("color"), self.C["coral"])) elif btype == "line": ln = slide.shapes.add_connector( 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h)) ln.line.color.rgb = hex_to_rgb( self._token_color(blk.get("color"), self.C["muted"])) ln.line.width = Pt(float(blk.get("weight", 1.25))) elif btype == "badge": d_cm = min(w, h) self._badge(slide, x + d_cm / 2, y + d_cm / 2, d_cm, blk.get("text", ""), fill=self._token_color(blk.get("color"), self.C["navy"])) elif btype == "stat": # Grand chiffre — display, corail par défaut self._text(slide, x, y, w, h, blk.get("text", ""), font=self.F_DISPLAY, size=int(blk.get("size", 72)), bold=True, color=self._token_color(blk.get("color"), self.C["coral"]), align=self._free_align(blk.get("align", "left")), anchor=MSO_ANCHOR.MIDDLE) elif btype in ("title", "heading"): self._text(slide, x, y, w, h, blk.get("text", ""), font=self.F_DISPLAY, size=int(blk.get("size", 28)), bold=True, color=self._token_color( blk.get("color"), self.C["white"] if on_dark else self.C["navy"]), align=self._free_align(blk.get("align", "left")), anchor=MSO_ANCHOR.MIDDLE) else: # text # Police body, ou display si explicitement demandé font = self.F_DISPLAY if blk.get("serif") else self.F_BODY self._text(slide, x, y, w, h, blk.get("text", ""), font=font, size=int(blk.get("size", 16)), bold=blk.get("bold", False), italic=blk.get("italic", False), color=self._token_color(blk.get("color"), default_text), align=self._free_align(blk.get("align", "left")), anchor=MSO_ANCHOR.TOP) def _free_align(self, a: str): return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get((a or "left").lower(), PP_ALIGN.LEFT) # ---------------- orchestration ---------------- REGISTRY = { "cover_split": "_render_cover_split", "executive_summary": "_render_executive_summary", "section_divider": "_render_section_divider", "big_stat": "_render_big_stat", "two_cols_text": "_render_two_cols_text", "kpi_grid": "_render_kpi_grid", "key_message": "_render_key_message", "circular_diagram": "_render_circular_diagram", "default_bullets": "_render_default_bullets", "numbered_steps": "_render_numbered_steps", "phases_timeline": "_render_phases_timeline", "recommendation_card": "_render_recommendation_card", "end_slide": "_render_end_slide", "freeform": "_render_freeform", "from_to_pairs": "_render_from_to_pairs", "gantt_timeline": "_render_gantt_timeline", "yearly_timeline": "_render_yearly_timeline", "comparison_table": "_render_comparison_table", "raci_table": "_render_raci_table", "process_arrow": "_render_process_arrow", "org_chart": "_render_org_chart", "matrix_2x2": "_render_matrix_2x2", } def render(self, data, output_path: str): if isinstance(data, str): data = yaml.safe_load(data) slides = data.get("slides", data) if isinstance(data, dict) else data prs = Presentation() prs.slide_width = Cm(self.SLIDE_W) prs.slide_height = Cm(self.SLIDE_H) blank = prs.slide_layouts[6] self._section_counter = 0 excluded = set(self.theme["signature"]["footer_excluded_layouts"]) for i, sd in enumerate(slides): slide = prs.slides.add_slide(blank) layout = sd.get("layout", "default_bullets") self._slide_num = i + 1 method = self.REGISTRY.get(layout) if not method: print(f" ⚠ Layout inconnu '{layout}' → default_bullets") method = "_render_default_bullets" layout = "default_bullets" if layout == "freeform": # Le freeform gère son fond lui-même selon sd["mode"] if sd.get("mode", "light") == "light": self._bg(slide, self.C["white"]) getattr(self, method)(slide, sd) if sd.get("footer", True): self._footer(slide, i + 1) else: mode = self.layouts.get(layout, {}).get("mode", "light") if mode == "light": self._bg(slide, self.C["white"]) getattr(self, method)(slide, sd) if layout not in excluded and layout != "recommendation_card": self._footer(slide, i + 1) prs.save(output_path) print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") def main(): import argparse parser = argparse.ArgumentParser( description="Sliding render_engine V2 — PR Editorial") parser.add_argument("input_file", help="Fichier YAML/JSON de la présentation") parser.add_argument("output", help="Chemin du PPTX à générer") parser.add_argument("--theme", default="theme_v2.yaml") parser.add_argument("--components", default="components_v2.yaml") parser.add_argument("--layouts", default="layouts_v2.yaml") args = parser.parse_args() for f in [args.input_file, args.theme, args.components, args.layouts]: if not os.path.exists(f): print(f"✗ Fichier introuvable : {f}") sys.exit(1) with open(args.input_file, encoding="utf-8") as f: data = yaml.safe_load(f) engine = RenderEngineV2(args.theme, args.layouts, args.components) engine.render(data, args.output) if __name__ == "__main__": main()